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| Header | Role in verification |
|---|---|
X-CountdownShare-Timestamp | Unix seconds. Prefixed to the body before signing, and checked for freshness. |
X-CountdownShare-Signature | v1=<hex>. What you compare your computed HMAC against. |
X-CountdownShare-Event-Id | Not part of the signature. Used for deduplication. |
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.
| Framework | How to get the raw body |
|---|---|
Express | express.raw({ type: "application/json" }) on the route — not express.json() |
Next.js route handler | await request.text() — nothing is pre-parsed |
Fastify | A content-type parser that preserves the raw buffer |
Flask | request.get_data() — not request.json |
Django | request.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 });
}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}.`- 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.
- 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.
- 03
Comparing with === or ==
A short-circuiting comparison reveals how many leading characters matched. Use timingSafeEqual, hmac.compare_digest, or hash_equals.
- 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.
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.
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.