Guide
How to verify Stripe webhook signatures, and why verification fails
Direct answer
To verify a Stripe webhook, read the Stripe-Signature header (t=<timestamp>,v1=<signature>), compute HMAC-SHA256 over the string <timestamp>.<raw request body> using your endpoint's full whsec_ signing secret, and compare the hex result to each v1 value in constant time. Then reject the event if the timestamp is older than your tolerance; Stripe's libraries default to 300 seconds. Almost every failure comes from verifying something other than the exact raw body, using the wrong secret, or an old timestamp. You can paste a failing request into the free webhook signature verifier to see which one it is, without the secret leaving your browser.
How the signature is built
When Stripe sends an event to your endpoint, it does three things:
- Takes the current Unix timestamp, for example
1760000000. - Concatenates the timestamp, a period, and the exact bytes of the JSON body it is about to send.
- Computes HMAC-SHA256 of that string, keyed with the endpoint's signing secret, and sends the hex digest in the header as
v1.
So the header looks like t=1760000000,v1=5257a8.... A header can carry more than one v1 value, for example while a secret is being rolled, and a match against any of them is a pass. The timestamp is inside the signed string, so an attacker can't replay an old event with a fresh timestamp; that is why you check its age.
Two details catch people out. The key is the whole secret string, whsec_ prefix included. And the signed content is the body as sent, byte for byte: Stripe pretty-prints its JSON, so any change in whitespace, key order or a trailing newline produces a different digest.
Verify with Stripe's library (Express)
The safest path is Stripe's own constructEvent, which does the parsing, the comparison and the tolerance check. The one thing you must do yourself is hand it the raw body:
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
// Raw body on this route only, and before any express.json() middleware.
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
console.warn("stripe signature failed:", err.message);
return res.status(400).send("bad signature");
}
// Acknowledge fast; do the work asynchronously and deduplicate on event.id.
queueStripeEvent(event);
res.sendStatus(200);
});
If express.json() is mounted globally above this route, req.body is already an object by the time it arrives, and verification fails every time. Mount the webhook route first, or exclude it from the JSON parser.
Verify in a Next.js route handler
Fetch-style runtimes (Next.js route handlers, Remix, Hono, Cloudflare Workers, Deno) give you the raw body through request.text(). Read it once, verify that string, and only then parse it:
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature") ?? "";
try {
const event = stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET!);
await queueStripeEvent(event);
return new Response(null, { status: 200 });
} catch {
return new Response("bad signature", { status: 400 });
}
}
Calling request.json() first and then JSON.stringify on the result is the classic mistake here. It looks equivalent and isn't: the re-serialized string has different whitespace from what Stripe signed.
Verify without the library (WebCrypto)
If you can't use Stripe's SDK, for example on an edge runtime where you want no dependencies, the check is short. This version runs anywhere WebCrypto exists:
async function verifyStripe(rawBody: string, header: string, secret: string, toleranceSec = 300) {
let t = 0;
const v1s: string[] = [];
for (const part of header.split(",")) {
const [name, value] = part.trim().split("=");
if (name === "t") t = Number(value);
if (name === "v1" && value) v1s.push(value);
}
if (!t || v1s.length === 0) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${t}.${rawBody}`));
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
return v1s.some((v) => v.length === expected.length && timingSafeEqual(v, expected));
}
function timingSafeEqual(a: string, b: string) {
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
The open-source @teamshift/webhook-inspect package exposes the same check as verify(), plus a diagnose() that explains failures, if you'd rather not maintain this yourself.
Why verification fails, and how to tell which cause it is
The body changed. A framework parsed the JSON and re-serialized it, a proxy added a trailing newline, or line endings changed from LF to CRLF. The fix is always the same: verify the raw bytes before anything parses them. A quick test is to try the original pretty-printed form; if that verifies, the body was rewritten. The webhook verifier does this automatically and reports it as confirmed.
The secret is wrong. Each endpoint has its own secret, and test mode and live mode are separate. stripe listen prints its own signing secret for the session, which is not your Dashboard endpoint's secret. Other common slips are pasting an API key (sk_...) or restricted key instead of the signing secret, stripping the whsec_ prefix, or loading the secret from a .env file with a trailing newline or quotes.
The timestamp is too old. If you replay a captured event, or your server clock has drifted, the signature is valid but outside the tolerance window. Check the server's clock sync before widening the tolerance, and for replays, re-sign the event with a fresh timestamp.
After verification
A verified event is genuine, but it can still arrive twice or out of order. Return a 2xx quickly and do the work asynchronously; Stripe retries non-2xx responses, for up to three days in live mode. Store each event.id you've processed and skip repeats. And if the event triggers something consequential, such as a refund or an email to the customer, decide which of those steps should wait for a person. Human in the loop AI covers where approval gates belong, and the workflow linter flags webhook triggers that never verify a signature at all.
FAQ
What secret do I use to verify Stripe webhooks?
The endpoint's signing secret, which starts with whsec, exactly as Stripe shows it, prefix included. It is different for each endpoint, for test and live mode, and for each stripe listen session. It is never your API key.
Why do I get "No signatures found matching the expected signature for payload"?
Your code verified a different string from the one Stripe signed. Most often a body parser turned the JSON into an object and it was serialized again before verification. Verify the raw request body instead, and check that the secret matches the endpoint.
What timestamp tolerance should I use for Stripe webhooks?
Stripe's libraries default to 300 seconds, which is a sensible value. If valid events fail only because of age, fix the server's clock sync rather than widening the window a lot.
Can I test Stripe webhook verification locally?
Yes. Run the Stripe CLI's listen command to forward events to your local endpoint and use the signing secret it prints for that session. You can also paste a captured request into the browser-based verifier on this site to check it without sending the secret anywhere.