Verifying Webhook Signatures
This is the most important page in this section. A webhook endpoint is a public URL that
mutates your state. If you don't verify signatures correctly, anyone who discovers the URL
can forge delivered and failed events. Verification is the security boundary, and it is
not optional boilerplate.
Sent signs webhooks Svix-style: the same HMAC scheme used by Svix, a widely used webhook-infrastructure provider, so libraries and knowledge built around Svix's convention transfer directly. The scheme is small and exact; get every detail right and nothing else matters. Get one wrong and either everything is rejected or everything is forged.
The SDKs do not verify inbound webhooks. There is no client.webhooks.verifySignature().
It does not exist. The SDKs expose webhook management (create, list, rotate, toggle,
test), but verification is hand-rolled per language against the scheme below. If a future
SDK adds a verifier with this exact scheme, prefer it. Until then, this code is yours to own.
For the product-level overview, see Signature verification in the concepts docs. This page is the implementation.
Quick path. Copy your language's function from
The verifier, in seven languages verbatim, call it with
the raw request body plus the webhook-id / webhook-timestamp / webhook-signature
headers (X-Webhook-ID / X-Webhook-Timestamp / X-Webhook-Signature on a
version 1 webhook) and your stored secret, and reject the request unless it returns valid, before any
other processing. The scheme details below explain why the code is shaped this way, and the
rotation and mistakes sections are worth a
read once, but the verifier function itself is the part you need first.
The scheme
Three lines. Memorize them:
key = base64_decode( secret without the "whsec_" prefix )
signed_content = "{webhook-id}.{webhook-timestamp}.{raw_body}" # X-Webhook-ID / X-Webhook-Timestamp on version 1
expected = "v1," + base64( HMAC_SHA256(key, signed_content) )Then constant-time compare expected against each space-separated token of the
webhook-signature header (X-Webhook-Signature on version 1), never
==/===: a normal comparison's early exit leaks timing an attacker can use to forge a
signature (see Signature verification in the
concepts docs for the full rationale).
Every piece matters:
- The secret is base64, not UTF-8. Strip the
whsec_prefix, then base64-decode the rest. The HMAC key is the decoded bytes. - The MAC covers
id.timestamp.body, joined by literal dots, rather than the body alone. The id and timestamp are inside the signature. - Sign the raw body, the exact bytes Sent sent, before any JSON parse or re-serialization.
- The output is versioned:
v1,followed by the base64 of the digest.
The verifier, in seven languages
Same scheme, idiomatic per language.
import { createHmac, timingSafeEqual } from "node:crypto";
const SIGNATURE_VERSION = "v1";
const TOLERANCE_SECONDS = 300;
export function computeSignature(
id: string, timestamp: string, rawBody: Buffer | string, secret: string,
): string {
const keyMaterial = secret.startsWith("whsec_") ? secret.slice(6) : secret;
const key = Buffer.from(keyMaterial, "base64"); // base64-decode the secret
const body = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
const signedContent = `${id}.${timestamp}.${body}`; // id.timestamp.body
const digest = createHmac("sha256", key).update(signedContent, "utf8").digest("base64");
return `${SIGNATURE_VERSION},${digest}`; // v1,<base64>
}
function constantTimeEquals(a: string, b: string): boolean {
const ab = Buffer.from(a), bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
}
export function verifyWebhook(input: {
id?: string; timestamp?: string; signature?: string;
rawBody: Buffer | string; secret: string;
}): { valid: true } | { valid: false; reason: string } {
const { id, timestamp, signature, rawBody, secret } = input;
if (!id || !timestamp || !signature) return { valid: false, reason: "missing signature headers" };
if (!secret) return { valid: false, reason: "webhook secret not configured" };
const ts = Number(timestamp);
if (!Number.isFinite(ts)) return { valid: false, reason: "invalid timestamp" };
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > TOLERANCE_SECONDS) return { valid: false, reason: "timestamp outside tolerance window" };
const expected = computeSignature(id, timestamp, rawBody, secret);
// Version 2 sends two signatures for 24 hours after a rotation; accept any match.
const matched = signature.split(" ").some((c) => constantTimeEquals(c.trim(), expected));
return matched ? { valid: true } : { valid: false, reason: "signature mismatch" };
}The replay window
A valid signature on a captured request is still an attack if it's replayed hours later. So reject any event whose timestamp is more than 300 seconds (5 minutes) away from now, in either direction:
reject if abs(now - webhook-timestamp) > 300Because the timestamp is part of the signed content, an attacker can't rewrite it to a fresh value without invalidating the signature. The window and the signature reinforce each other.
The window is safe to enforce on retries too: each attempt is signed when it is sent, so a
redelivery carries its own timestamp rather than the original's and clears the check however
long the backoff ran before it. That also makes the timestamp the wrong thing to build a dedupe
key from, since it differs between attempts at one event. webhook-id (X-Webhook-Event-ID on
version 1) doesn't: see
The webhook receiver.
Handling rotation
On a version 2 webhook, rotation overlaps: for
24 hours after the rotate call, webhook-signature carries a signature from the new secret and
one from the old, so a receiver still holding the old secret keeps verifying while you roll the
new one out. After that, only the new secret signs. The verifiers in the preceding section
already accept any matching token.
On a version 1 webhook, rotating a secret replaces it in one step: the old secret is invalid the moment the rotate
call returns, and every delivery from then on is signed only with the new secret. That
includes events queued before the rotation: Sent signs each delivery when it sends it, with the
webhook's current secret, URL and timeout, so nothing already in the queue arrives under the old
key. X-Webhook-Signature always carries exactly one v1,… token.
What makes rotation safe lives on your side: hold a set of candidate secrets per endpoint and accept a delivery if any of them verifies. Add the new secret to the set the moment rotate returns. On version 2 the old secret keeps matching for 24 hours, so nothing fails while the new one rolls out; retire the old entry after that. On version 1 the old entry is harmless (it never matches again), and deliveries that arrive before the new secret reaches your store fail verification and are retried, so keep that gap short.
The verifiers in the preceding section also split the header on spaces and test each token. A version 1 webhook sends exactly one signature, so there the split is defensive. Pair this page with Endpoint management, where the store persists the new secret as soon as it's issued.
Five mistakes to avoid
1. Not decoding the secret. The most common bug. HMAC(secret_string, body) (using the
literal whsec_... string as the key) will never match. Strip the prefix and base64-decode
the rest; the key is the decoded bytes.
2. Signing only the body. The MAC covers {id}.{timestamp}.{body}, joined by literal
dots. Signing just the body drops the id and timestamp from the signature and breaks
cross-endpoint and replay protection.
3. Ignoring the replay window. A signature verifies forever unless you bound it in time. Without the ±300s check, a captured-and-replayed request sails through. Enforce it.
4. Comparing with ==. A plain equality check leaks timing information. Always use a
constant-time compare: timingSafeEqual / hmac.compare_digest / hmac.Equal /
MessageDigest.isEqual / FixedTimeEquals / hash_equals / secure_compare.
5. Verifying parsed-then-reserialized JSON. Sign the exact raw bytes Sent sent; a re-serialized body rarely matches them (covered in The webhook receiver).
Next steps
Building the Webhook Receiver
Build the inbound webhook endpoint end-to-end: capture the raw body, verify the signature, acknowledge fast with a 2xx, and process events asynchronously.
Managing Webhook Endpoints
The management surface for webhook endpoints. Register endpoints, capture the signing secret exactly once, toggle endpoints, and rotate secrets safely.