Stripe: webhook signature verification failing [2026 fix]
Your webhook endpoint isn't using the raw request body for signature verification—Stripe needs the exact bytes before JSON parsing.
Stripe: Webhook Signature Verification Failing [2026 Fix]
TL;DR
Cause: You're verifying the signature against parsed JSON instead of the raw request body bytes. Fix: Passreq.rawBody (not req.body) to stripe.webhooks.constructEvent().---
Real Console Error Messages
Here are the exact errors you're seeing at 2am:
``` Error: No signatures found matching the expected signature for payload. ```
``` Stripe.error.SignatureVerificationError: No signatures found matching the expected signature for payload. Webhook signing secret: 'whsec_live_...' ```
``` verifySignatureHeaders failed for request from IP 54.187.174.x: signature verification failed ```
``` No valid signature headers found in request ```
``` Error: Unable to extract timestamp and signatures from header 'stripe-signature' ```
---
Broken Code → Fixed Code
❌ BROKEN (Express.js)
```javascript app.post('/webhook', express.json(), (req, res) => { const sig = req.headers['stripe-signature']; try { // WRONG: Using parsed JSON body const event = stripe.webhooks.constructEvent( req.body, // ← This is already parsed! sig, process.env.STRIPE_WEBHOOK_SECRET ); res.json({received: true}); } catch (err) { res.status(400).send(Webhook Error: ${err.message});
}
});
```✅ FIXED (Express.js)
```javascript app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => { const sig = req.headers['stripe-signature']; try { // CORRECT: Using raw body bytes const event = stripe.webhooks.constructEvent( req.body, // ← Now this is Buffer/raw bytes sig, process.env.STRIPE_WEBHOOK_SECRET ); res.json({received: true}); } catch (err) { res.status(400).send(Webhook Error: ${err.message});
}
});
```❌ BROKEN (Next.js 13+)
```javascript export async function POST(req) { const body = await req.json(); const sig = req.headers.get('stripe-signature'); try { // WRONG: Already parsed to object const event = stripe.webhooks.constructEvent( body, // ← Object, not bytes! sig, process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { return new Response(err.message, {status: 400}); } } ```✅ FIXED (Next.js 13+)
```javascript export async function POST(req) { const body = await req.text(); const sig = req.headers.get('stripe-signature'); try { // CORRECT: Text/raw format const event = stripe.webhooks.constructEvent( body, // ← String that will be converted to bytes sig, process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { return new Response(err.message, {status: 400}); } } ```❌ BROKEN (Fastify)
```javascript fastify.post('/webhook', async (req, reply) => { try { const event = stripe.webhooks.constructEvent( JSON.stringify(req.body), // ← Re-stringified but hash won't match req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { reply.code(400).send({error: err.message}); } }); ```✅ FIXED (Fastify)
```javascript fastify.post('/webhook', {bodyLimit: 1000000}, async (req, reply) => { try { // Fastify: use getRawBody plugin or custom handler const rawBody = req.rawBody || Buffer.from(JSON.stringify(req.body)); const event = stripe.webhooks.constructEvent( rawBody, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { reply.code(400).send({error: err.message}); } }); ```---
Why This Happens
Stripe signs the exact raw bytes of the request body. When your framework parses JSON, whitespace, key ordering, or encoding changes—even invisibly to you. The signature verification compares a hash of the original bytes against what you provide. If you give it parsed JSON (a JavaScript object), Stripe hashes the re-stringified version, and the hashes don't match.
Version note: As of 2026, this behavior is unchanged in stripe npm package v14+, but you should verify your version with npm list stripe since middleware behavior varies by framework version.
---
Still Broken? Check These Too
1. Wrong webhook secret: Verify you're using STRIPE_WEBHOOK_SECRET (starts with whsec_), not STRIPE_API_KEY. Log console.log(process.env.STRIPE_WEBHOOK_SECRET?.substring(0,10)) to confirm it loads. [See secrets management guide](/?guide=env-variables).
2. Multiple JSON parsers: If you have app.use(express.json()) globally *and* express.json() on the webhook route, the body gets parsed twice. Remove the route-level parser and use express.raw() instead.
3. Proxy/load balancer stripping headers: AWS ALB, nginx, or reverse proxies sometimes drop stripe-signature. Check upstream with curl -v and verify the header arrives at your app. [Debug request headers guide](/?guide=request-debugging).
---
Quick Verification
Test locally with Stripe CLI: ```bash stripe listen --forward-to localhost:3000/webhook stripe trigger payment_intent.succeeded ```
If it works here but fails in production, suspect environment variables or middleware differences.
---
Official Reference
[Stripe Webhooks Documentation](https://stripe.com/docs/webhooks/signatures)
---
Found a different variation? Drop it in the comments—webhook failures vary by framework version and middleware order.