WebhookToolkit
Stripe

Stripe webhook signature verification failed — find why

Last updated: July 2026

Stripe's constructEvent throws No signatures found matching the expected signature for payload and you have no idea which of a dozen things went wrong. This page does one thing: it takes the three artifacts Stripe hands you — the whsec_ secret, the exact raw body, and the Stripe-Signature header — recomputes the HMAC, and points at the one cause that actually broke it.

In short: 9 times out of 10 it's the raw body (your framework re-serialised the JSON before verification) or the wrong signing secret (the CLI's whsec_ instead of the endpoint's). The debugger below tells the two apart in one paste.

Debug your Stripe signature

Stripe is selected by default. The HMAC is computed in your browser with WebCrypto — your whsec_ never leaves the tab.

Alles draait in je browser met de Web Crypto API. Je signing-secret wordt nooit naar onze servers gestuurd — controleer het in het Netwerk-tabblad.

Anatomy of the Stripe-Signature header

Every Stripe webhook arrives with one header that looks like this. Each comma-separated field has a job:

A real Stripe-Signature header
Stripe-Signature: t=1614556800,
  v1=5257a869e7ecebeda32affa62cdca3fa51cad7414a563a8c6e3c4b41c2b7f2c8,
  v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39
Anatomy of the Stripe-Signature header
t=Unix timestamp (seconds) when Stripe generated the signature. It is part of what gets signed — and what the 5-minute replay check compares against.
v1=The signature you actually verify: HMAC-SHA256 of the string t + "." + rawBody, hex-encoded, keyed with your endpoint's whsec_. This is the only scheme constructEvent checks.
v0=A legacy test scheme. Stripe still sends it but the library ignores it for verification — don't try to match against v0.
, (comma)During a secret rotation Stripe signs with both the old and the new secret, so you may see two v1= values. constructEvent passes if any one matches.

The signed string is `${t}.${rawBody}` — not the body alone. Miss the t. prefix in a hand-rolled verifier and every signature fails, even with the right secret.

The 6 root causes, in the order to check them

Ranked by how often they're the culprit. Each has a tell that separates it from the others.

#CauseDistinctive symptomFix
1Body isn't the raw bytesFails on real events but the computed HMAC is close; passes if you paste the exact raw string into the debugger above.Feed constructEvent the untouched request body, before any JSON parser touches it.
2Wrong signing secretEvery event fails, HMAC nowhere near the v1. Often the CLI's whsec_ used against dashboard traffic.Copy the secret from the endpoint's page in the Dashboard (Developers → Webhooks), not from stripe listen.
3Middleware parsed the body firstWorks with stripe trigger but 400s in prod; Express app.use(express.json()) runs before your route.Mount express.raw({type:'application/json'}) on the webhook route only, above the global JSON parser.
4Test-mode secret in live mode (or vice-versa)Signature fails only for real payments, passes for test events. The whsec_ looks right but belongs to the other mode.Use the live endpoint's whsec_ for live traffic; test and live each have their own.
5Header missing or truncatedsig is undefined or empty; a proxy or CDN stripped Stripe-Signature or lower-cased it.Read req.headers['stripe-signature'] (lower-case in Node), and make sure your proxy forwards it.
6Wrong endpoint's secretOne endpoint verifies, another 400s — you reused a single whsec_ across two endpoint URLs.Each endpoint has its own signing secret. Map the secret to the URL Stripe is actually hitting.

The raw-body fix, framework by framework

Cause #1 is almost always this: your web framework read, parsed, and re-serialised the JSON before constructEvent saw it. Re-serialised JSON has different whitespace, so the HMAC no longer matches. Here's how to hand each framework the untouched bytes.

FrameworkWhy the body gets mutatedThe fix
Expressexpress.json() parses and discards the raw text app-wide.express.raw({type:'application/json'}) on the webhook route, before express.json().
Next.js App RouterRoute handlers don't parse the body for you.const body = await request.text() — that string is already raw.
Next.js Pages RouterThe built-in bodyParser rewrites the body.export const config = { api: { bodyParser: false } }, then buffer the stream.
Flaskrequest.json / request.form consume and re-encode the stream.payload = request.get_data() — bytes, before touching .json.
DjangoAccessing request.POST locks the body.request.body — read it first, before any form access.
AWS Lambda / API GatewayAPI Gateway may base64-encode, or a mapping template may rewrite the body.Use Lambda proxy integration; if event.isBase64Encoded, decode event.body before verifying.
Express — raw body on the webhook route only
// The webhook route MUST come before express.json()
app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['stripe-signature'];
    const event = stripe.webhooks.constructEvent(
      req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
    );
    res.json({ received: true });
  }
);
app.use(express.json()); // everything else
Next.js App Router — app/api/webhook/route.ts
export async function POST(req: Request) {
  const body = await req.text();            // raw, untouched
  const sig = req.headers.get('stripe-signature')!;
  const event = stripe.webhooks.constructEvent(
    body, sig, process.env.STRIPE_WEBHOOK_SECRET!
  );
  return Response.json({ received: true });
}
Next.js Pages Router — disable the body parser
import { buffer } from 'micro';
export const config = { api: { bodyParser: false } };

export default async function handler(req, res) {
  const raw = await buffer(req);
  const sig = req.headers['stripe-signature'];
  const event = stripe.webhooks.constructEvent(
    raw, sig, process.env.STRIPE_WEBHOOK_SECRET
  );
  res.json({ received: true });
}

Paste the exact bytes into the debugger above: if the signature only matches once you strip a trailing newline or a re-encoded field, the body was mutated in transit — not your secret.

Test mode vs live mode: the secret that looks right but isn't

Stripe hands you three different whsec_ values and they're easy to cross. First, the test endpoint's secret, shown in the Dashboard while you're in test mode. Second, the live endpoint's secret — a separate value on the same endpoint switched to live. Third, the CLI secret that stripe listen prints, different again and only valid for events forwarded by that session. All three produce a valid-looking HMAC, so the error is the same generic No signatures found — nothing says mode mismatch. The tell: test payments verify, real ones 400. Fix: read the secret from the exact endpoint, in the exact mode, that's receiving the traffic.

The 5-minute replay window everyone forgets

constructEvent doesn't only check the HMAC — it rejects any event whose t= timestamp is more than 300 seconds old, throwing Timestamp outside the tolerance zone. This bites when you replay a captured event hours later, or when your server clock has drifted. The signature is perfectly valid; it's the age that fails. The debugger above shows the timestamp age so you can tell a stale replay from a real signature mismatch. If you genuinely need a longer window, constructEvent takes a fourth tolerance argument in seconds — but fix the clock first.

Is it your handler or your secret? Generate a known-good event

When the debugger says the secret is right but prod still 400s, the bug is in how your handler reads the body. Isolate it: our Signer generates a Stripe-signed payload with a secret you control, so you can POST a guaranteed-valid event straight at your local handler. If it passes, your verification code is fine and the problem is the secret or the middleware. If it fails, the handler is mutating the body — jump back to the raw-body table above.

Generate a signed Stripe event

Diagnostic checklist

Run these in order the next time constructEvent throws.

  1. Paste the failing whsec_, raw body and Stripe-Signature into the debugger above — it names the cause before you read further.
  2. Confirm the body reaching constructEvent is the untouched raw string, not a parsed-and-re-serialised object.
  3. Copy the signing secret straight from the endpoint's page in the Dashboard — not from stripe listen, not from another endpoint.
  4. Check you're using the live secret for live traffic and the test secret for test traffic.
  5. Log req.headers['stripe-signature'] and confirm it's present and not truncated by a proxy.
  6. Check the event's t= timestamp isn't older than 5 minutes (stale replay or clock drift).

Capture and replay the real failing event

Still stuck? Point Stripe at a Webhook Toolkit Relay or capture URL, catch the exact request that's failing, and inspect the raw body and headers byte-for-byte — then replay it against your handler as many times as you need without waiting for another live event.

Stripe webhook testerWebhook-handtekening-validatorRelay

Veelgestelde vragen

Why does my Stripe webhook work locally but not in production?

Almost always the raw body. In local dev with stripe listen the CLI forwards untouched bytes, but your production middleware (Express express.json(), a Pages Router bodyParser, an API Gateway mapping template) parses and re-serialises the JSON before verification. Feed constructEvent the raw request body and it passes.

What does 'No signatures found matching the expected signature for payload' mean?

The HMAC you computed doesn't match any v1= Stripe sent. Two usual causes: the body was mutated before verification, or the signing secret is wrong (often the CLI's whsec_ instead of the endpoint's). Paste all three into the debugger above and it tells you which.

Can I use the same signing secret for test and live mode?

No. Each endpoint has a separate whsec_ for test and for live, and the CLI prints a third. A test secret produces a valid-looking HMAC that still fails on live events, with the same generic error. Match the secret to the mode receiving the traffic.

How do I fix 'Timestamp outside the tolerance zone'?

The signature is valid but the event's t= is more than 300 seconds old — usually a replayed capture or a drifted server clock. Fix the clock, or replay a fresh event. As a last resort, pass a larger tolerance (in seconds) as the fourth argument to constructEvent.

Is v0 in the Stripe-Signature header used for verification?

No. Only v1= (HMAC-SHA256 of t.rawBody) is checked by constructEvent. v0= is a legacy test scheme Stripe still emits but the library ignores. If your hand-rolled verifier matches against v0, that's your bug.

Does this Stripe signature debugger send my secret anywhere?

No. The HMAC is computed in your browser with WebCrypto. Your whsec_, body and header never leave the tab — nothing is uploaded, logged or stored. Disconnect from the network and it still works.

Stripe webhook signature verification failed: 6 causes · Webhook Toolkit