Stripe Webhook Signature Verification Failed — the fix, per framework
If you're seeing No signatures found matching the expected signature for payload or SignatureVerificationError, it is almost never your webhook secret. It's that your framework parsed the request body before Stripe's SDK could read the raw bytes. Here's why, and the exact fix for FastAPI, Express, Next.js, Flask, and Django.
The one root cause behind 90% of these errors
Stripe signs the exact raw bytes of the request body. stripe.Webhook.construct_event(payload, sig_header, secret) recomputes the HMAC over the payload you pass it and compares to the Stripe-Signature header. If your framework has already parsed the body into JSON and you pass the re-serialized object (or a pretty-printed string), the bytes differ — even by one space — and the signature won't match.
JSON body-parser middleware runs before your handler, consumes the stream, and now the raw bytes are gone. You hand Stripe JSON.stringify(req.body), which is NOT byte-identical to what Stripe signed.
Fix per framework
FastAPI (Python)
Read the raw body with await request.body() — do not use a Pydantic model or await request.json() for the webhook route.
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body() # raw bytes — correct
sig = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(payload, sig, WEBHOOK_SECRET)
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail="bad signature")
# ... handle event
return {"received": True}
Express (Node)
The classic one. express.json() applied globally eats the raw body. Mount express.raw() on the webhook route only, before any global JSON parser.
// BEFORE app.use(express.json())
app.post("/webhooks/stripe",
express.raw({ type: "application/json" }), // raw Buffer — correct
(req, res) => {
const sig = req.headers["stripe-signature"];
try {
const event = stripe.webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET);
// ... handle event
res.json({ received: true });
} catch (err) {
res.status(400).send(`Webhook Error: ${err.message}`);
}
});
Next.js (App Router)
Use await req.text() to get the raw body. The old Pages-Router bodyParser: false config does not apply in the App Router.
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const body = await req.text(); // raw string — correct
const sig = req.headers.get("stripe-signature")!;
try {
const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
// ... handle event
return Response.json({ received: true });
} catch (err) {
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 });
}
}
Next.js Pages Router: set export const config = { api: { bodyParser: false } } and read the stream with a buffer helper.
Flask (Python)
@app.route("/webhooks/stripe", methods=["POST"])
def stripe_webhook():
payload = request.get_data() # raw bytes — correct (NOT request.json)
sig = request.headers.get("Stripe-Signature")
try:
event = stripe.Webhook.construct_event(payload, sig, WEBHOOK_SECRET)
except stripe.error.SignatureVerificationError:
return "bad signature", 400
return "", 200
Django
@csrf_exempt
def stripe_webhook(request):
payload = request.body # raw bytes — correct
sig = request.META.get("HTTP_STRIPE_SIGNATURE")
try:
event = stripe.Webhook.construct_event(payload, sig, WEBHOOK_SECRET)
except stripe.error.SignatureVerificationError:
return HttpResponse(status=400)
return HttpResponse(status=200)
Other causes (the remaining 10%)
- Wrong secret: the
whsec_...for a CLIstripe listensession is DIFFERENT from the dashboard endpoint's secret. Don't mix them. - Proxy re-encoding: some API gateways / load balancers re-serialize or gzip the body. Verify the raw bytes reach your handler unaltered.
- Multiple endpoints: if you have several webhook endpoints, each has its own signing secret. Using endpoint A's secret to verify endpoint B's events fails.
- Timestamp tolerance: if your server clock is badly skewed, the default 5-minute tolerance can reject otherwise-valid events.
Webhooks failing silently in production?
A signature-verification bug that 400s every event means Stripe stops retrying and you lose subscription updates without noticing. I run a one-shot audit that tests your live endpoint for signature handling, idempotency, timeout behavior, and retry safety — $199, full refund if I don't find ≥3 issues.
See the audit →How to confirm it's fixed
Use the Stripe CLI to replay a real event against your local endpoint:
stripe listen --forward-to localhost:8000/webhooks/stripe
stripe trigger payment_intent.succeeded
A clean 2xx with no SignatureVerificationError in your logs means the raw body is reaching the SDK correctly.