Stripe webhook signature verification failed: 6 causes and how to fix each
September 25, 2026
You added a Stripe webhook, the events show up in the dashboard, and your server answers every one of them with a 400 and this error:
No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?The message is honest but unhelpful. Stripe signs the exact bytes it sent, and your code recomputes the signature from the bytes it thinks it received. If anything differs, even one whitespace character, verification fails. Almost every case comes down to one of the six causes below, and the first one accounts for most of them.
1. You verified a parsed body instead of the raw body
This is the cause behind the large majority of reports. A JSON parser turns the payload into an object; when you turn it back into a string with JSON.stringify the key order, spacing, or unicode escaping can change, so the signature no longer matches. The fix is to hand constructEvent the untouched raw text or buffer.
Next.js (App Router)
// app/api/stripe/webhook/route.ts (Next.js App Router)
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const signature = req.headers.get('stripe-signature')
if (!signature) return new Response('missing signature', { status: 400 })
// The RAW text of the request. Never req.json() and then JSON.stringify().
const payload = await req.text()
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET!.trim()
)
} catch (err) {
console.error('Stripe signature check failed:', (err as Error).message)
return new Response('invalid signature', { status: 400 })
}
// ...handle event.type here, then:
return new Response('ok', { status: 200 })
}In the older Pages Router you must also disable the built-in parser for that route with export const config = { api: { bodyParser: false } } and read the stream yourself.
Express
// Express: the webhook route must get the raw body, BEFORE express.json()
import express from 'express'
import Stripe from 'stripe'
const app = express()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
app.post(
'/stripe/webhook',
express.raw({ type: 'application/json' }), // <- raw Buffer, not parsed JSON
(req, res) => {
let event
try {
event = stripe.webhooks.constructEvent(
req.body, // Buffer
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
)
} catch (err) {
return res.status(400).send('invalid signature')
}
res.sendStatus(200)
}
)
// Only now register the JSON parser for the rest of your routes
app.use(express.json())The order is the trap: if app.use(express.json()) is registered before the webhook route, the body is already parsed by the time your handler runs, and the raw bytes are gone.
2. You are using the wrong signing secret
The secret that starts with whsec_ is not your API key. And it is not one shared secret for your account: every webhook endpoint has its own. The usual mix-ups:
- You pasted the secret from
stripe listen(the CLI prints its ownwhsec_) into production, or the dashboard endpoint’s secret into your local.env. - You have two endpoints (say, staging and production) and copied the secret of the wrong one.
- You rolled the secret in the dashboard and your server still has the old value in its environment.
Open the endpoint in the dashboard, reveal its signing secret, and compare it character by character with what your running process actually reads.
3. Test mode versus live mode
Test and live mode are completely separate, including their webhook endpoints and secrets. A live event signed with the live secret will always fail verification against a test-mode secret. If everything works in development and only fails in production, this and cause 2 are the first two things to check.
4. The secret has invisible extra characters
A trailing newline, a space, or wrapping quotes in an environment variable is enough to break the check, and it is very hard to see in a log. Hosting dashboards make this easy to do when you paste a value. The defensive move is to .trim() the secret when you read it (as in the example above) and to print its length once, not its value: a whsec_ secret has a fixed, predictable length.
5. Timestamp outside the tolerance zone
The signature header carries a timestamp, and by default the library rejects events older than five minutes to block replay attacks. The related error reads Timestamp outside the tolerance zone. It appears when your server clock is skewed, when a queue or cold start holds the request too long before it is verified, or when you re-send a saved payload from a script. Verify first, before any slow work, and keep the server clock synced. Stripe re-signs each retry with a fresh timestamp, so a genuine retry does not have this problem.
6. A proxy or middleware changed the body
Anything between Stripe and your handler that rewrites the payload breaks the signature: a body-size or compression middleware, an API gateway that re-serializes JSON, a WAF, or a framework that normalizes line endings. If causes 1–5 are clean, send a test event straight to a bare handler that logs the raw body length and compare it to what Stripe shows for the event delivery.
The failure that looks fixed but is not
Once signatures start passing, there is a quieter problem: a handler that verifies correctly, returns 200, and then does nothing useful with the event. That is the situation described in your Stripe webhook returns 200, but it didn’t work. And while the endpoint was rejecting events, real customers may have paid without getting access. See a customer paid and is staring at a paywall for how that happens.
Check what the failed period cost you
Stripe retries failed deliveries for a few days, but not forever. If your signature check was broken for a while, some events were never processed, so your database can disagree with Stripe about who is paying. The quickest way to find out is to compare the two directly. You can run this reconciliation script once, or let Venwai do it every day with a read-only Stripe key and alert you in Slack, Discord, Telegram, or email when a subscription in Stripe and your app stop agreeing.
Free while it’s in beta, no card required: venwai.com.
Venwai compares Stripe with who your app actually lets in, once a day, using a read-only key. It alerts you the moment they disagree. Free while in beta, no card required.
Check my Stripe for free