Receive real-time notifications when your screenshots, PDFs, and batch jobs are ready. No polling required.
Webhook notifications allow your application to receive real-time HTTP POST callbacks when capture events occur. Instead of polling our API to check if a screenshot is ready, you configure a webhook URL and we push the results to you as soon as they are available.
Webhooks are especially useful for batch processing, where you submit multiple URLs and want to be notified when results are ready, rather than repeatedly checking status.
capture.completedFired when a screenshot, PDF, or OG image has been successfully generated.
capture.failedFired when a capture request fails due to navigation error, timeout, or rendering issue.
batch.completedFired when all captures in a batch request have been processed (succeeded or failed).
POST to the webhooks API to have CaptureAPI deliver one signed test payload to your endpoint and report whether it answered 2xx. This is a connectivity check, not a subscription: nothing is stored. To receive real events, pass a webhook_url on each capture request.
curl -X POST "https://captureapi.dev/api/v1/webhooks" \
-H "X-API-Key: cap_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://your-app.com/api/webhook",
"events": ["capture.completed", "capture.failed", "batch.completed"]
}'Sent when a single screenshot, PDF, or OG image is successfully generated.
{
"event": "capture.completed",
"timestamp": "2026-03-25T14:30:00Z",
"request_id": "req_abc123def456",
"data": {
"url": "https://example.com",
"type": "screenshot",
"format": "png",
"width": 1280,
"height": 720,
"file_size": 245760,
"duration_ms": 1250
}
}Sent when a capture request fails for any reason.
{
"event": "capture.failed",
"timestamp": "2026-03-25T14:30:05Z",
"request_id": "req_xyz789",
"data": {
"url": "https://invalid-domain.example",
"type": "screenshot",
"error_code": "URL_UNREACHABLE",
"error_message": "The target URL could not be reached. Check that the URL is valid and publicly accessible."
}
}Sent when all items in a batch request have been processed.
{
"event": "batch.completed",
"timestamp": "2026-03-25T14:31:00Z",
"batch_id": "batch_abc123",
"data": {
"total": 10,
"succeeded": 9,
"failed": 1,
"results": [
{
"request_id": "req_001",
"url": "https://example.com",
"status": "completed"
},
{
"request_id": "req_002",
"url": "https://invalid.example",
"status": "failed",
"error_code": "URL_UNREACHABLE"
}
]
}
}Every webhook request includes an X-CaptureAPI-Signature header in the form sha256=<hex>: an HMAC-SHA256 of the raw request body. Always verify it to ensure the webhook is from CaptureAPI and has not been tampered with.
The signing secret is issued by us, not chosen by you — email hello@captureapi.dev to get yours. The optional secret field on the endpoint test above is a different thing: it signs that one test payload only, so a test verified with your own secret proves connectivity, not that your verification code will accept real events. Verify a real delivery too.
import crypto from "crypto";
// The header is "sha256=" followed by 64 hex characters. Compare the hex part,
// not the whole header, and never on a raw string: timingSafeEqual throws when
// the two buffers differ in length.
function verifyWebhookSignature(
rawBody: string,
header: string | undefined,
secret: string
): boolean {
if (!header?.startsWith("sha256=")) return false;
const received = Buffer.from(header.slice("sha256=".length), "hex");
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest();
return (
received.length === expected.length &&
crypto.timingSafeEqual(received, expected)
);
}
// In your webhook handler. Use the RAW request body: re-serialising the parsed
// JSON can reorder keys and change the bytes the signature was computed over.
app.post("/api/webhook", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
const isValid = verifyWebhookSignature(
rawBody,
req.headers["x-captureapi-signature"],
process.env.CAPTUREAPI_WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).json({ error: "Invalid signature" });
}
const { event, data } = JSON.parse(rawBody);
switch (event) {
case "capture.completed":
// Process the completed screenshot
console.log("Capture ready:", data.url, data.file_size, "bytes");
break;
case "capture.failed":
// Handle the failure
console.error("Capture failed:", data.error_message);
break;
case "batch.completed":
// Process batch results
console.log("Batch done:", data.succeeded, "/", data.total);
break;
}
res.status(200).json({ received: true });
});Webhooks are best-effort notifications, not a delivery guarantee. Your endpoint gets exactly one POST with a 10-second timeout; if it returns a non-2xx status or times out, we log it and move on. Treat the webhook as a hint and reconcile against the synchronous response — the capture bytes are in the API response itself, never in the webhook.
| Attempt | Delay | Description |
|---|---|---|
| Single attempt | Immediately after the capture | One POST with a 10-second timeout. A non-2xx or a timeout is logged on our side and the delivery is dropped — there is no retry queue. |
After 5 failed delivery attempts, the webhook event is marked as failed. You can view failed deliveries in your dashboard and manually retry them.
Return a 200 status code as fast as possible. Process the webhook payload asynchronously to avoid timeouts.
Always verify the X-CaptureAPI-Signature header to ensure the request is authentic and has not been modified.
Use the request_id field to deduplicate events. In rare cases, the same event may be delivered more than once.
Webhook URLs must use HTTPS. We do not deliver webhooks to HTTP endpoints for security reasons.
Store raw webhook payloads yourself. We keep no delivery log you can inspect, so your own record is the only one there is.