Stripe Webhook Signature Failed — Raw Body Required
StripeSignatureVerificationError: No signatures found matching the expected signature for payload. Your endpoint logs show a JSON body that looks perfect. Stripe CLI triggers work on a colleague’s laptop. Production fails. Almost every time, the framework parsed JSON before Stripe’s verifier saw the bytes. The signature is HMAC over the raw body string. Re-serialized JSON is a different message.
What Stripe signs
The Stripe-Signature header covers timestamp + '.' + raw_body. If Express ran express.json() globally, req.body is an object. Calling JSON.stringify(req.body) does not restore the original spacing, key order, or Unicode escapes. Verification fails even when the event is genuine.
Express: capture raw bytes for one route
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
const app = express();
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["stripe-signature"];
if (!sig || Array.isArray(sig)) {
res.status(400).send("missing signature");
return;
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error(err);
res.status(400).send("invalid signature");
return;
}
// handle event.type ...
res.json({ received: true });
},
);
// JSON parser for everything else — AFTER the webhook route
app.use(express.json());
Order matters. A global express.json() registered first will consume the stream; the webhook route never sees raw Buffer/string.
Fastify and Next.js
Fastify: use addContentTypeParser for the webhook path or req.rawBody plugins carefully; do not JSON.parse before constructEvent.
Next.js App Router:
export const runtime = "nodejs";
export async function POST(req: Request) {
const rawBody = await req.text();
const sig = req.headers.get("stripe-signature")!;
const event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
// ...
return Response.json({ received: true });
}
Do not req.json() first. In Pages Router, disable bodyParser for that API route and read the buffer from the incoming message.
Other failure modes (after raw body is fixed)
| Symptom | Cause |
|---|---|
| Works with CLI, fails in Dashboard | Wrong signing secret (whsec_… for that endpoint / mode) |
| Intermittent failures | Load balancer or middleware modifying body (gzip re-encode, BOM) |
Timestamp outside tolerance | Server clock skew — sync NTP; default tolerance is 300s |
| Test mode events on live secret | Mixing sk_test webhook secret with live endpoint |
| Multiple secrets during rotation | Construct with old and new secrets until cutover completes |
Idempotency after verify
Signature success only proves authenticity. Stripe retries on 5xx / timeouts. Store event.id and skip duplicates:
if (await db.hasProcessed(event.id)) return res.json({ received: true });
await db.process(event);
await db.markProcessed(event.id);
Return 200 quickly; do heavy work async if needed — but only after durable enqueue, or you will drop work on process crash.
Debug ritual
- Log whether
typeof body === "string" || Buffer.isBuffer(body)at the verifier. - Confirm the endpoint’s signing secret from Dashboard → Developers → Webhooks → that URL (not the API key).
- Replay with Stripe CLI:
stripe listen --forward-to localhost:3000/webhooks/stripe. - Compare clock:
date -uon the server vs time.is.
Endpoint secrets get mixed up
Each Stripe webhook endpoint has its own whsec_. Verifying production traffic with the CLI secret from stripe listen fails every time. Name secrets by environment and endpoint. During rotation, try constructEvent with the new secret, then fall back to the old for a short window — log which slot succeeded, never the secret value itself.
If you remember one rule: verify the bytes Stripe sent, not the object your framework built. Everything else is secret hygiene and retries.