Stripe Error: signature_verification_failed — Bad Webhook Signature
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'];
try {
const event = stripe.webhooks.constructEvent(
req.body, // must be raw Buffer, NOT JSON
sig,
process.env.STRIPE_WEBHOOK_SECRET, // whsec_... from Dashboard
);
// handle event
} catch (err) {
// err.message: 'No signatures found matching the expected signature for payload'
return res.status(400).send(`Webhook Error: ${err.message}`);
}
res.json({ received: true });
});
A Stripe webhook signature verification failure means your endpoint received a payload that, when hashed with your endpoint secret, didn’t match the HMAC Stripe sent in the Stripe-Signature header. Verification is byte-exact: even a single rewritten newline or a re-serialised JSON object will fail. The error is almost never about Stripe — it’s about the bytes between Stripe’s egress and your constructEvent() call.
Get this right once and you never think about it again. Get it wrong and you silently drop business-critical events: paid invoices that don’t grant access, subscription cancellations that don’t deprovision, refunds that don’t reach your ledger. The fix is almost always to use the raw request body, with no middleware between Stripe’s TCP packet and constructEvent().
Why this happens
- Body was parsed as JSON before verification. By far the #1 cause. Express's `express.json()`, FastAPI's automatic body parsing, Next.js API routes, or any middleware that turns the request body into an object will re-serialise it back to a different byte sequence than Stripe signed. The HMAC will never match.
- Wrong endpoint secret (live vs test, or wrong endpoint). Each webhook endpoint has its own `whsec_...` secret. Using the test-mode secret to verify a live-mode payload fails. Copy-pasting the wrong endpoint's secret (e.g., from a Connect endpoint to a primary endpoint) also fails. Stripe CLI's `stripe listen` issues yet another secret that only works for forwarded events.
- Body modified by reverse proxy or CDN. Cloudflare, nginx, AWS API Gateway, or a service worker may rewrite the body — adding/removing a trailing newline, normalising line endings, or transforming UTF-8 BOMs. Any byte-level change invalidates the signature.
- Tolerance window exceeded (replayed or delayed event). Stripe's default tolerance is 300 seconds between the timestamp in the signature header and your server's clock. If your server clock is skewed, or the request was queued/replayed beyond 5 minutes, verification fails with a tolerance error rather than a hash mismatch.
- Multiple endpoint secrets needed but only one configured. If you create webhook endpoints for multiple Stripe accounts (Connect platforms) or rotate secrets, your handler must try each candidate secret. Stripe's SDK lets you pass an array; passing only the first secret fails when the event came from another endpoint.
How to fix it
Fixes are ordered by likelihood. Start with the first one that matches your context.
1. Use the raw request body — never parsed JSON
The single most common cause is a body parser running before the signature check. In Express, mount `express.raw()` *only* on the webhook route, before any global JSON parser. In Next.js Pages Router, set `bodyParser: false` in the route config. In Next.js App Router, read `request.text()` directly. In FastAPI, use `await request.body()` (returns bytes).
// app/api/stripe/webhook/route.js (Next.js App Router)
import Stripe from 'stripe';
export const runtime = 'nodejs';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
const body = await req.text(); // raw string, byte-identical to Stripe's payload
const sig = req.headers.get('stripe-signature');
try {
const event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET,
);
return Response.json({ received: true, type: event.type });
} catch (err) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
}
2. Verify you're using the right endpoint secret for this exact endpoint
Each endpoint in Dashboard → Developers → Webhooks has its own `whsec_...`. Open the endpoint detail page, click "Reveal" next to "Signing secret," and confirm character-for-character that it matches your `STRIPE_WEBHOOK_SECRET` env var. If you're testing locally with `stripe listen`, that command prints a *different* secret each session — copy that one.
3. Try multiple secrets if you have several endpoints or recently rotated
The Stripe Node SDK accepts an array of secrets in `constructEvent`. Iterate through known-valid secrets, returning the first one that verifies. Useful during secret rotations and multi-endpoint setups.
function verifyAny(rawBody, signature, secrets) {
for (const secret of secrets) {
try {
return stripe.webhooks.constructEvent(rawBody, signature, secret);
} catch (_) { /* try next */ }
}
throw new Error('No webhook secret matched');
}
const event = verifyAny(rawBody, sig, [
process.env.STRIPE_WEBHOOK_SECRET_PRIMARY,
process.env.STRIPE_WEBHOOK_SECRET_CONNECT,
]);
4. Check server clock skew if you see tolerance errors
If `err.message` contains "Timestamp outside the tolerance zone," your server clock is more than 5 minutes off Stripe's. Run `chronyc tracking` (Linux), `sntp -sS time.apple.com` (macOS), or `w32tm /query /status` (Windows) and resync via NTP. Containers without time sync (some lightweight Linux distros) are common offenders.
5. Inspect what reaches your handler with a passthrough log
Log the raw body length, first/last 20 bytes, and the `Stripe-Signature` header before verification. Compare against Stripe's webhook attempt log in Dashboard → Webhooks → Endpoint → Recent deliveries. Differences (length off by 1, smart quotes, BOM) instantly reveal upstream rewriting.
Detection and monitoring in production
Alert on a non-zero `signature_verification_failed` rate per webhook endpoint, broken out by endpoint and by `event.type` you can't yet see (you can derive event type from `Stripe-Signature` timestamp + IP + delivery patterns even without verification). Stripe will retry failed deliveries, so a sustained verify-fail rate means business-critical events (payments, subscriptions) are being silently dropped after retries exhaust.
Related errors
- stripecard_declinedThe customer's card issuer refused to authorise the charge. Stripe relays the issuer's decline reason in `decline_code` (e.g., `insufficient_funds`, `lost_card`, `do_not_honor`).
- stripeinsufficient_fundsThe customer's card issuer declined the charge because the account doesn't have enough available balance to cover the amount, including any pending authorisations and holds.
- stripeauthentication_requiredThe card issuer requires the customer to complete 3D Secure (SCA / PSD2) authentication, but the PaymentIntent was confirmed without the customer completing the 3DS challenge — usually because you're using a bare Card element instead of the Payment Element or didn't handle `requires_action`.
- stripeapi_key_invalidThe API key you sent in the `Authorization: Bearer ...` header doesn't match any active Stripe key — usually because of a test/live mode mismatch, a deleted or rolled key, copy-paste corruption, or a missing environment variable falling through to `undefined`.
- openairate_limit_exceededYour account has exceeded its per-minute request (RPM) or per-minute token (TPM) limit for the model you're calling. Limits are tier-based and per-model.
Frequently asked questions
Why does my webhook work locally with `stripe listen` but fail in production? +
I'm using Express. Why does `express.json()` break Stripe verification? +
Can I just compute the signature myself instead of using the SDK? +
Should `signature_verification_failed` return 400 or 401? +
Does the `Stripe-Signature` header contain multiple signatures? +
I rotated my webhook secret. How do I avoid downtime? +
Why do I get verification failures for some events but not others? +
Can a wrong API version cause `signature_verification_failed`? +
When to escalate to Stripe support
Open a support ticket only after you've (a) logged the raw body length and confirmed it matches Stripe's `Content-Length`, (b) verified the endpoint secret character-for-character, and (c) ruled out reverse proxy/CDN rewriting. If verification still fails, attach a Stripe Dashboard webhook attempt ID — support can replay the exact byte stream and confirm it from their side.