Stripe: webhook signature verification failing [2026 fix]
Webhook signature verification fails when endpoint secret is missing or mismatched; use correct env var and validate raw request body.
Stripe: Webhook Signature Verification Failing [2am Emergency Fix]
TL;DR
Cause: You're using the wrong endpoint secret or passing processed JSON instead of raw request body toverifyWebhookSignature().
Fix: Load STRIPE_WEBHOOK_SECRET from environment, pass req.rawBody (not req.body), and ensure your framework doesn't auto-parse webhook requests.---
Exact Console Errors You'll See
``` Error: No signatures found matching the expected signature for payload. ```
``` Stripe.error.SignatureVerificationError: No signatures found matching the expected signature for payload. Webhook id: evt_xxxxx ```
``` webhook signature verification failed: computed sig does not match stripe-signature header ```
``` Error: Invalid signature. Expected sig: sha256=xxx, got: sha256=yyy ```
``` TypeError: Cannot read property 'rawBody' of undefined at Object.constructEvent ```
---
Broken Code vs. Exact Fix
❌ BROKEN: Express with auto-parsed body
```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const app = express();
// Problem: This parses JSON, destroying the raw signature app.use(express.json());
app.post('/webhook', (req, res) => {
const sig = req.headers['stripe-signature'];
const endpointSecret = 'whsec_test_secret'; // WRONG: hardcoded
try {
// WRONG: passing parsed req.body
const event = stripe.webhooks.constructEvent(
req.body, // ← This is already parsed JSON, signature won't verify
sig,
endpointSecret
);
res.json({received: true});
} catch (err) {
res.status(400).send(Webhook error: ${err.message});
}
});
```
✅ FIXED: Webhook-specific raw body handling
```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const app = express();
// Parse JSON for normal routes app.use(express.json());
// CRITICAL: Webhook route BEFORE global middleware to capture raw body
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; // ← Load from env
if (!endpointSecret) {
return res.status(400).send('Missing STRIPE_WEBHOOK_SECRET environment variable');
}
try {
// CORRECT: passing raw buffer
const event = stripe.webhooks.constructEvent(
req.body, // ← Now this is Buffer/raw body
sig,
endpointSecret
);
// Handle event
switch (event.type) {
case 'payment_intent.succeeded':
console.log('Payment succeeded:', event.data.object.id);
break;
case 'charge.refunded':
console.log('Charge refunded:', event.data.object.id);
break;
}
res.json({received: true});
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(Webhook error: ${err.message});
}
});
// IMPORTANT: Put other routes after webhook app.post('/api/other', express.json(), (req, res) => { // This gets normal parsed JSON res.json({ok: true}); });
app.listen(3000); ```
❌ BROKEN: Next.js API Route
```javascript // pages/api/webhook.js import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export default async function handler(req, res) { if (req.method !== 'POST') return res.status(405).end(); const sig = req.headers['stripe-signature']; // WRONG: Next.js auto-parses body const event = stripe.webhooks.constructEvent( req.body, // ← Already parsed sig, 'whsec_test123' ); }
export const config = { api: { bodyParser: false }, // Forgot this }; ```
✅ FIXED: Next.js API Route
```javascript // pages/api/webhook.js import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end();
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
try {
const event = stripe.webhooks.constructEvent(
req.body, // ← Correctly raw buffer due to config below
sig,
endpointSecret
);
res.status(200).json({received: true});
} catch (err) {
return res.status(400).send(Webhook Error: ${err.message});
}
}
// CRITICAL: Disable body parsing for this route export const config = { api: { bodyParser: false, }, }; ```
---
Environment Variable Checklist
✓ STRIPE_WEBHOOK_SECRET is in your .env.local (starts with whsec_)
✓ You copied it from [Stripe Dashboard > Webhooks > Signing Secret](https://dashboard.stripe.com/webhooks)
✓ Secret is different per environment (test vs. live)
✓ Not hardcoded in source
---
Still Broken? Check These Too
1. Webhook URL mismatch: Your registered endpoint in Stripe dashboard (https://api.example.com/webhook) must exactly match your actual server URL. Check [related: domain routing](/?guide=webhook-domain-routing).
2. Stripe CLI testing: If testing locally with stripe listen, the CLI generates a different signing secret than dashboard webhooks. Don't mix them. Use stripe listen --print-secret output for local .env.
3. Timestamp validation: Stripe rejects signatures older than 5 minutes. If server clock is skewed >300s, verification fails. Sync NTP: ntpdate -s time.nist.gov (Linux) or check System Preferences (Mac).
4. Proxy/reverse proxy stripping headers: If behind nginx/CloudFlare, ensure stripe-signature header reaches your app. Check [related: headers through proxies](/?guide=proxy-header-passthrough).
5. Race condition on secret rotation: If you rotated webhook secrets in Stripe dashboard, old requests in flight may still arrive. Both old+new secrets remain valid for ~24 hours.
---
Version Notes
Stripe Node.js SDK: This guide covers stripe >= 8.0. Earlier versions used different method names (not confirmed for 2026, verify with npm list stripe).
Express versions: express >= 4.16.0 includes express.raw(). For older versions, use body-parser separately.
---
Official Resources
---
Found a different variation? Drop it in the comments. (Cloudflare Workers? Deno? Lambda with API Gateway? Share your fix!)