What Is a Webhook? Definition for Screenshot Jobs

A webhook is an HTTP callback one system sends to another when an event happens. How webhooks work for screenshot jobs, with a tested self-hosted example.

By Simon Chiu · Updated

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 POST to 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:

  1. A signature. The sender signs the body with a shared secret, usually an HMAC. Anyone can send a POST to your URL; only the sender knows the secret.
  2. A timestamp inside the signature. It lets you reject an old request that someone recorded and sends again.
  3. Retries. If your endpoint is down or answers with an error, the sender tries again later.
  4. 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.

For the full asynchronous flow, read async screenshots with webhooks. To get a signing secret, create a free account.

Frequently asked questions

What is the difference between a webhook and an API call?
Direction. With an API call, your code asks another system for something. With a webhook, the other system calls your code when something happens, so you do not have to keep asking.
Are webhooks delivered exactly once?
Usually not. Senders retry when your endpoint fails or answers slowly, so the same event can arrive twice. Store an event ID and ignore repeats.
Do Puppeteer or Playwright send webhooks?
No. They are libraries inside your own process. If you want a callback when a self-hosted capture finishes, your worker has to send the HTTP request itself, as in the example on this page.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.