ToolsFree tool
Stripe webhook signature verifier
Check a Stripe-Signature header against your endpoint's signing secret and see exactly why verification fails. Nothing you paste leaves your browser.
The result shows up here
Paste the endpoint's signing secret, the Stripe-Signature header, and the raw body from a request, then verify. To see how it works first, use Try an example.
What gets signed
HMAC-SHA256(secret, t + "." + body)“No signatures found matching the expected signature for payload”
Stripe's libraries raise this error for every mismatch, so on its own it doesn't say what's wrong. One of three inputs is: the raw body, the Stripe-Signature header, or the endpoint secret. Paste all three above to find out which.
The body was parsed before it was verified
The most common cause. If express.json() runs before your webhook route, the body is already an object, and serializing it again changes its bytes. Register the webhook route with express.raw({ type: "application/json" }) before app.use(express.json()). In a Next.js route handler, read await request.text(); in the Pages Router, turn off bodyParser and read the raw buffer. Behind API Gateway, pass the raw body through to your Lambda function.
The secret is from a different endpoint
Every endpoint has its own whsec_ secret, and test-mode and live-mode endpoints never share one. stripe listen prints a secret of its own, separate from any Dashboard endpoint, so a secret that works locally fails in production, and the other way around.
The secret was changed on its way to the HMAC
Stripe uses the whole whsec_… string as text for the key. Stripping the prefix, Base64-decoding the rest (which is how Standard Webhooks secrets work), or loading it from an .env file with a trailing newline or quotes all change the key.
Only part of the header is checked
The header holds a timestamp and one or more signatures: t=…,v1=…,v1=…. Split it on commas, sign with the t value, and accept the request if any v1 entry matches. Ignore v0; it's a placeholder on test events.
The timestamp is outside the tolerance
Stripe's libraries reject requests signed more than five minutes ago. Every retry is signed again with a fresh timestamp, so this usually means a request was replayed by hand or the server's clock has drifted. Keep it synced with NTP.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be the exact bytes received (a Buffer), not re-serialized JSON.
export function verifyStripeWebhook(rawBody, headers, secret) {
let timestamp;
const signatures = [];
for (const item of (headers["stripe-signature"] ?? "").split(",")) {
const separator = item.indexOf("=");
const key = item.slice(0, separator);
const value = item.slice(separator + 1);
if (key === "t") timestamp = value;
else if (key === "v1") signatures.push(Buffer.from(value, "hex"));
}
if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
// The whole whsec_ string is the key: don't strip it or Base64-decode it.
const expected = createHmac("sha256", secret)
.update(timestamp + ".")
.update(rawBody)
.digest();
return signatures.some(
(signature) => signature.length === expected.length && timingSafeEqual(signature, expected),
);
}With Express, register the webhook route with express.raw({ type: "application/json" }) before app.use(express.json()). The stripe package does the same check in stripe.webhooks.constructEvent.
Stripe signs each delivery with the endpoint's secret and sends one header, Stripe-Signature, with comma-separated parts:
t: when the attempt was signed, in Unix seconds. Each retry gets a new one.v1: the HMAC-SHA256 signature as hex. There's one per active secret, so two appear for up to 24 hours after you roll a secret.v0: a placeholder signature on test events. Ignore it.
To verify a request:
- Split the header on commas and each part on its first
=, keepingtand everyv1. - Build the message
t + "." + raw body. - Compute HMAC-SHA256 of it with the whole
whsec_secret as the key, and hex-encode the result. - Compare it in constant time with each
v1value. Any match is valid. - Reject timestamps more than five minutes from your clock.
Stripe's libraries do all of this in stripe.webhooks.constructEvent (Node), stripe.Webhook.construct_event (Python), and webhook.ConstructEvent (Go). When they reject a request, this page shows which input is wrong.
Sending webhooks from your own product? Cascadehook signs every delivery with Standard Webhooks, retries failures for up to 72 hours, and keeps every attempt for replay. Create a project or try the Standard Webhooks verifier.
Stripe is a trademark of Stripe, Inc.. Cascadehook isn't affiliated with or endorsed by Stripe.