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

  1. 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.
  2. To test locally, expose your port with a tunnel such as ngrok and set the tunnel URL as the webhook URL.
  3. 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 in this topic

Was this page helpful?