How to Stop Stripe Webhooks From Creating Duplicate Orders
Stripe's retry policy is designed to ensure delivery. Without idempotency in your handler, it also ensures duplicate orders. Here's the fix.
You deployed a change on Thursday at 5pm. Your webhook handler broke for 20 minutes. Stripe retried every event that returned a non-2xx response. When your handler came back up, some of those retries processed on top of events that had already been partially handled.
On Friday morning, you had 12 customers with duplicate orders.
This is the Stripe webhook duplicate problem. It's incredibly common. Here's how to prevent it.
Why duplicates happen
Stripe's retry policy is designed to guarantee delivery, not idempotency — if your handler returns 500, Stripe will retry the same event up to 87 times over 72 hours. The Stripe webhook documentation covers the delivery model, and Stripe webhook best practices address handler design. For an in-depth look at the race condition most implementations miss, see Stripe webhook duplicate events.
Retries are the right behavior — you want missed events to eventually get processed. The problem is what happens to your handler on retry:
// Naive handler — creates a duplicate on retry
app.post('/webhooks/stripe', async (req, res) => {
const event = stripe.webhooks.constructEvent(...)
if (event.type === 'payment_intent.succeeded') {
// This runs on EVERY delivery attempt, including retries
await db.orders.create({
stripe_payment_intent_id: event.data.object.id,
amount: event.data.object.amount,
status: 'paid'
})
await stripe.subscriptions.update(customerId, { tier: 'pro' })
}
res.sendStatus(200)
})
On retry: second order created, subscription updated again (may or may not be a problem depending on your update logic).
The idempotency key pattern
The fix: check whether you've already processed this event before doing anything.
app.post('/webhooks/stripe', async (req, res) => {
const event = stripe.webhooks.constructEvent(...)
if (event.type === 'payment_intent.succeeded') {
const stripeEventId = event.id // This is your idempotency key
// Check if already processed
const existing = await db.webhookEvents.findOne({
stripe_event_id: stripeEventId
})
if (existing) {
// Already processed — return 200 so Stripe stops retrying
return res.sendStatus(200)
}
// Process atomically — record + action in one transaction
await db.transaction(async (trx) => {
// Record that we're processing this event
await trx.webhookEvents.insert({
stripe_event_id: stripeEventId,
processed_at: new Date()
})
// Do the actual work
await trx.orders.create({ ... })
await trx.subscriptions.update({ ... })
})
}
res.sendStatus(200)
})
The critical elements:
- Use
event.idas your idempotency key — Stripe guarantees this is unique per event - Check before acting — not after
- Record + action in the same transaction — prevents the race condition where two requests process simultaneously
The race condition to watch out for
The basic pattern above has a subtle bug under concurrent load: two retry requests arriving simultaneously can both pass the "already processed?" check before either one writes the record.
Fix with a unique constraint:
CREATE TABLE webhook_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
stripe_event_id text UNIQUE NOT NULL, -- This is the guard
processed_at timestamptz NOT NULL DEFAULT now()
);
Now the second insert will fail with a unique constraint violation, which you catch and treat as "already processed":
try {
await db.transaction(async (trx) => {
await trx.webhookEvents.insert({ stripe_event_id: stripeEventId })
await trx.orders.create({ ... })
})
} catch (error) {
if (error.code === '23505') { // unique_violation
return res.sendStatus(200) // Already processed, not an error
}
throw error // Real error — let Stripe retry
}
What about replay?
Idempotency becomes especially critical during recovery scenarios. If your app was down for an hour and you're replaying 40 events, your idempotency guard is the only thing standing between you and 40 duplicate orders. To understand what a silent webhook failure looks like before you even get to replay, read silent webhook failure patterns.
But there's a layer above idempotency that most tools miss: knowing which events actually need replaying — because replaying events with confirmed outcomes is how you create new duplicates even with idempotency guards in place.
Learn how to test Stripe webhooks locally before you hit these issues in production.
If 15 of those 40 events were partially processed before the outage, you don't want to replay them — you want to investigate whether they need manual remediation.
HookTunnel's guardrailed replay addresses this at the tooling layer: it won't replay an event with a confirmed receipt (Applied Confirmed state). But your application-level idempotency guard is still required as a defence in depth.
Summary
- Use
event.idas your idempotency key - Check before processing, not after
- Record the event ID and process atomically in a single transaction
- Add a database unique constraint as your concurrency guard
- Treat unique constraint violations as "already processed" — return 200
Webhooks arrive more than once. Your handler needs to be ready for that.
Want to know which of your webhook events were actually applied — not just delivered? HookTunnel's outcome receipts give you cryptographic proof, per event.
Stop guessing. Start proving.
Generate a webhook URL in one click. No signup required.
Get started free →