the gapwhat I doworkblogbook a call rostyslab21@gmail.com

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.

typescriptthe fix (Next.js App Router)
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);
// ...
}
Fix

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.

Fix

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.

typescriptswitch on the real type
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
}
Fix

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.

typescriptread per environment
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-08-27.basil',
});
export const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
Fix

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.

typescriptskip what you've already handled
if (await alreadyProcessed(event.id)) {
return NextResponse.json({ received: true, duplicate: true });
}
// ... handle it ...
await markProcessed(event.id);
Fix

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.

typescriptone order, fulfilled 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);
Fix

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.

Fix

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.

need this shipped right?

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.

stay in the loop

get new posts by email