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 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 without webhook.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 in this topic

Was this page helpful?