Stripe webhooks are the part of a payments integration that looks easy in a tutorial and becomes a 3 AM Slack message in production. The official docs cover the happy path well. They don't cover the six ways the happy path breaks once you're processing real money.
This is a field guide from shipping Stripe on E7 Platform and a few other products. Each failure mode comes with how to spot it and how I fix it.
1. Signature mismatch — the raw body trap
What breaks: Your webhook endpoint returns 400 Invalid signature for every real Stripe event. You haven't changed anything. Test events from the Stripe CLI work fine.
Why: Next.js (App Router especially) and most Node frameworks parse the request body as JSON by default. Stripe's signature verification needs the raw, unmodified byte string of the body. If your framework has already turned it into a parsed object, the bytes don't match anymore, and the HMAC signature fails.
Fix (Next.js App Router):
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
const body = await request.text() // critical — .text(), not .json()
const signature = (await headers()).get('stripe-signature')
if (!signature) return new Response('No signature', { status: 400 })
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return new Response(`Invalid signature: ${(err as Error).message}`, {
status: 400,
})
}
await handleEvent(event)
return new Response(null, { status: 200 })
}
The line that matters is await request.text(). Parse the JSON after signature verification succeeds, not before.
Detection: Log both Invalid signature errors and successful verifications with a timestamp. A production endpoint should see a ~100% success rate for real events. Anything less and you have this bug (or a rotating secret — see #5).
2. Idempotency — retries causing double-processing
What breaks: A customer gets charged once but receives two confirmation emails, or their subscription gets activated twice, or a team seat gets created twice.
Why: Stripe retries webhooks aggressively. If your endpoint is slow, or times out, or returns a 5xx, Stripe will hit it again. Up to 3 days of exponential backoff. The same event.id will arrive multiple times. If your handler isn't idempotent, each retry re-runs your side effects.
Fix: Persist every processed event ID and check it first. A simple Postgres table, primary key on stripe_event_id:
CREATE TABLE processed_stripe_events (
stripe_event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
async function handleEvent(event: Stripe.Event) {
// Idempotency guard — INSERT will fail if we've seen this ID
try {
await db.processedStripeEvents.create({
data: { stripeEventId: event.id, eventType: event.type },
})
} catch (err) {
if (isUniqueViolation(err)) {
console.log(`Duplicate event ${event.id}, skipping`)
return
}
throw err
}
// Real handler runs exactly once per event
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event)
break
// ...
}
}
The insert-first pattern is important. If you insert after the handler runs, a crash between the handler and the insert leaves you in the same failure mode.
3. Event ordering — the out-of-order subscription
What breaks: You receive customer.subscription.updated before customer.subscription.created. Or invoice.paid before the subscription exists in your database. Your handler errors because it can't find the parent record.
Why: Stripe doesn't guarantee event ordering. Two events fired at nearly the same time can arrive in either order, especially if one retries after a transient failure.
Fix: Don't rely on events to carry parent-child semantics. Instead, fetch the full current state from Stripe when a dependent event arrives, and reconcile.
async function handleSubscriptionUpdated(event: Stripe.Event) {
const sub = event.data.object as Stripe.Subscription
// Don't trust the event; fetch current state
const current = await stripe.subscriptions.retrieve(sub.id, {
expand: ['customer'],
})
// Upsert — creates if missing (handles out-of-order .created)
await db.subscriptions.upsert({
where: { stripeId: current.id },
create: {
/* ... */
},
update: {
/* ... */
},
})
}
Two patterns that help:
- Upsert, don't insert-or-update conditionally. Fewer race conditions.
- Refetch from Stripe on dependent events. Stripe's API is the source of truth; your webhook is a change notification, not the data.
4. The 10-second timeout
What breaks: A webhook handler does something slow (sends an email, calls an external API, runs a ML inference) and takes 12 seconds to finish. Stripe times out at 10 seconds, marks the webhook as failed, retries. Now you're running two handlers in parallel for the same event, which triggers failure mode #2 if you missed the idempotency guard.
Why: Stripe's published timeout is 10 seconds. In practice their load balancer sometimes gives up earlier. Any webhook that does meaningful work past 3–5 seconds is skating on thin ice.
Fix: The handler does two things — verify and enqueue. Nothing else.
async function handleEvent(event: Stripe.Event) {
// Idempotency guard (fast: single DB insert)
await insertProcessedEvent(event.id)
// Enqueue the real work (fast: a queue insert)
await queue.enqueue({
type: `stripe.${event.type}`,
eventId: event.id,
payload: event.data.object,
})
// Return 200 immediately
}
A background worker picks up the queued job and does the slow work — emails, syncing to internal APIs, Slack notifications. The webhook endpoint consistently returns in ~100ms. Stripe stays happy.
Use whatever queue you have: Redis + BullMQ, Postgres + pg-boss, SQS, Inngest. The specific choice matters less than having one.
5. Environment mismatch — test events hitting prod
What breaks: Your logs show Invalid signature errors at weird hours. Investigating, the events are real Stripe events — but they're signed with your test webhook secret, not production.
Why: Usually one of:
- A developer left the CLI running with
stripe listen --forward-topointed at the production URL - A staging environment with a shared secret was misconfigured
- You rotated the webhook secret in Stripe but forgot to update the prod env var
Fix: Two habits save you here.
Habit 1: Use different endpoint paths for test and prod.
https://api.yourapp.com/webhooks/stripe/live <- live events only
https://api.yourapp.com/webhooks/stripe/test <- test events only
Each path verifies against a different webhook secret. A test event hitting /live fails cleanly and noisily, and you can alert on it.
Habit 2: Log the event's livemode flag and alert on mismatches.
if (event.livemode !== (process.env.NODE_ENV === 'production')) {
await alert(
`Stripe livemode mismatch: event=${event.livemode}, env=${process.env.NODE_ENV}`
)
return new Response(null, { status: 400 })
}
One line. Saves a long incident.
6. The silent 200 — returning success without actually handling the event
What breaks: Stripe's dashboard shows all events delivered successfully. Your database doesn't reflect them. Customers complain their paid subscription isn't active. Logs don't show errors, because your handler caught them and swallowed.
Why: Somewhere deep in your handler, an await someAsync() throws. The catch block you thought would log the error actually returned success because of how it was written. Or a try/catch wraps only part of the handler and the rest silently fails. Or you return 200 too early, before the handler has actually done its work.
Fix: Three rules.
Rule 1: Never return 200 before the handler completes. If you're using the enqueue pattern from #4, the 200 is fine because the real work is handed off to a reliable queue. Otherwise, await the handler fully before returning.
Rule 2: Re-throw errors, don't swallow them. Let the webhook return 500, let Stripe retry. A retry is cheap; a silently broken subscription is expensive.
try {
await handleEvent(event)
} catch (err) {
console.error(`Webhook handler failed for ${event.id}:`, err)
// Do NOT return 200 here — let Stripe retry
return new Response(`Handler failed: ${(err as Error).message}`, {
status: 500,
})
}
Rule 3: Alert on any webhook handler exception in production. Sentry, Rollbar, PostHog — anything that emails you in 5 minutes. A webhook that's failing silently is the worst possible production failure mode because you don't learn about it until a customer does.
The meta-lesson
Every one of these bugs ships to prod the first time and gets caught after the first real incident. The second time, you've built the scaffolding — signature logging, idempotency table, background queue, livemode guard, Sentry on exceptions — and this whole category of bugs disappears.
If you're about to ship Stripe to production for the first time, build that scaffolding before you turn on live mode. It's a day of work that pays back ten times.
I've shipped Stripe integrations in six production products. If yours is stuck in one of these failure modes — or you want a second pair of eyes before going live — I do webhook audits (€500, 2-day turnaround, written report). Contact me.