Screenshot API webhooks tell your server when work finishes without you holding a connection open. In RenderScreenshot there are three cases. A POST /v1/screenshot with webhook.url in the body runs asynchronously and sends one screenshot.completed or screenshot.failed event to that URL. A batch (POST /v1/batch) with more than 10 URLs, or with webhook.url, 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 only | The image, as usual | screenshot.completed / screenshot.failed, per request |
POST /v1/screenshot |
webhook.url in the body |
202 with {"id": "job_...", "status": "processing", "webhook": "..."} |
screenshot.completed (and screenshot.failed if listed in webhook.events) |
POST /v1/batch, 10 URLs or fewer |
No webhook.url (a dashboard webhook does not count) |
All results in the response | None |
POST /v1/batch, more than 10 URLs |
No webhook.url |
202 with a batch id; poll GET /v1/batch/:id |
batch.completed / batch.failed to the dashboard webhook, if one is set |
POST /v1/batch, any size |
webhook.url in the body |
202 with a batch id |
batch.completed / batch.failed |
Only webhook.url in the request body, or a batch of more than 10 URLs, makes work asynchronous. A dashboard webhook never turns a request or a small batch async.
A single screenshot with a webhook
curl -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/", "preset": "og_card", "webhook": { "url": "https://your-app.example.com/webhooks/renderscreenshot", "events": ["completed", "failed"] } }'
The API answers 202 at once with {"id": "job_...", "status": "processing", "webhook": "https://your-app.example.com/..."}. The job id is the same value as the event's id, so you can match the event to the request. webhook.events defaults to ["completed"]; add "failed" to hear about failures too. webhook.secret, when given, signs this request's events instead of the dashboard secret. A credit is charged only when the render succeeds. webhook.url must be a public http or https URL whose host resolves to public addresses when you send the request, else the request fails with invalid_parameter. If a dashboard webhook is also set to a different URL, it still receives its own 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 (the message) and data.error_code (for example timeout or blocked) instead of the image fields. Batch items in GET /v1/batch/:id carry the same error_code. 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, or is the webhook.secret you sent with a single request.
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 withoutwebhook.url, 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 |
A small batch returns 202 |
The body has webhook.url |
Remove webhook.url for inline results, or handle the event |
A single screenshot returns 202 instead of an image |
The body has webhook.url |
Remove it for a synchronous image, or handle screenshot.completed |
| 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.