
Stripe Webhook Signature Verification Failed? Every Cause and Fix (Node, Next.js, Python, 2026)
Almost always one of three things: a wrong endpoint secret (test, live and Stripe CLI each have their own whsec_), a request body that got parsed and re-serialized before reaching constructEvent, or a proxy that re-encoded it. Verify the signature on the exact raw bytes, then dedupe by event.id and return 2xx fast.
Why does Stripe webhook signature verification fail?
The thesis first, because it saves you hours: the code is rarely the bug — the inputs are. I’ve debugged this in production for Nixbly clients more times than I’d like to admit, and it’s almost never the handler logic. It’s a mismatched secret or a mangled body.
Here’s the error, as the Node SDK phrases it (other SDKs convey the same failure with slightly different wording):
Webhook signature verification failed. No signatures found matching the expected signature for payload.
Now the mental model that fixes most of these in five minutes. Stripe signs every webhook by computing an HMAC over the exact raw bytes of the request body plus a timestamp, keyed with your endpoint’s signing secret. It ships that signature in the Stripe-Signature header, which carries a timestamp (t=) and one or more signatures (v1=). When your server calls the verifier, the SDK recomputes the same HMAC on the body it received and compares. If they don’t match, you get the error above.
// Node
const event = stripe.webhooks.constructEvent(rawBody, sigHeader, endpointSecret);
# Python
event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
There are exactly three inputs: the raw body, the Stripe-Signature header, and the endpoint secret. If any one of them is off by a single byte, verification fails every single time — even with perfect application code. This is the same source-of-truth discipline I lean on in how to integrate Stripe into your SaaS — the webhook is only trustworthy because the signature is.
See Stripe — Resolve webhook signature verification errors.
How do I diagnose a signature failure in 60 seconds?
Before you touch a line of code, isolate which of the three inputs is wrong. Three checks, in order.
The 60-second webhook signature triage
- Confirm the secret matches the modeTest, live, and Stripe CLI each have their own whsec_. Match the event's origin.
- Confirm raw bytes reach the verifierLog typeof body / Buffer.isBuffer right before constructEvent. Should be a Buffer or string, not an object.
- Check for a proxy or middlewareGateway, body logger, or parser upstream that rewrites bytes before your handler sees them.
Two quick tells that save you the most time:
- It works under
stripe listenbut fails in production (or vice versa)? Almost certainly a secret mismatch. Each mode uses a differentwhsec_. - Every single event fails identically? Suspect raw-body handling, not the secret. A wrong secret and a mangled body produce the same error message, but a mangled body fails uniformly while a secret problem tends to track one specific environment.
Cause 1 — wrong or mismatched endpoint secret
Every webhook endpoint you create in Stripe has its own signing secret, a string that starts with whsec_. That alone trips people up, but the real trap is mode:
- Test mode and live mode are signed with different secrets. Copy your test
whsec_into your production env var and every live event fails — your code looks correct, so you’ll blame the code. stripe listenprints a third, differentwhsec_when you start local forwarding. It’s printed to your terminal and is valid only for events the CLI forwards. It is not your Dashboard endpoint’s secret.
The single most common version of this bug I see: one shared STRIPE_WEBHOOK_SECRET env var copied across local, staging, and production. Three environments, three different correct secrets, one value — so two of the three always fail.
Fix: open the Dashboard, go to the exact endpoint receiving the event, reveal its signing secret, and confirm it matches the env var for that mode. Don’t assume — reveal and compare character by character.
How do I pass the raw body in each framework?
This is the number one real cause, and it’s almost always a framework default working against you. The HMAC is computed over the precise UTF-8 bytes Stripe sent. Any JSON parse-then-re-serialize, any whitespace change, any key reorder, any re-encoding changes the bytes — and a different byte string produces a different HMAC. The meaning being identical is irrelevant; the verifier compares bytes, not JSON.
The villain is usually a body parser that runs before your handler, turning the raw bytes into a parsed object. By the time you stringify it back, it’s no longer what Stripe signed.
Here’s the raw-body fix in each stack.
Node / Express — mount express.raw on the webhook route, and make sure express.json() does not run first:
const express = require('express');
const Stripe = require('stripe');
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
const processed = new Set(); // back this with Redis/DB in prod
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }), // raw bytes, route-scoped
(req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // Buffer of exact raw bytes
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
if (processed.has(event.id)) return res.json({ received: true });
processed.add(event.id);
// hand off to async worker, then ack fast
res.json({ received: true });
}
);
If you call app.use(express.json()) globally, register the webhook route before it, or scope the JSON parser so it skips the webhook path. The order matters.
Next.js App Router — read the body as text, never as JSON:
// app/api/webhooks/stripe/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(); // raw string — NOT req.json()
const sig = req.headers.get('stripe-signature');
let event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
// dedupe by event.id, then process async
return Response.json({ received: true });
}
The instant you call await req.json() here, the App Router parses and you’ve lost the original bytes. await req.text() preserves them.
Python / FastAPI — read the raw body with await request.body():
from fastapi import FastAPI, Request, HTTPException
import stripe, os
app = FastAPI()
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body() # exact raw bytes
sig_header = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, WEBHOOK_SECRET
)
# stripe.error.SignatureVerificationError on current SDK;
# also importable as stripe.SignatureVerificationError — confirm for your version
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
# dedupe by event["id"], then queue for async processing
return {"received": True}
The same rule applies to the two stacks not shown above. Next.js Pages Router: disable the parser with export const config = { api: { bodyParser: false } } and read the raw stream. Flask: use request.get_data() (raw) and never request.json. Same discipline, every framework.
Why one body verifies and the other throws
Raw body preserved
- express.raw / req.text() / request.body() delivers exact bytes
- SDK recomputes HMAC over identical input
- Matches the v1= signature in the header
- constructEvent returns the event — verified
Parsed then re-serialized
- Body parser turns bytes into an object
- Re-stringify reorders keys, drops whitespace
- HMAC computed over different bytes
- No match — 'No signatures found...' thrown
Cause 3 — a proxy or middleware re-encoded the body
This is the sneaky one the docs barely mention, and it bites when your application code is provably correct. Something upstream of your handler mutates the bytes before they arrive.
Usual suspects:
- AWS API Gateway base64-encoding the body, so what reaches your Lambda isn’t the raw payload Stripe sent.
- Cloudflare or Nginx body transformations, compression, or normalization on the proxy path.
- Request-logging middleware that
JSON.parses the body to pretty-print it, then re-emits a re-serialized version downstream.
How to confirm: compute a hash (or just the byte length) of the body your handler receives and compare it to the payload Stripe shows in the Dashboard for that event. If the lengths differ, something between Stripe and your code is rewriting bytes — your verifier is innocent.
Fix: exclude the webhook route from every body-parsing and body-logging layer, and pass the bytes through untouched. On API Gateway, that may mean handling the base64 decode yourself before verifying. The rule is invariant across stacks: nothing touches the body until after constructEvent.
The three causes at a glance
What the docs skip: idempotency and replay protection
Once the signature verifies, the signature doc goes quiet — but two production concerns remain, and skipping them is how you end up double-charging or double-provisioning. If you’re still deciding whether webhooks are even the right transport, I compared the tradeoffs in webhooks vs polling vs API.
Duplicate delivery is normal. Stripe can and does deliver the same event more than once — on retries, on network blips, occasionally for no visible reason. Dedupe by event.id: persist the IDs you’ve already processed and make every handler idempotent so a second delivery is a no-op. In the rare case where Stripe emits two distinct Event objects for the same underlying change, also dedupe on the data.object id plus event.type. In the snippets above I used an in-memory Set to keep them short; in production, back it with Redis or a unique constraint on a processed_events table.
Replay protection is built in — leave it on. constructEvent enforces a default tolerance (300 seconds / 5 minutes as of 2026 — confirm current in the docs) on the signed t= timestamp, rejecting bodies replayed outside that window. It’s configurable, but never set it to 0 — that disables replay protection entirely.
Return 2xx fast, process async. Verify, dedupe, enqueue, and acknowledge — then do the real work in a worker. If you run slow business logic inline, you risk timing out, and a timeout makes Stripe retry, which compounds your duplicate problem. Ack in milliseconds; process in the background.
And the discipline that ties it all together: the verified event is the source of truth. After verifying, fetch or confirm the resource’s current status from Stripe before you grant or revoke anything. Never act on a browser redirect or a success_url — those are trivially forgeable. Only a signed, verified event that you then confirm against the API earns the right to flip access. These are exactly the events you act on once signatures pass — the failure and recovery cases I walk through in recover failed payments with Stripe dunning. See Stripe — webhooks for the dedupe and async guidance.
Production-grade webhook flow
FAQ
Why does it work in the Stripe CLI but fail in production? Different whsec_ per mode and per endpoint. The CLI prints its own forwarding secret; your live endpoint has a separate one. Match the secret to where the event actually originated.
Can I just JSON.parse and re-stringify to a canonical form? No. There is no canonical form that recovers the original bytes. The HMAC is over the exact bytes Stripe sent, not over the meaning of the JSON. Any round-trip through a parser changes the bytes and breaks the match.
Does the order of the v1= signatures matter? No. Stripe may include several signatures in the header (for example during secret rotation). A match on any one valid signature passes verification.
What does the t= timestamp do? It’s signed alongside the payload, and constructEvent rejects it if it falls outside the tolerance window (300s by default, as of 2026). That’s your replay protection — an attacker can’t capture a valid request and resend it hours later.
Do I need a separate secret per endpoint? Yes. Each endpoint has its own whsec_. If you run multiple endpoints (say one for payments, one for Connect), each one verifies only against its own secret.
The bottom line
Right secret for the right mode, plus the exact raw bytes passed untouched into constructEvent, equals a signature that verifies. That’s the whole fix for 99% of these failures. If you’ve checked the secret and the body and it still fails, look upstream — a proxy or logger is rewriting bytes you never see.
Then make it production-grade: dedupe by event.id and return 2xx fast, processing the real work async. And trust only the verified event — never a redirect, never a query param. This is the same raw-body discipline that carries over to other LATAM processors; I cover the Conekta version, including RSA-signed verification for OXXO and SPEI, in the Conekta integration guide. Get the bytes right and everything downstream gets easier.