Verify webhook signatures
Every webhook RenderScreenshot sends carries an HMAC-SHA256 signature. Your endpoint must check it before it trusts the body, reject old timestamps so a captured request cannot be replayed, and ignore deliveries it has already handled. This guide shows all three in the six official SDKs. It is part of the guides hub; event types and payloads are in the webhooks reference.
Every receiver below was run on 2026-10-15 against a sender that signs exactly as our webhook service does. Each one returned 200 for a valid delivery, 200 without processing again for a retry, and 401 for a changed body, a wrong secret and a 10-minute-old timestamp. Versions: Node 22.18 with Express 4.22 and renderscreenshot 1.1.0, Python 3.14 with Flask 3.1, Rails 8.1 on Ruby 4.0 with the renderscreenshot gem 1.0.0, PHP 8.5, Go 1.27 with rs-go 1.1.0. The Java listing was not run.
What the signature covers
Each delivery has three headers:
| Header | Example | Meaning |
|---|---|---|
X-Webhook-Timestamp |
1792644548 |
Unix seconds when this attempt was sent |
X-Webhook-Signature |
sha256=4f1c... |
HMAC of the timestamp and the body |
X-Webhook-ID |
whk_9a2b... |
ID of this delivery attempt |
The signed message is the timestamp, a dot, and the raw body:
signature = "sha256=" + hex( HMAC_SHA256( key = webhook_secret, message = timestamp + "." + raw_body ) )
The key is the full signing secret from Dashboard → Webhooks, including its whsec_ prefix. Because the timestamp is inside the HMAC, an attacker cannot take an old delivery and attach a new timestamp to it.
Three rules that prevent most failures
1. Verify the raw bytes. Our sender serializes JSON with Rails, which escapes &, < and > inside strings as \u0026, \u003c and \u003e. A screenshot URL with a query string, such as https://example.com/search?q=a&page=2, therefore arrives as ...q=a\u0026page=2. If your framework parses the body and you call JSON.stringify on the result, you get & back, the bytes differ, and the check fails. We reproduced this with Express: express.json() plus JSON.stringify(req.body) rejected a valid delivery, and express.raw() accepted it. Read the body as bytes on this route.
2. Reject old timestamps. All SDK helpers reject a timestamp more than 300 seconds from your server's clock. Each retry is signed again with a new timestamp, so a legitimate retry still passes. Keep your server clock synced with NTP.
3. Deduplicate on the body, not the header. A delivery that times out or gets a non-2xx response is retried, up to 5 attempts. The retry has a new X-Webhook-ID and a new timestamp, but the same JSON body. The pair event + id (for example screenshot.completed:req_0a1b2c3d4e5f6a7b) identifies the event. Store it with a unique constraint and skip events you have already seen.
Also answer quickly. The sender waits up to 10 seconds for your response. Verify, record the event, return 200, and do slow work (downloads, image processing) in a background job.
Receivers in six languages
Node.js
// server.mjs - Node 20+, Express 4 or 5, renderscreenshot SDK import express from 'express'; import { verifyWebhook } from 'renderscreenshot'; const app = express(); const seen = new Set(); // use a database unique index or Redis SET NX in production // express.raw keeps the exact bytes that were signed. Do not use express.json() on this route. app.post('/webhooks/renderscreenshot', express.raw({ type: 'application/json' }), (req, res) => { const payload = req.body.toString('utf8'); const signature = req.get('X-Webhook-Signature') ?? ''; const timestamp = req.get('X-Webhook-Timestamp') ?? ''; // verifyWebhook also rejects timestamps more than 5 minutes from now if (!verifyWebhook(payload, signature, timestamp, process.env.WEBHOOK_SECRET)) { return res.status(401).send('invalid signature'); } const event = JSON.parse(payload); const key = `${event.event}:${event.id}`; if (seen.has(key)) return res.sendStatus(200); // retry of a delivery we already handled seen.add(key); res.sendStatus(200); // answer fast, then do the work setImmediate(() => handle(event)); }); function handle(event) { if (event.event === 'screenshot.completed') { console.log('ready:', event.data.url, '->', event.data.screenshot_url); } else if (event.event === 'screenshot.failed') { console.error('failed:', event.data.url, event.data.error); } } app.listen(process.env.PORT ?? 3000);
If an app-wide express.json() runs first, mount this route before it, or the raw body is gone.
Python
# app.py - Python 3.9+, Flask 3, renderscreenshot SDK import json import os from flask import Flask, request from renderscreenshot import verify_webhook app = Flask(__name__) seen = set() # use a database unique index or Redis SET NX in production @app.post("/webhooks/renderscreenshot") def renderscreenshot_webhook(): payload = request.get_data(as_text=True) # the exact bytes that were signed signature = request.headers.get("X-Webhook-Signature", "") timestamp = request.headers.get("X-Webhook-Timestamp", "") # tolerance defaults to 300 seconds; it rejects old (replayed) deliveries if not verify_webhook(payload, signature, timestamp, os.environ["WEBHOOK_SECRET"]): return "invalid signature", 401 event = json.loads(payload) key = f"{event['event']}:{event['id']}" if key in seen: return "", 200 # retry of a delivery we already handled seen.add(key) enqueue(event) # hand off to your job queue, then answer return "", 200 def enqueue(event): print("queued", event["event"], event["data"]["url"])
In Django, use request.body.decode() and mark the view @csrf_exempt. The signature replaces the CSRF check for this one endpoint.
Ruby (Rails)
# app/controllers/renderscreenshot_webhooks_controller.rb class RenderscreenshotWebhooksController < ActionController::API def create payload = request.raw_post # the exact bytes that were signed valid = RenderScreenshot::Webhook.verify( payload: payload, signature: request.headers['X-Webhook-Signature'].to_s, timestamp: request.headers['X-Webhook-Timestamp'].to_s, secret: ENV.fetch('WEBHOOK_SECRET') # tolerance: 300 seconds by default ) return head :unauthorized unless valid event = JSON.parse(payload) key = "#{event['event']}:#{event['id']}" # Rails.cache.write(..., unless_exist: true) returns false when the key is already there if Rails.cache.write("rs-webhook:#{key}", true, unless_exist: true, expires_in: 1.day) ScreenshotReadyJob.perform_later(event) end head :ok end end
Inheriting from ActionController::API skips CSRF protection. If your controller inherits from ActionController::Base, add skip_forgery_protection.
PHP
<?php // webhook.php - PHP 8.0+, renderscreenshot/renderscreenshot SDK require __DIR__ . '/vendor/autoload.php'; use RenderScreenshot\Webhook; $payload = file_get_contents('php://input'); // the exact bytes that were signed [$signature, $timestamp] = Webhook::extractHeaders($_SERVER); // The default tolerance of 300 seconds rejects old (replayed) deliveries if (!Webhook::verify($payload, $signature, $timestamp, getenv('WEBHOOK_SECRET'))) { http_response_code(401); exit('invalid signature'); } $event = json_decode($payload, true); // Insert-or-ignore on a unique key makes retries harmless $db = new PDO('sqlite:' . __DIR__ . '/webhooks.sqlite'); $db->exec('CREATE TABLE IF NOT EXISTS webhook_events (event_key TEXT PRIMARY KEY, body TEXT)'); $insert = $db->prepare('INSERT OR IGNORE INTO webhook_events (event_key, body) VALUES (?, ?)'); $insert->execute([$event['event'] . ':' . $event['id'], $payload]); if ($insert->rowCount() === 1) { // First time we see this event: queue the real work here error_log("queued {$event['event']} {$event['data']['url']}"); } http_response_code(200);
In Laravel, read $request->getContent() and exclude the route from the VerifyCsrfToken middleware.
Go
// main.go - Go 1.22+, github.com/Render-Screenshot/rs-go package main import ( "encoding/json" "io" "log" "net/http" "os" "sync" rs "github.com/Render-Screenshot/rs-go" ) var ( mu sync.Mutex seen = map[string]bool{} // use a database unique index or Redis SET NX in production ) func webhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // the exact bytes that were signed if err != nil { http.Error(w, "bad body", http.StatusBadRequest) return } ok := rs.VerifyWebhook(string(body), r.Header.Get("X-Webhook-Signature"), r.Header.Get("X-Webhook-Timestamp"), os.Getenv("WEBHOOK_SECRET"), rs.DefaultTolerance) // 5 minutes; rejects old (replayed) deliveries if !ok { http.Error(w, "invalid signature", http.StatusUnauthorized) return } var event struct { Event string `json:"event"` ID string `json:"id"` Data json.RawMessage `json:"data"` } if err := json.Unmarshal(body, &event); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } key := event.Event + ":" + event.ID mu.Lock() dup := seen[key] seen[key] = true mu.Unlock() w.WriteHeader(http.StatusOK) if !dup { go handle(event.Event, event.Data) // answer first, then work } } func handle(kind string, data json.RawMessage) { log.Printf("handled %s %s", kind, data) } func main() { http.HandleFunc("POST /webhooks/renderscreenshot", webhook) log.Fatal(http.ListenAndServe(":"+os.Getenv("PORT"), nil)) }
Java (Spring Boot)
// RenderScreenshotWebhookController.java - Spring Boot 3, rs-java (not run for this guide) import com.renderscreenshot.sdk.Webhook; import com.renderscreenshot.sdk.Webhook.WebhookEvent; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController public class RenderScreenshotWebhookController { // use a database unique index or Redis SET NX in production private final Set<String> seen = ConcurrentHashMap.newKeySet(); @PostMapping("/webhooks/renderscreenshot") public ResponseEntity<String> receive( @RequestBody String payload, // a String body keeps the exact bytes that were signed @RequestHeader("X-Webhook-Signature") String signature, @RequestHeader("X-Webhook-Timestamp") String timestamp) { // 300-second tolerance by default; rejects old (replayed) deliveries if (!Webhook.verify(payload, signature, timestamp, System.getenv("WEBHOOK_SECRET"))) { return ResponseEntity.status(401).body("invalid signature"); } WebhookEvent event = Webhook.parse(payload); if (seen.add(event.getType() + ":" + event.getId())) { // first time we see this event: queue the real work here } return ResponseEntity.ok(""); } }
Do not bind the body to a DTO class on this route. Jackson would parse it, and the original bytes would be lost.
Without an SDK
The check is about ten lines in any language. The rules are the same as above: raw body, constant-time comparison, timestamp window.
import hashlib import hmac import time def verify(raw_body: bytes, signature: str, timestamp: str, secret: str, tolerance: int = 300) -> bool: try: if abs(time.time() - int(timestamp)) > tolerance: return False except ValueError: return False message = timestamp.encode() + b"." + raw_body expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature)
Compare with a constant-time function (hmac.compare_digest, crypto.timingSafeEqual, hash_equals, subtle.ConstantTimeCompare, MessageDigest.isEqual). A plain == can leak how many leading characters matched.
Test your endpoint
- Use Send Test in Dashboard → Webhooks. It sends a signed event with
"event": "test". Your handler should verify it and return 200 even though it does not act on that event type. - To test locally, expose your port with a tunnel such as ngrok and set the tunnel URL as the webhook URL.
- In your test suite, sign a fixture body with a test secret and the current time, and assert 200. Then change one byte of the body and assert 401. The SDK testing guide shows a complete fixture.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Every delivery fails, secret is correct | Handler verifies a re-serialized body | Read the raw body on this route |
Fails only when the page URL has &, < or > |
Same cause; those characters arrive escaped as \u0026 and similar |
Read the raw body |
| Works locally, fails in production | A proxy or middleware rewrites the body, or the server clock is wrong | Verify before any body middleware; sync the clock with NTP |
| Fails after you rotated the secret | Old secret still in your environment | Update WEBHOOK_SECRET and redeploy |
| The same screenshot is processed twice | Deduplication uses X-Webhook-ID |
Deduplicate on event + id from the body |
| Retries keep arriving | Handler takes more than 10 seconds or returns non-2xx | Return 200 first, then work in a job |
| 403 from your framework | CSRF protection on the route | Exempt this route; the signature is the protection |
Related
- Webhooks reference: events, payloads and the retry schedule.
- Async screenshots with webhooks: when to use webhooks instead of waiting for the response.
- Screenshots in background jobs: where the webhook handler hands off work.
- Raw HTTP: the signed-URL HMAC, which uses a different canonical string.