Screenshot API webhooks tell your server when work finishes without you holding a connection open. In RenderScreenshot there are two cases. A batch (POST /v1/batch) with more than 10 URLs, or with a webhook, runs asynchronously and sends one batch.completed or batch.failed event. A webhook URL set in the dashboard also sends a screenshot.completed or screenshot.failed event for every single request. Every delivery is signed with HMAC-SHA256. This guide shows which requests run asynchronously and why, the delivery flow, a tested receiver, and how retries and duplicates behave, so you can decide between webhooks and polling. It belongs to the guides; the event reference is webhooks.
Tested on October 15, 2026: the Node.js 22.18.0 receiver (and Flask 3.1 and Ruby versions of it) against payloads signed exactly as the RenderScreenshot API signs them (same HMAC input, same JSON escaping), including a tampered body, an old timestamp and a repeated delivery. The webhook helpers in the Node.js, Python, Ruby, PHP and Go SDKs accepted the same signed payload. The async rules below come from the API source.
When work runs asynchronously
| Request | Webhook configured? | What you get back | Event |
|---|---|---|---|
POST /v1/screenshot or GET |
No | The image (or JSON) | None |
POST /v1/screenshot or GET |
Dashboard webhook | The image, as usual | screenshot.completed / screenshot.failed, per request |
POST /v1/batch, 10 URLs or fewer |
No | All results in the response | None |
POST /v1/batch, more than 10 URLs |
No | 202 with a batch id; poll GET /v1/batch/:id |
None |
POST /v1/batch, any size |
webhook.url in the body, or a dashboard webhook |
202 with a batch id |
batch.completed / batch.failed |
One consequence is easy to miss: once a dashboard webhook is set, every batch runs asynchronously, even a batch of two URLs, because the dashboard URL is used when the request has no webhook.url. If your code expects small batches to return results inline, poll or handle the event.
The flow
sequenceDiagram participant App as Your app participant API as RenderScreenshot API participant Hook as Your webhook endpoint App->>API: POST /v1/batch (urls, webhook.url) API-->>App: 202 { id, status: "processing" } API->>API: render items, plan concurrency API->>Hook: POST batch.completed (signed) Hook-->>API: 200 within 10 s Hook->>API: GET results_url (Bearer key) API-->>Hook: per-item results
Start a batch with a webhook
curl -X POST https://api.renderscreenshot.com/v1/batch \ -H "Authorization: Bearer $RS_API_KEY" -H "Content-Type: application/json" \ -d '{ "urls": ["https://example.com/", "https://example.org/"], "options": { "preset": "og_card" }, "webhook": { "url": "https://your-app.example.com/webhooks/renderscreenshot" } }'
The response is 202 with {"id": "batch_...", "status": "processing", ...}. You can also check progress at any time; this is the backstop if an event never arrives:
curl https://api.renderscreenshot.com/v1/batch/batch_abc123 -H "Authorization: Bearer $RS_API_KEY"
What arrives
Headers on every delivery: X-Webhook-Signature (sha256= followed by a hex HMAC), X-Webhook-Timestamp (Unix seconds), X-Webhook-ID and User-Agent: RenderScreenshot-Webhook/1.0. The body for a single screenshot, pretty-printed here but with the escaping the API uses:
{ "event": "screenshot.completed", "id": "req_abc123", "timestamp": "2026-10-15T12:00:00Z", "data": { "url": "https://example.com/?a=1\u0026b=2", "screenshot_url": "https://cdn.renderscreenshot.com/screenshots/ab12.png", "width": 1200, "height": 630, "size": 48213, "format": "png", "cached": false } }
A screenshot.failed event has data.error instead of the image fields. A batch.completed event has data.summary (total, completed, failed) and data.results_url; fetch that URL with your API key for the per-item results.
Verify the signature on the raw body
This section covers what an async integration needs; verify webhook signatures has complete receivers for all six SDKs and framework notes.
The signature is sha256= + hex(HMAC-SHA256(secret, "{X-Webhook-Timestamp}.{raw body}")). The secret is in Dashboard, Webhooks.
Verify the bytes you received, not JSON you rebuilt. The API escapes &, < and > in the JSON body as \u0026, \u003c and \u003e. Parse and re-stringify that body and you get a plain &, which changes the HMAC. In our test, the raw body verified and the re-serialized body of the same event did not. Any event whose URL has a query string with & triggers it, so the bug shows up only on some events.
Node.js
// receiver.mjs: verify and handle RenderScreenshot webhooks. Node 22+, no dependencies. import http from 'node:http'; import { createHmac, timingSafeEqual } from 'node:crypto'; const SECRET = process.env.WEBHOOK_SECRET; const TOLERANCE_S = 300; // reject deliveries signed more than 5 minutes ago const seen = new Set(); // use Redis or a unique DB index in production export function verify(rawBody, signature = '', timestamp = '', secret = SECRET) { const ts = Number(timestamp); if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_S) return false; const expected = 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b); } async function handle(event) { switch (event.event) { case 'screenshot.completed': console.log('ready', event.data.url, event.data.screenshot_url); break; case 'screenshot.failed': console.log('failed', event.data.url, event.data.error); break; case 'batch.completed': case 'batch.failed': console.log(event.event, event.id, event.data.summary, event.data.results_url); break; default: console.log('ignored', event.event); } } const server = http.createServer((req, res) => { if (req.method !== 'POST' || req.url !== '/webhooks/renderscreenshot') { res.writeHead(404).end(); return; } const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); // verify the bytes as received, never re-serialized JSON if (!verify(raw, req.headers['x-webhook-signature'], req.headers['x-webhook-timestamp'])) { res.writeHead(401).end('invalid signature'); return; } const event = JSON.parse(raw); const dedupeKey = `${event.event}:${event.id}`; // stable across retries; X-Webhook-ID is not res.writeHead(200).end('ok'); // answer within the 10-second delivery timeout if (seen.has(dedupeKey)) return; seen.add(dedupeKey); handle(event).catch((err) => console.error('handler failed', err)); }); }); if (import.meta.url === `file://${process.argv[1]}`) { server.listen(Number(process.env.PORT ?? 3000), () => console.log('listening')); }
Test result: a valid delivery returned 200, the same event delivered again returned 200 without running the handler twice, and a bad signature returned 401. A timestamp 10 minutes old was rejected.
With Express, use express.raw({ type: 'application/json' }) on this route so req.body is a Buffer, and pass req.body.toString('utf8') to verify.
SDK helpers
Each SDK has a verifier with a 5-minute timestamp window. Pass the raw body as a string.
// Node.js import { verifyWebhook } from 'renderscreenshot'; const ok = verifyWebhook(rawBody, req.headers['x-webhook-signature'], req.headers['x-webhook-timestamp'], process.env.WEBHOOK_SECRET);
# Python from renderscreenshot import verify_webhook ok = verify_webhook(request.get_data(as_text=True), request.headers['X-Webhook-Signature'], request.headers['X-Webhook-Timestamp'], os.environ['WEBHOOK_SECRET'])
# Ruby ok = RenderScreenshot::Webhook.verify(payload: request.raw_post, signature: request.headers['X-Webhook-Signature'], timestamp: request.headers['X-Webhook-Timestamp'], secret: ENV.fetch('WEBHOOK_SECRET'))
// PHP $ok = RenderScreenshot\Webhook::verify(file_get_contents('php://input'), $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'], $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'], getenv('WEBHOOK_SECRET'));
// Java boolean ok = Webhook.verify(rawBody, request.getHeader("X-Webhook-Signature"), request.getHeader("X-Webhook-Timestamp"), System.getenv("WEBHOOK_SECRET"));
// Go ok := rs.VerifyWebhook(string(rawBody), r.Header.Get("X-Webhook-Signature"), r.Header.Get("X-Webhook-Timestamp"), os.Getenv("WEBHOOK_SECRET"), rs.DefaultTolerance)
The Node.js, Python, Ruby, PHP and Go helpers returned true for the signed test payload. The Java helper was not run. After verifying, parse the raw body yourself and read the fields shown above.
Retries, timeouts and duplicates
- Timeout. Each delivery waits up to 10 seconds for your response. Return
2xxfirst, then do the work in a job. - Retries. Any non-
2xxresponse, timeout or connection error is retried, up to 5 attempts in total with growing gaps between them. After the last attempt the event is dropped. The webhooks reference lists the schedule. - Duplicates. A delivery can arrive twice, for example when your server processed it but the response was lost. Each attempt is signed again, so
X-Webhook-ID,X-Webhook-Timestamp, the body'stimestampand the signature all change between attempts. Deduplicate oneventplusidfrom the body. - Long outages. Retries do not cover an endpoint that is down for a long time. For batches, poll
GET /v1/batch/:idfor any batch you started but never heard about. For single screenshots, the synchronous response already had the result; treat the event as a notification.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Signature fails on some events only | Verifying re-serialized JSON; URLs with & change |
Verify the raw body |
| Signature fails on every event | Wrong secret, or a framework already parsed and replaced the body | Copy the secret from Dashboard, Webhooks; read the raw body |
| Valid events rejected as too old | Server clock drift | Sync the clock (NTP); keep a 5-minute window |
| Same event handled twice | Deduplicating on X-Webhook-ID |
Deduplicate on event + id from the body |
| Small batches no longer return results | A dashboard webhook makes every batch async | Poll or use the event; or remove the dashboard webhook |
| No events at all | Webhook URL not reachable from the internet | Use the dashboard Send Test button; expose local servers with a tunnel |
| Events stop during a deploy | Endpoint returned 5xx for longer than the retries last | Poll batches you started; return 503 only briefly |
Related
- Webhooks reference: event types and headers.
- Verify webhook signatures: receivers in all six SDK languages.
- Bulk screenshots: batch sizes per plan, polling and partial failures.
- Batch endpoint: request and response fields.
- Set up a test endpoint with a free API key.