Handling Stripe Webhooks Resiliently with Idempotency Keys
Webhooks fail. Webhooks retry. Webhooks arrive out of order. If your payment architecture isn't idempotent, you will inevitably double-charge users or double-provision resources.
The Reality of Webhooks
When Stripe processes a payment, it sends an invoice.payment_succeeded webhook to your server.
Your server receives it, provisions the user's premium account, but right before responding 200 OK, your database hangs and the request times out.
Stripe didn't get the 200 OK, so it retries the webhook an hour later. Your server provisions the premium account again. If provisioning involves sending welcome emails or crediting API tokens, the user just got double.
Idempotency is Mandatory
An idempotent operation is one that produces the same result no matter how many times it is executed.
Every Stripe webhook payload includes an id. This is your idempotency key.
The Implementation
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => {
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
// 1. Check if we have processed this event ID
const processed = await db.processed_events.findUnique({
where: { event_id: event.id }
});
if (processed) {
// We already handled this! Just return 200 to satisfy Stripe.
return res.status(200).send();
}
// 2. Process the business logic
await handleStripeEvent(event);
// 3. Record the event as processed
await db.processed_events.create({
data: { event_id: event.id, type: event.type }
});
res.status(200).send();
});
Taking it Further: Database Transactions
The snippet above has a race condition. If two identical webhooks arrive at the exact same millisecond, they might both pass the findUnique check before either has time to write to processed_events.
To fix this, use Postgres UNIQUE constraints on event_id. Try to insert the event first. If it throws a unique constraint violation (Error 23505), you know it's a duplicate, and you can safely ignore it.
Tags
Related Blogs
Distributed Rate Limiting using Redis Sliding Windows
Fixed-window rate limiting has a fatal flaw. Here is how to implement a precise sliding window using Redis Sorted Sets.
Migrating from TanStack Start to Next.js App Router: An Architecture Post-Mortem
A deep dive into why we moved our entire CMS away from Vite SSR and TanStack Router, the performance implications of Server Components, and the hydration traps we had to fix.
Beyond JWTs: Designing a Stateful, High-Performance Session Architecture
Stateless JWTs are great until you need to instantly revoke a compromised session. Here's how to build a stateful, Redis-backed authentication system that handles 50k+ concurrent users with sub-millisecond validation.