Handle Stripe Webhooks Reliably
11 Aug 2026Stripe will retry a webhook you don’t acknowledge, send events out of order, and occasionally deliver the same event twice. If your handler isn’t built for all three, you will eventually double-charge someone or miss a cancellation.
Here’s the shape of a handler that survives all three:
sequenceDiagram
participant S as Stripe
participant H as Webhook handler
participant Q as Queue
participant W as Worker
S->>H: POST event + signature
H->>H: Verify signature
H->>Q: Enqueue event
H-->>S: 200 OK (fast ack)
Q->>W: Deliver event
W->>W: Check processedEvents (seen before?)
W->>W: Handle event, record ID
1. Verify the signature first, always
Every handler starts the same way, no exceptions:
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
let event
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
)
} catch (err) {
console.error('Webhook signature verification failed:', err.message)
return res.status(400).send(`Webhook Error: ${err.message}`)
}
// handle event
})
Note express.raw, not express.json. Stripe signs the raw request body — if a body-parsing middleware runs first and re-serializes the JSON, the signature check fails on every request even though the event is genuine. This is the single most common “webhooks don’t work” bug reported against Stripe integrations, and it’s always this.
2. Respond 200 fast, process slow
Stripe expects a response within a few seconds or it counts the delivery as failed and retries. Don’t do the real work inline:
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const event = verifyEvent(req) // as above
await queue.add('stripe-event', event) // enqueue, don't process
res.json({ received: true }) // ack immediately
})
If your handler calls out to your database, sends an email, and updates three tables before responding, a slow dependency turns into a wave of duplicate retries — Stripe assumes failure and resends, and now you’re processing the same event N times concurrently.
3. Make every handler idempotent
Stripe explicitly does not guarantee exactly-once delivery. Store the event ID and check it before acting:
async function handleEvent(event) {
const seen = await db.processedEvents.findUnique({ where: { id: event.id } })
if (seen) return // already handled, skip
await db.processedEvents.create({ data: { id: event.id, type: event.type } })
switch (event.type) {
case 'invoice.payment_succeeded':
await activateSubscription(event.data.object)
break
case 'customer.subscription.deleted':
await deactivateSubscription(event.data.object)
break
}
}
The insert-then-process order matters: if you check for the event, then process, then record it, a crash between processing and recording leaves you unprotected against the next retry.
4. Don’t trust event order
customer.subscription.updated and customer.subscription.deleted for the same customer can arrive out of order under retry or network delay. Don’t assume the last webhook you received reflects current reality — when it matters, fetch the object fresh from the Stripe API inside the handler instead of trusting the payload:
const subscription = await stripe.subscriptions.retrieve(event.data.object.id)
This costs you one API call but guarantees you’re acting on current state, not a stale snapshot from a delayed retry.
5. Handle a replay from the dashboard
Stripe lets you manually resend an event from the dashboard for debugging — good, because it means your idempotency check needs to hold up months later, not just during the retry window. If processedEvents only keeps 24 hours of history to save space, a manual replay six months from now will double-process. Keep it, or at least keep the event IDs, indefinitely.
The failure mode to actually test
Kill your handler mid-processing (literally: kill -9 the process after it logs “processing event X” but before it commits) and confirm the retry that follows doesn’t double-charge or double-activate anything. If that test fails, your idempotency check is checking the wrong thing — usually it’s checking too late, after the side effect already happened.