Verify a webhook signature

Your webhook URL is a public endpoint that anyone can post to. Verifying the signature is what makes a delivery trustworthy — without it, an unverified handler acts on whatever is sent to it by whoever finds the address. Every delivery is signed with HMAC-SHA256 using the destination's signing secret, and verification is about ten lines in any language.

What is signed

The timestamp and the raw body, joined by a full stop. The result is hex-encoded and prefixed with v1= in the header.

The signing scheme

signed_string = "{timestamp}.{raw_body}"
signature     = HMAC_SHA256(signed_string, signing_secret)
header        = "v1=" + hex(signature)

# timestamp comes from X-CountdownShare-Timestamp
# raw_body is the request body EXACTLY as sent, byte for byte
HeaderRole in verification
X-CountdownShare-TimestampUnix seconds. Prefixed to the body before signing, and checked for freshness.
X-CountdownShare-Signaturev1=<hex>. What you compare your computed HMAC against.
X-CountdownShare-Event-IdNot part of the signature. Used for deduplication.
The timestamp is inside the signed string, which is what makes the freshness check meaningful — an attacker cannot take a captured delivery, change the timestamp to now, and have the signature still match.

Why it must be the raw body

HMAC is computed over exact bytes. Parsing JSON and re-serialising it produces a different byte sequence — key order can change, whitespace disappears, numbers reformat — and the signature will not match even though the data is identical.

This is the single most common webhook integration bug, and it is invisible from the payload: the JSON looks right, the secret is right, and verification fails anyway. Capture the body as bytes or a string before any middleware touches it.

FrameworkHow to get the raw body
Expressexpress.raw({ type: "application/json" }) on the route — not express.json()
Next.js route handlerawait request.text() — nothing is pre-parsed
FastifyA content-type parser that preserves the raw buffer
Flaskrequest.get_data() — not request.json
Djangorequest.body — not the parsed data
Laravel$request->getContent()

Working implementations

All three do the same four things: read the headers, check freshness, compute, compare.

Node.js

node:crypto

import { createHmac, timingSafeEqual } from "node:crypto";

// Express: app.post(path, express.raw({ type: "application/json" }), handler)
// The signature covers the RAW body. Parsing it first will break the check.
export function verify(rawBody, headers, secret) {
  const timestamp = headers["x-countdownshare-timestamp"];
  const signature = headers["x-countdownshare-signature"];
  if (!timestamp || !signature) return false;

  // Reject anything older than five minutes — a valid signature
  // captured off the wire is otherwise replayable forever.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const received = signature.replace(/^v1=/, "");

  // Constant-time comparison. === leaks timing information.
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(received, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Python

hmac + hashlib

import hashlib
import hmac
import time


def verify(raw_body: bytes, headers, secret: str) -> bool:
    timestamp = headers.get("X-CountdownShare-Timestamp")
    signature = headers.get("X-CountdownShare-Signature")
    if not timestamp or not signature:
        return False

    if abs(time.time() - int(timestamp)) > 300:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    received = signature.removeprefix("v1=")
    return hmac.compare_digest(expected, received)

PHP

hash_hmac

<?php
function verify(string $rawBody, array $headers, string $secret): bool
{
    $timestamp = $headers['X-CountdownShare-Timestamp'] ?? null;
    $signature = $headers['X-CountdownShare-Signature'] ?? null;

    if (!$timestamp || !$signature) {
        return false;
    }

    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    $received = preg_replace('/^v1=/', '', $signature);

    return hash_equals($expected, $received);
}

Next.js route handler

Worth showing separately because it is the case where getting the raw body is easiest — request.text() returns exactly what was sent.

app/api/webhooks/countdownshare/route.ts

// app/api/webhooks/countdownshare/route.ts
//
// Next.js route handlers do not pre-parse the body, so request.text()
// gives you exactly what was sent.
export async function POST(request: Request) {
  const rawBody = await request.text();

  const headers = {
    "x-countdownshare-timestamp": request.headers.get("x-countdownshare-timestamp"),
    "x-countdownshare-signature": request.headers.get("x-countdownshare-signature"),
  };

  if (!verify(rawBody, headers, process.env.COUNTDOWNSHARE_WEBHOOK_SECRET!)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(rawBody);   // parse only AFTER verifying

  await queue.add("countdown-event", event);
  return new Response(null, { status: 200 });
}
Parse only after verifying. Doing it the other way round means your code has already allocated and inspected data it has not established is genuine.

Four ways this goes wrong

Do not do these

// 1. Parsed before verifying — the most common failure by far.
app.use(express.json());                      // body is now an object
app.post("/webhooks", (req, res) => {
  verify(JSON.stringify(req.body), ...);      // key order and whitespace differ
});

// 2. Re-serialising the parsed body. Same problem, harder to spot.
const rawish = JSON.stringify(req.body);      // not the original bytes

// 3. Comparing with ===. Leaks timing information to an attacker.
return expected === received;

// 4. Signing the body alone, without the timestamp prefix.
createHmac("sha256", secret).update(rawBody);  // missing `${timestamp}.`
  1. 01

    Body parsed before verification

    A global express.json() consumes the stream. Register the raw parser on this route specifically, before anything else can touch it.

  2. 02

    Re-serialising to get "the raw body"

    JSON.stringify of a parsed object is not the original bytes. There is no way to reconstruct them once parsed.

  3. 03

    Comparing with === or ==

    A short-circuiting comparison reveals how many leading characters matched. Use timingSafeEqual, hmac.compare_digest, or hash_equals.

  4. 04

    Forgetting the timestamp prefix

    The signed string is "{timestamp}.{body}", not the body alone. Omitting the prefix produces a valid HMAC of the wrong input.

The freshness window

Rejecting deliveries whose timestamp is more than five minutes old is not required by the signature scheme, but it closes a real gap: a signature stays valid forever, so anyone who obtains one delivery can resend it indefinitely unless you bound how old it may be.

Five minutes is comfortable. Retries can arrive much later than that, but each retry is signed fresh with its own timestamp, so a legitimate retry never looks stale. If you see deliveries rejected for age, the usual cause is server clock skew on your side rather than anything about the delivery.

Check the freshness before computing the HMAC. It is a cheap comparison, and doing it first means an obviously stale request never reaches the expensive path.

Testing your implementation

POST /webhook-destinations/{destination_id}/test sends a real signed delivery on demand. It works before any timer exists, which means you can confirm verification is correct as the first thing you build rather than the last.

Two checks are worth running. A test delivery should verify and return 2xx. And the same request with one byte of the body changed should fail — if it passes, verification is not actually running, which is a failure mode that looks exactly like success until someone finds your URL.

Inspect what happened with GET /webhook-deliveries: it records the response status your endpoint returned and how long it took. A 401 there confirms your rejection path works. See retries and replay.

Verification during a secret rotation

A rotation switches the signing secret immediately, so a handler that knows only one secret will reject everything signed before it caught up. Accept both for a changeover window: verify against the new secret first and fall back to the previous one.

Keep the old secret for about a day. Retries span roughly fifteen hours, so a delivery signed before the rotation may still be arriving well afterwards. The full sequence is on destinations.