ToolsFree tool
Webhook signature verifier
Check a Standard Webhooks signature and see why it fails. Works with Cascadehook and any sender that follows the spec.
The result shows up here
Paste the secret, the three headers, and the raw body from a request, then verify. To see how it works first, use Try an example.
What gets signed
HMAC-SHA256(key, id + "." + timestamp + "." + body)The body was parsed before it was verified
The most common cause. Middleware such as express.json() turns the body into an object, and serializing it again changes key order, spacing, or number formatting. Verify the exact raw bytes first, then parse.
The secret is used as text
Passing the whsec_… string straight to your HMAC function uses its characters as the key. Strip the prefix and Base64-decode it first.
The wrong secret
Each endpoint has its own secret. After a rotation the old one stops working once the grace period ends, so make sure your receiver loads the new one.
The header is compared as a whole
The header can hold more than one signature. Split it on spaces and accept the request if any v1 entry matches.
The timestamp is out of range
Clock drift, or testing with a request captured earlier, fails the tolerance check even when the signature is correct. Keep your clock synced with NTP.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be the exact bytes received (a Buffer), not re-serialized JSON.
export function verifyWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"] ?? "";
const signatures = headers["webhook-signature"] ?? "";
if (!id || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest();
return signatures.split(" ").some((entry) => {
const [version, value] = entry.split(",");
if (version !== "v1" || !value) return false;
const candidate = Buffer.from(value, "base64");
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
});
}With Express, read the body with express.raw({ type: "application/json" }) so rawBody is the original Buffer.
The sender signs every request with a secret shared with your endpoint and sends three headers:
webhook-id: a unique message ID. Retries keep it, so use it to skip duplicates.webhook-timestamp: when the attempt was signed, in Unix seconds.webhook-signature: one or more space-separatedv1,<base64>signatures. There are two while the sender rotates its secret.
To verify a request:
- Remove the
whsec_prefix and Base64-decode the rest. The decoded bytes are the HMAC key. - Build the message
webhook-id + "." + webhook-timestamp + "." + raw body. - Compute HMAC-SHA256 of it and Base64-encode the result.
- Compare it in constant time with each
v1,entry. Any match is valid. - Reject timestamps more than five minutes from your clock.
Sending webhooks yourself? Cascadehook signs every delivery this way, retries failures for up to 72 hours, and keeps every attempt for replay. Create a project or read the signature docs.