Stripe webhook signature verification failed: every error and its fix

"No signatures found matching the expected signature for payload." Your webhook code is probably fine. The problem is almost always that something touched the request body before your verification did — and Stripe's signature check is exact to the byte.
Signature verification is the one part of a Stripe integration where "close enough" fails outright. Once you know what it's actually comparing, the fix is usually a one-line change to how you read the body.
Stripe webhook signature errors: what each message means
If you got here by pasting an error, start with the exact message. constructEventthrows seven of them, and they are not interchangeable — one of them isn't about the body at all. They are listed in the order the library checks them.
No signatures found matching the expected signature for payload.
the signature comparison
- Cause
- The bytes you passed in are not the bytes Stripe signed. Nearly always a JSON body parser ran first, parsed the body to an object and re-serialised it — which changes key order, whitespace and number formatting, and so changes the signature.
- Fix
- Read the raw body on this route and pass that.
await req.text()in an App Router handler,express.raw()mounted beforeexpress.json()in Express. If the body is definitely raw, check you are using this endpoint'swhsec_secret for this mode — and check the secret for a stray newline, which the SDK will warn about in the same message.
Webhook payload must be provided as a string or a Buffer instance representing the _raw_ request body.
the signature comparison, when the payload is an object
- Cause
- The same bug as above, caught one step earlier: you handed
constructEventa parsed JavaScript object instead of the raw body. Stripe can tell, so it says so specifically rather than just reporting a mismatch. - Fix
- Stop calling
req.json()orreq.bodyon the webhook route. Once the body has been parsed the original signed bytes are gone — they cannot be reconstructed by re-serialising, which is the whole reason this is a distinct error.
Timestamp outside the tolerance zone
the timestamp check, after the signature already matched
- Cause
- The signature was fine. The event is simply older than the tolerance window — five minutes by default. Either your server clock has drifted, or you are replaying a captured request long after it was sent.
- Fix
- Sync the clock with NTP; on a container host that usually means the host, not the container. This one is worth recognising on sight, because it is the only error in this list that is not about the body — chasing the parser here wastes an afternoon.
No webhook payload was provided.
the first check, before anything is parsed
- Cause
- The payload argument was empty or undefined. Usually the raw-body reader returned nothing: the stream was already consumed by middleware, or the framework's body parser was disabled without anything put in its place.
- Fix
- Log the body length before verifying. Zero means the problem is upstream of Stripe entirely, and no amount of signature debugging will find it.
No stripe-signature header value was provided.
the header check
- Cause
- The
Stripe-Signatureheader never reached your handler. Either something in front of the app strips unknown headers, or the request did not come from Stripe at all — a health check, a scanner, or your own testcurl. - Fix
- Check the proxy or gateway first. If the header is absent on real Stripe deliveries, the event never had a chance to verify, and the endpoint is effectively unprotected against the requests that do carry a body.
Unable to extract timestamp and signatures from header
parsing the header
- Cause
- The header arrived but is malformed — it should look like
t=1690000000,v1=abc…. Something rewrote it, truncated it, or a testing tool forged a header of its own shape. - Fix
- Log the header verbatim and compare it against a real delivery in the Stripe dashboard. If they differ, the bug is in whatever sits between Stripe and your handler.
No signatures found with expected scheme
parsing the header
- Cause
- The header parsed, but contains no
v1signature — the scheme the library expects. Typically a hand-built header in a test, or a replay tool that copied the timestamp and dropped the rest. - Fix
- Generate test events with the Stripe CLI rather than hand-rolling the header. The CLI signs them properly, with its own signing secret.
The first two are the same bug wearing different clothes, and they cover the large majority of real cases. The rest of this post is why.
What the check actually compares
When Stripe sends an event, it signs the raw request body with your endpoint's signing secret and puts the result in the Stripe-Signature header. constructEvent re-computes that signature from the body you hand it and checks the two match. The comparison is over the exact bytes Stripe sent — not the data, the bytes.
So if the body you pass in differs from what Stripe signed by even a single character, the signatures won't match and verification fails. That's the whole bug, and here's what changes those bytes:
The body parser is the usual culprit
Most web frameworks parse the request body into JSON before your handler runs. Parsing to an object and re-serializing it changes the bytes — key order, whitespace, number formatting all shift — so by the time you call constructEvent, you're verifying a re-serialized copy, not the original. Every event fails with a 400.
The fix depends on your framework, but the shape is always the same: bypass the JSON parser for this one route and read the raw body.
- Next.js App Router: read the body with
await req.text()in the route handler and pass that string straight toconstructEvent— don't callreq.json(). - Next.js Pages API: disable the built-in body parser for the route (
export const config = { api: { bodyParser: false } }) and read the raw stream. - Express: use
express.raw({ type: 'application/json' })on the webhook route, mounted before any globalexpress.json().
When it's not the body
If the raw body is definitely intact and it still fails, check these in order:
- Wrong secret. You need the endpoint's
whsec_signing secret, not your API key — and it's per-endpoint and per-mode. A test-mode secret won't verify a live event, and the CLI gives you a different secret again. - Clock skew. Verification also checks the event's timestamp against a tolerance window (five minutes by default). A badly wrong server clock makes every event look too old and fails the check.
- A proxy or logger rewriting the body somewhere in front of your app — same problem as the parser, different place.
This prompt fixes the whole thing for your specific framework:
My Stripe webhook signature verification keeps failing with "No signatures found matching the expected signature for payload." I'm using <FRAMEWORK> — tell me the exact fix. Requirements: - Explain that stripe.webhooks.constructEvent needs the EXACT raw request body bytes, and that any JSON body parser running before my handler changes those bytes and breaks verification. - Show me how to get the raw, unparsed body in my specific framework (App Router route handler, Express, Next.js Pages API, etc.), including disabling the default body parser where needed. - Confirm I'm passing the Stripe-Signature header and my webhook SIGNING secret (whsec_...), not my API key, and that I'm using the signing secret for THIS specific endpoint (test vs live, and per-endpoint). - Mention the timestamp tolerance: verification also fails if my server clock is badly skewed, since Stripe checks the event isn't older than its tolerance window. Give me the corrected handler in full.
Stripe's signature verification docs have the per-language details.
A 400 is still a silent failure
Here's the part that bites: while verification is failing, the payments still succeed. Stripe gets a 400, marks the delivery failed, and retries for three days before disabling your endpoint — but your customers paid and got nothing, and nothing on your side looks broken. A rejected signature is invisible until someone complains.
The way to catch it is to watch the endpoint from outside. The moment it starts returning 400s instead of 2xx, you want an email — inside the retry window, while you can still fix the parser and replay the missed events.
Catch a rejected webhook before your customers do.
Join Tell Me When Down free and we'll watch your Stripe webhook endpoint around the clock. The moment signature checks start failing, you get an email — in time to fix the raw-body bug and replay the events, not after Stripe disables the endpoint.
spot something wrong or out of date? [email protected] — we'll fix it