If you have ever wired Stripe webhooks into a Next.js app and watched every event come back as Webhook signature verification failed, the cause is almost always the same: by the time your handler runs, the request body is no longer the bytes Stripe signed. Verification is an HMAC over the exact raw payload — reparse or re-serialise it and the signature can never match.
Here is the App Router way to read the raw body, verify the signature, and make the endpoint survive Stripe’s retries.
Why constructEvent needs the raw body
stripe.webhooks.constructEvent(payload, sig, secret) recomputes an HMAC-SHA256 over payloadusing your endpoint’s signing secret and compares it to the stripe-signatureheader. The check is byte-exact. If anything in the chain parses the JSON and hands you an object — or middleware buffers and rewrites the stream — the recomputed hash differs from Stripe’s and verification fails, even though the JSON “looks” identical.
The good news in the App Router: route handlers do not auto-parse the body the way the old Pages api routes did (no more export const config = { api: { bodyParser: false } }). You just read it as text.
The route handler
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
// The raw, unparsed bytes Stripe signed — read as text, never JSON.
const body = await req.text();
const sig = req.headers.get("stripe-signature");
if (!sig) {
return new Response("Missing stripe-signature header", { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
// Bad signature = forged or misconfigured. Return 4xx; do NOT process it.
const msg = err instanceof Error ? err.message : "unknown error";
return new Response(`Signature verification failed: ${msg}`, { status: 400 });
}
await handleEvent(event);
return new Response(null, { status: 200 });
}Two things people get wrong here:
- Don’t call
req.json()anywhere beforeconstructEvent. Once the stream is consumed as JSON you cannot get the original bytes back, and verification is dead. - Keep middleware off this route. If you run global middleware that touches the body, exclude
/api/webhooks/stripefrom its matcher.
The endpoint-secret trap (test vs live vs CLI)
A “works locally, fails in production” webhook is almost always the wrong STRIPE_WEBHOOK_SECRET. There are three different signing secrets, and they are not interchangeable:
- The secret
stripe listenprints when you forward events to localhost (startswhsec_) — use this in dev. - The secret on your test-mode dashboard endpoint.
- The secret on your live-mode dashboard endpoint — a different value again. Set this in production env vars, not the test one.
Idempotency: Stripe will send the same event twice
Stripe retries delivery (with back-off, for up to three days) until it gets a 2xx. Network blips and slow handlers mean the same event will arrive more than once, and two retries can even overlap. The simplest robust defence is to make the side effects idempotent — upsert keyed on the Stripe object id, so a re-delivery converges to the same state instead of double-applying:
async function handleEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed":
case "customer.subscription.updated":
case "customer.subscription.deleted":
// Idempotent: upsert keyed on the Stripe subscription id, so a duplicate
// or retried delivery converges to the same row, never double-applies.
await upsertSubscriptionFromEvent(event);
break;
// ...only the events you actually act on
}
}If a side effect genuinely can’t be made idempotent (firing a one-off email, say), dedupe on the event ID — but do it atomically. A naive findUnique then create has a race: two overlapping retries can both pass the check before either inserts. Instead, reserve the id behind a UNIQUE constraint and run the reservation and the work in one transaction— a concurrent delivery then fails the insert, and if the work throws, the marker rolls back so Stripe’s retry reprocesses cleanly.
Return the 2xx quickly. If a handler does slow work (sending email, calling another API), do the minimum synchronously and offload the rest to a queue — otherwise Stripe times out, marks the delivery failed, and retries, which is how you end up processing the same subscription change five times.
Log every event (billing reconciliation + audit)
Write each verified event to an append-only log — the event ID, type, and a timestamp. When a customer says “I was charged but my plan didn’t upgrade,” that log is the difference between a two-minute reconciliation and an afternoon in the Stripe dashboard. It is also the audit trail a SOC 2 or procurement review will ask for later. See the compliance scaffold most kits skip for the wider audit-log and DSAR picture.
A quick checklist
- Read the body with
req.text(), neverreq.json(). - Pass the raw body +
stripe-signature+ the right secret toconstructEvent. - Return
400on verification failure,200on success. - Exclude the webhook route from body-touching middleware.
- Dedupe on
event.id; make handlers safe to re-run. - Acknowledge fast; offload slow work.
Skip the wiring. The Next.js SaaS Starter ships verified webhooks, idempotent subscription sync, and an audit log seeded in the first migration — the exact flow above, gate-audited before release.
Last updated: 4 June 2026. Verify behaviour against the current Stripe API docs before shipping; webhook event shapes and retry windows can change between API versions.