Stripe webhook signature mismatch is the most common Stripe webhook bug. Your handler reads the Stripe-Signature header, computes the HMAC, and gets a different value — Stripe rejects every delivery with a 401, 403, or your handler explicitly returns 401. The fix is almost always one of three things.
Root Causes
1. Body was parsed before HMAC was computed (~80% of cases)
Stripe signs the raw request bytes, exactly as sent. If express.json() or any body parser runs before your verification code, the bytes have changed (whitespace, key ordering, escape sequences) and the HMAC will never match. Use express.raw({ type: 'application/json' }) on the webhook route — and only on the webhook route.
2. Wrong webhook secret
Each Stripe webhook endpoint gets its own signing secret (visible in the Stripe Dashboard at Developers → Webhooks → click endpoint → Signing secret). If you have multiple endpoints (production, staging, Connect), you have multiple secrets. Loading the wrong one — or the test-mode secret on a live event — causes 100% mismatch.
3. Timestamp tolerance exceeded
Stripe's signature header includes a t= timestamp inside the signed material. stripe.webhooks.constructEvent() rejects deliveries older than 5 minutes by default, returning SignatureVerificationError. If your server's clock is wrong, you'll see this on every delivery. Run NTP.
Fix It — The Working Stripe Verification Pattern
// Express — the only correct shape
import express from 'express'
import Stripe from 'stripe'
const app = express()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET
app.post('/webhooks/stripe',
// CRITICAL: raw, not json
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature']
let event
try {
event = stripe.webhooks.constructEvent(
req.body, // Buffer of exact bytes
sig,
endpointSecret
)
} catch (err) {
console.error('Webhook signature verification failed:', err.message)
return res.status(401).send('signature mismatch')
}
// event is now verified — handle it
switch (event.type) {
case 'payment_intent.succeeded':
// ...
break
}
res.status(200).send('ok')
}
)
Debugging Checklist
- Add
console.log({ bodyHash: crypto.createHash('sha256').update(req.body).digest('hex').slice(0,8), sig })on receive. If the same body hash produces different sigs in dev vs prod, your prod body parser is mutating the body upstream. - Use the Stripe CLI:
stripe listen --forward-to localhost:3000/webhooks/stripe— fires real signed test events. If those work locally, the problem is environment-specific. - Compare
STRIPE_WEBHOOK_SECRETacross environments. Print the prefix (whsec_xxxxxx...) on boot. - Server time check:
date -u. Off by >5 minutes? Runntpdateor fix your clock sync.
Stripe-Specific Gotchas
- Test webhooks fired from the Stripe Dashboard ("Send test webhook") are signed with the same secret as real events — you don't need a special test secret.
- Connect webhooks (account-level events) use a different signing secret than account webhooks. Don't share the secret across both.
- Multiple endpoints are common — make sure your env var matches the specific endpoint the request was sent to.
How to Reproduce
Use WebhookWhisper's signature playground (free, no signup) to compute a Stripe-style signature locally. Compare your computed signature against what your handler computes on the same body. If they differ, your handler is reading a different body — find where the body is being mutated.
Frequently Asked Questions
Why does my Stripe webhook work locally with `stripe listen` but fail in production?
The Stripe CLI sends to your local machine without going through any reverse proxy. Production has Cloudflare, nginx, or a load balancer that may strip or modify the body. Add the body-hash debug log and compare.
Can I disable Stripe's timestamp tolerance check?
stripe.webhooks.constructEventAsync allows tolerance to be set to 0 (disabled), but don't — it's the only defense against replay attacks. Fix your clock instead.
How do I rotate the Stripe webhook secret without downtime?
Stripe's dashboard supports adding a second active signing secret per endpoint. Configure your handler to accept either secret during the rotation window, then remove the old one.