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 2xx first, then do the work in a job.
  • Retries. Any non-2xx response, 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's timestamp and the signature all change between attempts. Deduplicate on event plus id from the body.
  • Long outages. Retries do not cover an endpoint that is down for a long time. For batches, poll GET /v1/batch/:id for 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 in this topic

Was this page helpful?