Stripe Webhook Signature Verification Failed: Fix

Stripe webhook signature verification fails when your code checks a parsed or changed body, or uses the wrong whsec_ secret. Verify the raw bytes instead.
The error looks like this: StripeSignatureVerificationError: No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe? It's one of the most common payment bugs I fix, and it's nasty because it often works on your laptop and breaks only after deploying. Meanwhile orders stay unpaid in your database while the money sits in Stripe. Here is how signature checking works, the six causes I see, and the exact fix for Express, Next.js and NestJS.
Key takeaways
- Stripe signs the exact bytes it sent. If a JSON parser reads the body first and you re-stringify it, the bytes change and the signature no longer matches.
- Every endpoint has its own secret. The
whsec_fromstripe listenis not the one from your dashboard endpoint, and test mode and live mode secrets differ. - Express: use
express.raw({ type: 'application/json' })on the webhook route only. Next.js App Router:await req.text(). NestJS:rawBody: trueandreq.rawBody. - Timestamps matter: the default tolerance is 300 seconds, so a badly wrong server clock also fails verification.
- Return 2xx fast and handle each event once, keyed by
event.id, because Stripe retries failed deliveries.
How Stripe webhook signatures work
When Stripe sends an event to your endpoint, it adds a Stripe-Signature header with a timestamp (t=) and one or more signatures (v1=). The signature is an HMAC-SHA256 of the timestamp plus the raw request body, made with your endpoint's signing secret. Your server repeats the calculation with the same secret and body. If the results match, the event really came from Stripe and wasn't changed on the way.
The important word is raw. JSON.parse followed by JSON.stringify can change spacing, key order, number formatting and unicode escapes. Even one different byte gives a completely different HMAC. That's why the official libraries ask for the body as a string or a Buffer, exactly as received.
The six causes, most common first
1. A JSON body parser runs before your webhook handler
In Express, app.use(express.json()) at the top of the app parses every request, including the webhook. By the time your handler runs, req.body is an object, and passing it (or JSON.stringify(req.body)) to Stripe fails. Register the webhook route with a raw parser before the global JSON parser:
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // Buffer, untouched
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// handle event...
res.json({ received: true });
});
app.use(express.json()); // everything else
2. The wrong signing secret
This one catches people after deployment. There are several secrets and they're all called whsec_...:
- Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripeprints its own secret. It's only valid for that CLI session's forwarded events. - Dashboard endpoint, test mode: each endpoint you add under Developers → Webhooks has its own secret.
- Dashboard endpoint, live mode: a different endpoint with a different secret.
Production needs the secret of the live-mode endpoint that points at your production URL. If you have two endpoints for one URL, each event is signed with its endpoint's secret, so check you're reading the right one. Also strip stray quotes or spaces from the environment variable.
3. Next.js reads the body as JSON
In the App Router, read the body as text and don't call req.json() first:
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response('Invalid signature', { status: 400 });
}
// handle event...
return Response.json({ received: true });
}
In the older Pages Router, API routes parse JSON by default. Turn it off for this route with export const config = { api: { bodyParser: false } } and read the raw stream (for example with the micro package's buffer(req)).
4. NestJS without rawBody
Nest parses JSON for you, which is what you want everywhere except the webhook. Since Nest 9 you can keep a raw copy alongside the parsed body:
// main.ts
const app = await NestFactory.create(AppModule, { rawBody: true });
// webhook.controller.ts
@Post('webhooks/stripe')
handle(@Req() req: RawBodyRequest<Request>, @Headers('stripe-signature') sig: string) {
const event = this.stripe.webhooks.constructEvent(req.rawBody!, sig, this.secret);
// handle event...
}
If req.rawBody is undefined, check that the option is set where the app is created and that no custom body parser middleware replaced Nest's.
5. Something between Stripe and your app changes the body
Proxies and platforms can rewrite requests. Things I've seen: a serverless wrapper that base64-encodes the body, a framework middleware that trims whitespace, and an API gateway that re-encodes JSON. Plain Nginx proxy_pass passes the body through untouched, so on a normal VPS this is rarely the cause. To check, log the length and the first characters of the body you verify and compare them with the event payload shown in the Stripe dashboard.
6. The server clock is wrong
The signature includes a timestamp, and the libraries reject events older than the tolerance, 300 seconds by default, to stop replay attacks. The error then says the timestamp is outside the tolerance zone. On a Linux server, check with timedatectl that "System clock synchronized" says yes.
Test the fix locally before deploying
stripe listen --forward-to localhost:3000/api/webhooks/stripe # in a second terminal stripe trigger payment_intent.succeeded
Use the secret printed by stripe listen in your local .env. You should see [200] responses in the CLI output. After deploying, open your endpoint in the dashboard and use the resend button on a recent event to check production.
Make the handler production-ready
- Respond fast. Verify, store the event, return 200, and do slow work (emails, PDFs) afterwards. Slow responses look like failures to Stripe.
- Be idempotent. Stripe can deliver the same event more than once. Save
event.idand skip events you've already processed, or make updates conditional, for example "mark paid only if still pending." - Don't rely on webhooks alone. On this site's checkout, a scheduled job also asks Stripe about orders still marked unpaid, so a missed webhook or a closed browser tab can't leave a paid order stuck.
- Watch the failures. The endpoint page in the Stripe dashboard lists every failed delivery with the response your server gave. Check it after each deploy.
If the webhook request fails in the browser-to-API part of your app instead, my CORS error guide may be the one you need, and if your API returns 502 to Stripe, see fixing Nginx 502 Bad Gateway.
Frequently asked questions
Why does my Stripe webhook work locally but fail in production?
Usually because production uses a different signing secret. The secret from stripe listen only works for CLI-forwarded events; production needs the secret of the live-mode endpoint you created in the dashboard for your production URL.
Can I skip signature verification?
No. Without it anyone who finds your webhook URL can send a fake "payment succeeded" event and get your product for free. Verification is what proves the event came from Stripe.
What does "Webhook payload must be provided as a string or a Buffer" mean?
Your code passed a parsed JavaScript object to constructEvent. Pass the raw body instead: a Buffer from express.raw(), the string from await req.text(), or req.rawBody in NestJS.
Does Stripe retry failed webhooks?
Yes. In live mode Stripe retries a failed delivery for up to three days with increasing delays, so fixing the bug quickly lets most missed events arrive on their own. You can also resend individual events from the dashboard.
How do I find the right webhook secret?
In the Stripe dashboard go to Developers → Webhooks, open the endpoint that points at your URL, and reveal its signing secret. Make sure the dashboard is in the same mode, test or live, as the API keys your server uses.
Payments still not updating?
I fix Stripe integrations in Node.js, Next.js and NestJS apps: webhooks, subscriptions, refunds and the orders that got stuck along the way. Book my React, Next.js and Node.js bug fix service, see my web development services, or contact me with the error message and your framework.
Written by
MD Rakibul Islam Rakib
Full-stack developer, DevOps engineer and Linux system administrator with 5+ years of production experience. I deploy, harden and fix servers and web apps for clients worldwide, and everything in this article runs on real servers I manage, including this site.
- Stripe webhook signature verification failed
- No signatures found matching the expected signature
- Stripe raw body
- constructEvent
- Next.js Stripe webhook
- NestJS rawBody
- whsec


