Stripe webhooks that survive production: the 6 failures (and how to fix each)
Your Stripe webhook works on localhost, fires fine in test mode… then goes silent in production. I’ve debugged this exact thing across a dozen production apps. It’s almost always one of six reasons — plus a seventh that doesn’t break the webhook at all, it just quietly charges your customer twice.
Every fix below is in a working Next.js reference you can clone and run — each one mapped to a few lines of commented code: github.com/rostyslab21/stripe-webhooks-done-right.
verify the raw body, not the parsed one
This is the number-one cause of “every event fails signature verification.” A JSON body-parser — Express’s express.json(), or reaching for await req.json() in a Next.js route — reads the payload and re-serializes it. The bytes change. Stripe signed the original bytes, so constructEvent throws on every single event.
export async function POST(req: Request) {
const raw = await req.text(); // raw bytes, never req.json()
const signature = req.headers.get('stripe-signature');
const event = stripe.webhooks.constructEvent(raw, signature, webhookSecret);
// ...
}read the body as raw text and hand those exact bytes to constructEvent. Never parse before you verify. In Express, mount express.raw({ type: ‘application/json’ }) on that one route only.
acknowledge in under 20 seconds
Stripe waits about 20 seconds for your 200. If you run database writes, send emails, and generate a PDF before returning, a slow task blows the timeout. Stripe retries; after enough failures it disables the endpoint entirely — and now nothing works.
return 200 first, then do the slow work. Acknowledge the delivery immediately and hand the event to a background job or durable queue that a separate worker drains. The 200 is the only thing Stripe is waiting for.
subscribe to the event your flow actually fires
A very common silent failure: you wait for payment_intent.succeeded, but a Stripe Checkout flow fires checkout.session.completed first — and that may be the only event you ever get. You’re listening for something that never arrives, so fulfillment never runs, and nothing errors.
switch (event.type) {
case 'checkout.session.completed':
case 'payment_intent.succeeded':
case 'invoice.paid':
await enqueue(event); // hand off, don't fulfill inline
break;
default:
// acknowledge unhandled types so Stripe stops retrying
}open the Stripe Dashboard, look at the events your specific flow actually emits, and subscribe to those — not the ones you assume fire.
keep test-mode and live-mode secrets apart
Every mode has its own secret key and its own webhook signing secret (whsec_…). Verify a live event with a test signing secret and every check fails — silently, because a failed signature looks identical to a misconfigured one.
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-08-27.basil',
});
export const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;read both secrets from environment variables, per environment. Never hardcode either. Use the test pair locally and in staging; swap to the live pair only in production.
make it idempotent on the event id
Stripe delivers each event at least once. The same event.id can arrive twice — from a retry, a network hiccup, or a redeploy mid-delivery. Process it twice and you double-fulfill an order or send two emails.
if (await alreadyProcessed(event.id)) {
return NextResponse.json({ received: true, duplicate: true });
}
// ... handle it ...
await markProcessed(event.id);record every event.id you’ve fully processed and skip the ones you’ve seen. In production this is a processed_events table with a UNIQUE constraint on the id, or a Redis SETNX — not an in-memory set.
guard fulfillment on the order, not just the event
This is the one that actually double-charges people, and event-id dedup alone won’t catch it. A single purchase emits several distinct events — checkout.session.completed, then payment_intent.succeeded, then invoice.paid — and a failed-then-retried payment creates a brand-new PaymentIntent. Different event.ids, same order. Dedup only on the event id and you’ll still fulfill that order more than once.
const orderKey = fulfillmentKey(event); // your order id, from metadata
if (orderKey && (await alreadyFulfilled(orderKey))) {
return NextResponse.json({ received: true, alreadyFulfilled: true });
}
await enqueue(event);
if (orderKey) await markFulfilled(orderKey);put your own order id on the Stripe object (client_reference_id on Checkout, or metadata.order_id) so the key is stable across every event type for one purchase — then fulfill each key exactly once. In the worker, make that an atomic insert guarded by a UNIQUE constraint; never check-then-act under concurrency.
don’t let serverless freeze mid-work
On Vercel or Lambda, once you send the response the function can freeze mid-await. A floating promise you didn’t await never finishes — Stripe sees a 200, the webhook “succeeded,” and your handler simply never ran.
finish all async work before you return, or — better — offload it to a durable queue that a separate worker drains. Never fire-and-forget a promise after responding.
the pattern
Notice what none of these fixes are: none of them is “add more try/catch.” Every guarantee is structural — verify raw bytes, acknowledge fast, dedup on ids you control, do the work somewhere that can’t be frozen. You treat the webhook as an untrusted, at-least-once, out-of-order stream, because that’s exactly what it is.
The full reference — all seven fixes, tests that go red without them, and a copy-paste AI prompt that audits your own integration against this list — is on GitHub, MIT-licensed.
Payments breaking the moment real traffic hits?
I build payment and integration plumbing that survives production — not just the happy path. Need a hand with the build or the deploy? The first hour is on me. Email me and tell me what's breaking.