A webhook is an HTTP request that one system sends to a URL you registered, at the moment an event happens, so your application learns about the event without asking for it.
People also call it a "reverse API" or an "HTTP callback". For screenshots, the event is usually "the capture is ready" or "the capture failed". This entry is part of the screenshot glossary.
What it means in plain terms
There are two ways to find out that a slow job has finished:
- Polling. Your code asks "done yet?" every few seconds. It is simple and works behind any firewall, but most requests are wasted, and you learn about the result up to one interval late.
- Webhook. You give the other system a URL once. When the job finishes, it sends a
POSTto that URL with the result. No wasted requests, no delay, but your endpoint must be reachable from the internet and must check that the request is genuine.
Webhooks make sense when work takes longer than a client wants to hold a connection open: a batch of a few hundred captures, a long full-page PDF, or a pipeline step that should start as soon as an image exists.
How a webhook delivery works
sequenceDiagram participant App as Your app participant Svc as Screenshot service participant Hook as Your webhook endpoint App->>Svc: start job (URLs, options) Svc-->>App: 202 Accepted, job id Note over Svc: render in the background Svc->>Hook: POST event JSON + signature headers Hook->>Hook: verify signature, check timestamp, dedupe Hook-->>Svc: 200 OK (fast) Hook->>App: enqueue follow-up work Note over Svc,Hook: no 2xx? the sender retries later
Four properties make a webhook safe and reliable:
- A signature. The sender signs the body with a shared secret, usually an HMAC. Anyone can send a
POSTto your URL; only the sender knows the secret. - A timestamp inside the signature. It lets you reject an old request that someone recorded and sends again.
- Retries. If your endpoint is down or answers with an error, the sender tries again later.
- An event ID. Because of retries, the same event can arrive more than once. The ID lets you skip repeats.
Do-it-yourself: a capture worker that calls a webhook
Puppeteer and Playwright have no webhook feature. They run inside your process and return a buffer. If a self-hosted capture worker must report to another service, it sends the callback itself. This worker captures a page with Playwright and posts a signed event, with retries and backoff:
// worker.mjs — CALLBACK_URL=... WEBHOOK_SECRET=... node worker.mjs https://example.com import { createHmac, randomUUID } from 'node:crypto'; import { chromium } from 'playwright'; const target = process.argv[2] ?? 'https://example.com'; const jobId = randomUUID(); async function notify(event, data) { const body = JSON.stringify({ event, id: jobId, data }); const timestamp = Math.floor(Date.now() / 1000).toString(); const signature = 'sha256=' + createHmac('sha256', process.env.WEBHOOK_SECRET) .update(`${timestamp}.${body}`).digest('hex'); for (let attempt = 1; attempt <= 3; attempt++) { try { const res = await fetch(process.env.CALLBACK_URL, { method: 'POST', headers: { 'content-type': 'application/json', 'x-timestamp': timestamp, 'x-signature': signature }, body, signal: AbortSignal.timeout(10_000), }); if (res.ok) return; console.warn(`attempt ${attempt}: HTTP ${res.status}`); } catch (err) { console.warn(`attempt ${attempt}: ${err.message}`); } await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); // back off before retrying } throw new Error('webhook delivery failed after 3 attempts'); } let result; const browser = await chromium.launch(); try { const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); await page.goto(target, { waitUntil: 'load', timeout: 30_000 }); const file = `${jobId}.png`; await page.screenshot({ path: file }); result = ['capture.completed', { url: target, file }]; } catch (err) { result = ['capture.failed', { url: target, error: err.message.split('\n')[0] }]; process.exitCode = 1; } finally { await browser.close(); } await notify(...result); // throws (exit code 1) if every delivery attempt fails
The receiving side reads the raw body, recomputes the HMAC over timestamp.body, compares it with crypto.timingSafeEqual, rejects timestamps older than 5 minutes, answers 200, and skips event IDs it has already stored.
We ran the worker against a local receiver on 2026-10-15 (Node 22.18.0, [email protected]). Three cases behaved as intended:
| Case | Receiver | Worker |
|---|---|---|
| Capture of https://example.com, correct secret | accepted capture.completed |
exit 0 |
| Same capture, wrong secret | rejected 3 attempts with 401 | exit 1 after the last retry |
| Unresolvable host | accepted capture.failed with net::ERR_NAME_NOT_RESOLVED |
exit 1 |
The failure case matters as much as the success case. A webhook that only reports success leaves the other side waiting forever.
Webhooks in RenderScreenshot
You do not need to build the sender. Set a webhook URL in Dashboard → Webhooks and copy the signing secret. Every request then also produces a screenshot.completed or screenshot.failed event, and batches produce batch.completed or batch.failed. You can also pass a URL per batch:
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" } }'
Each delivery carries X-Webhook-Signature, X-Webhook-Timestamp and X-Webhook-ID, and a failed delivery is retried with growing delays, up to 5 attempts in total. Inside our Rails app, deliveries run as background jobs after the render is billed and logged, so a slow receiver never delays the API response. The event list and payloads are in the webhooks reference, and receivers for six languages are in verify webhook signatures. The curl request above was not run for this page.
Common mistakes
- Verifying re-serialized JSON. Parse after you verify, and verify the raw bytes.
- Doing slow work before answering. Senders time out and retry. Answer 200 first, then queue the work.
- Deduplicating on a per-delivery header. Use the event ID in the body; delivery IDs change with each retry.
- No fallback. A firewall change can silently stop deliveries. Keep a polling path, such as the batch status endpoint, for reconciliation.
- Plain
==for signatures. Use a constant-time comparison.
Related terms
For the full asynchronous flow, read async screenshots with webhooks. To get a signing secret, create a free account.