If you've integrated Stripe webhooks, you've probably seen this error:
StripeSignatureVerificationError: No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
The error message is accurate but doesn't say much. Below are the five causes I see most often, in order of how common they are, with a fix for each.
How the signature works (30 seconds)
Every webhook request has a Stripe-Signature header that looks like this:
t=1728470400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Stripe computes v1 as an HMAC-SHA256 of the string "{t}.{raw_body}", keyed with your endpoint's signing secret (whsec_...). The library recomputes it on your side and compares the two. So verification only succeeds when all three of these match exactly what Stripe used:
- the raw body bytes,
- the signing secret,
- a timestamp within the tolerance window (5 minutes by default).
Every cause below breaks one of those three.
1. Your framework parsed the body before you verified it
This causes the large majority of failures. Your framework reads the body, parses it as JSON, and hands you an object. When you pass that object (or JSON.stringify(req.body)) to constructEvent, the bytes no longer match. Key order, whitespace and unicode escaping can all change, and any change at all breaks the HMAC.
The fix is always the same: get the untouched bytes and verify those.
Express
Use express.raw() on the webhook route, and register it before any global express.json():
const express = require('express');
const Stripe = require('stripe');
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
// Must come before app.use(express.json())
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // a Buffer, thanks to express.raw()
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Webhook verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// handle event...
res.sendStatus(200);
});
app.use(express.json()); // everything else gets parsed JSON
If you can't change the middleware order, keep a copy of the raw bytes with the verify hook instead:
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf; },
}));
// then: stripe.webhooks.constructEvent(req.rawBody, sig, secret)
To check quickly, log Buffer.isBuffer(req.body) in the handler. If it prints false, the raw body has already been lost.
Next.js (App Router)
Use req.text(). Don't use req.json():
// app/api/webhook/route.js
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
const body = await req.text();
const sig = req.headers.get('stripe-signature');
try {
const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET);
// handle event...
return new Response('ok', { status: 200 });
} catch (err) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
}
Next.js (Pages Router)
Turn off the built-in body parser and read the stream yourself:
// pages/api/webhook.js
export const config = { api: { bodyParser: false } };
async function readRaw(req) {
const chunks = [];
for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
return Buffer.concat(chunks);
}
export default async function handler(req, res) {
const buf = await readRaw(req);
const event = stripe.webhooks.constructEvent(
buf, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
res.status(200).end();
}
NestJS
Enable raw body support when you create the app, then read req.rawBody:
const app = await NestFactory.create(AppModule, { rawBody: true });
// in the controller
@Post('webhook')
handle(@Req() req: RawBodyRequest<Request>, @Headers('stripe-signature') sig: string) {
const event = this.stripe.webhooks.constructEvent(req.rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET);
}
Fastify
Tell Fastify to give you a Buffer for JSON:
fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
done(null, body);
});
// request.body is now a Buffer; parse it yourself after verifying
(Scope this to the webhook route with a plugin if other routes need parsed JSON.)
Flask
@app.post("/webhook")
def webhook():
payload = request.get_data() # raw bytes. Don't use request.json
sig = request.headers.get("Stripe-Signature")
event = stripe.Webhook.construct_event(payload, sig, os.environ["STRIPE_WEBHOOK_SECRET"])
return "", 200
Cloudflare Workers / edge runtimes
There's no Node crypto here, so use the async variant with the Web Crypto provider:
const stripe = new Stripe(env.STRIPE_SECRET_KEY, { httpClient: Stripe.createFetchHttpClient() });
const body = await request.text();
const event = await stripe.webhooks.constructEventAsync(
body,
request.headers.get('stripe-signature'),
env.STRIPE_WEBHOOK_SECRET,
undefined,
Stripe.createSubtleCryptoProvider()
);
2. You're using the wrong secret
The secret you pass must be the endpoint signing secret:
- It starts with
whsec_. Ansk_live_,sk_test_,rk_orpk_key will never verify a webhook. - Each endpoint has its own secret. If you have a staging endpoint and a production endpoint, they don't share one. Copy it from Developers → Webhooks → your endpoint → Signing secret.
-
The Stripe CLI uses its own secret.
stripe listen --forward-to localhost:3000/webhookprints awhsec_...that's different from the dashboard one. Locally you need the CLI value; in production you need the dashboard value. - If you rolled the secret, the old one stops working once its expiry passes. Redeploy with the new one.
Also check for stray whitespace or quotes when the secret comes from an .env file or a CI variable. A trailing newline is enough to break it. A cheap guard:
const secret = process.env.STRIPE_WEBHOOK_SECRET?.trim();
if (!secret?.startsWith('whsec_')) throw new Error('STRIPE_WEBHOOK_SECRET looks wrong');
3. Test mode vs. live mode
Test mode and live mode endpoints are separate objects with separate secrets. If your production server receives live events but has the test-mode whsec_ (or the other way around), every single event fails, even though nothing looks wrong. Check event.livemode in a few successful test events, and keep the two secrets in clearly named variables (STRIPE_WEBHOOK_SECRET_LIVE / _TEST) so they can't get swapped silently.
4. Something modified the body in transit
Your code may be fine, but something in front of it changes the request:
- A reverse proxy or API gateway that re-encodes the body, or adds or removes a trailing newline.
- Logging or tracing middleware that reads the stream and re-serializes it.
- Serverless platforms that base64-encode the body (AWS API Gateway with
isBase64Encoded: true). Decode it back to bytes before you verify. - Converting the body with the wrong encoding, e.g.
buf.toString('latin1'). If you convert it to a string, use UTF-8, or just pass the Buffer.
Rule of thumb: verify as early as possible and as close to the socket as possible, then parse.
5. The timestamp is too old
By default the libraries reject a signature whose t= is more than 300 seconds away from your server's clock. This shows up as Timestamp outside the tolerance zone. Common reasons:
- Your server or container clock has drifted. Make sure NTP is running.
- You put the raw webhook on a queue and verify it later. Verify when it arrives, then queue the parsed event.
- You're replaying an old payload you saved during debugging.
You can pass a larger tolerance as the fourth argument (constructEvent(body, sig, secret, 600)), but the window is there to stop replayed requests, so fix the clock before you widen it.
After it verifies: don't lose events
Getting the signature to pass is half the job. A few habits that prevent the next round of bugs:
- Return 2xx fast. Acknowledge the event, then do the slow work (emails, DB writes, API calls) in the background. Stripe treats slow responses as failures and retries them.
-
Be idempotent. Stripe can deliver the same event more than once. Store
event.idand skip events you've already processed. -
Don't count on ordering.
invoice.paidcan arrive beforecustomer.subscription.updated. Fetch the current object from the API if order matters. - Plan for downtime. Live-mode retries stop after about 3 days. If your endpoint is down longer than that, or keeps erroring, those events are gone. You can recover them from the Events API, but having something that stores events and retries them for you is much nicer.
Quick debugging checklist
- Is the thing you pass to
constructEventa Buffer or string of the exact request bytes? - Does the secret start with
whsec_, and is it for this endpoint and this mode? - Locally, are you using the
stripe listensecret? - Is anything between Stripe and your handler touching the body?
- Is your server clock correct?
If those five check out, the signature will verify.
The full write-up with more framework fixes is the Fix Your Stripe Webhooks guide ($9). I also open-sourced the relay I use to store and retry events: webhook-relay. I'm the author of both.
Top comments (0)