Stripe Webhook Duplicate Events: Why It Happens and How to Handle It
Stripe retries webhooks on any 5xx or timeout. When your handler is slow under load, the retry arrives before the first run finishes. Application-level idempotency checks are not enough. Here is the full pattern — including the race condition most implementations miss.
Monday morning. A customer emails: "I was charged twice." You check Stripe — one payment, payment_intent.succeeded, $49.00, processed Saturday at 11:42 PM. You check your orders table — two rows, both linked to the same Stripe payment intent, created 34 seconds apart.
You check your webhook handler. It has idempotency logic. You check the idempotency code. It looks correct. You check your logs. Both webhook deliveries returned 200.
This is one of the most frustrating bugs in webhook-heavy systems because the code appears to be doing the right thing. The idempotency check is there. The unique constraint is there. But duplicates still happen. The reason is almost always a race condition that the idempotency check does not protect against.
Why Stripe Sends Duplicate Events
Stripe's webhook delivery is not at-most-once. It is at-least-once — the guarantee is eventual delivery, not exactly-once delivery. The Stripe webhook documentation covers this model, and Stripe webhook best practices address idempotency explicitly.
The retry schedule for a failed delivery is:
- Immediate: 30 seconds after failure
- Retry 1: 1 minute
- Retry 2: 5 minutes
- Retry 3: 10 minutes
- Retry 4: 30 minutes
- Retry 5: 2 hours
- Retry 6: 5 hours
- Retry 7: 10 hours
- Retry 8: 24 hours (final)
A delivery is considered "failed" if:
- Your server returns a 5xx status code
- Your server returns no response within 30 seconds (timeout)
- The connection is refused or reset
Notice that a server-side timeout triggers retries. If your handler takes 35 seconds to respond — which is plausible under database load — Stripe sees a timeout and queues a retry, even if your handler eventually completes and the data was written.
The result: Stripe considers the event undelivered. Your handler considers it processed. Stripe retries. Your handler runs again with the same event.
Application-Level Idempotency: The Naive Approach
The first instinct is to check whether you have already processed the event before doing work:
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
// Naive idempotency check
const existing = await db.query(
'SELECT id FROM processed_webhook_events WHERE stripe_event_id = $1',
[event.id]
);
if (existing.rows.length > 0) {
// Already processed — return 200 and skip
return res.status(200).send('ok');
}
// Process the event
if (event.type === 'payment_intent.succeeded') {
await db.orders.createFromPaymentIntent(event.data.object);
}
// Mark as processed
await db.query(
'INSERT INTO processed_webhook_events (stripe_event_id, processed_at) VALUES ($1, NOW())',
[event.id]
);
res.status(200).send('ok');
});
This looks correct. In serial execution, it is correct. But webhook handlers do not run serially under load.
The Race Condition
The race condition is the part that most idempotency implementations miss entirely — two concurrent requests can both pass the check before either writes the record. Consider what happens when two requests arrive at the same time — and they will, because Stripe sometimes sends duplicate deliveries within milliseconds of each other when a previous delivery timed out. For more context on how these duplicates appear in the wild, see how Stripe webhooks create duplicate orders.
T=0ms Request A arrives — SELECT processed_webhook_events WHERE id = 'evt_abc' → 0 rows
T=1ms Request B arrives — SELECT processed_webhook_events WHERE id = 'evt_abc' → 0 rows
T=5ms Request A passes idempotency check, starts processing
T=6ms Request B passes idempotency check, starts processing (A hasn't committed yet)
T=45ms Request A inserts order, inserts processed record, commits
T=46ms Request B inserts DUPLICATE order, inserts processed record → CONFLICT (if you're lucky)
T=47ms Request B: if no unique constraint on orders, second order commits silently
Both requests passed the application-level idempotency check because they both read an empty table before either wrote to it. This is a classic read-check-write race condition, and it happens every time a provider sends two simultaneous retries — which Stripe does.
The naive approach is broken because the idempotency check and the processing are not atomic. There is a window between the check and the write where another request can slip through.
DB-Level Idempotency: The Correct Approach
The fix is to move idempotency enforcement into the database, where it can be made atomic.
Step 1: Unique constraint on the processed events table.
CREATE TABLE processed_webhook_events (
id SERIAL PRIMARY KEY,
stripe_event_id VARCHAR(255) NOT NULL UNIQUE,
event_type VARCHAR(100) NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processing_result JSONB
);
CREATE UNIQUE INDEX idx_processed_webhook_events_stripe_event_id
ON processed_webhook_events (stripe_event_id);
Step 2: Use ON CONFLICT to make the insert atomic.
async function processStripeEvent(event) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Attempt to claim this event atomically
// If another request already claimed it, this returns 0 rows
const claim = await client.query(`
INSERT INTO processed_webhook_events (stripe_event_id, event_type, status)
VALUES ($1, $2, 'processing')
ON CONFLICT (stripe_event_id) DO NOTHING
RETURNING id
`, [event.id, event.type]);
if (claim.rows.length === 0) {
// Another request already claimed this event — skip
await client.query('ROLLBACK');
return { status: 'duplicate_skipped' };
}
// We claimed the event — now do the actual work inside the same transaction
if (event.type === 'payment_intent.succeeded') {
await client.query(
'INSERT INTO orders (stripe_payment_intent_id, customer_id, amount) VALUES ($1, $2, $3)',
[event.data.object.id, event.data.object.metadata.customer_id, event.data.object.amount]
);
}
// Mark as completed
await client.query(
'UPDATE processed_webhook_events SET status = $1, processed_at = NOW() WHERE stripe_event_id = $2',
['completed', event.id]
);
await client.query('COMMIT');
return { status: 'processed' };
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
The key difference from the naive approach: the INSERT ... ON CONFLICT DO NOTHING ... RETURNING id inside a transaction is atomic at the database level. When two requests arrive simultaneously, the database serializes them. One wins the INSERT and gets a row back. The other gets 0 rows back because the UNIQUE constraint fires. There is no window where both can proceed.
The entire processing logic happens inside the same transaction as the idempotency claim. If the order insert fails, the idempotency record rolls back too, which means a future retry can try again. This is important: the idempotency record does not persist when the processing fails.
Full Idempotent Handler with Transaction and Receipt
Putting it all together: signature verification, idempotency via database transaction, and an outcome receipt after commit:
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
// Step 1: Verify signature before any processing
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
logger.warn({ err: err.message, msg: 'Stripe signature verification failed' });
return res.status(400).send('Signature verification failed');
}
// Step 2: Acknowledge immediately — after verification, before processing
res.status(200).send('ok');
// Step 3: Process asynchronously with full idempotency
processEventWithIdempotency(event).catch((err) => {
logger.error({ err, stripeEventId: event.id, eventType: event.type, msg: 'Webhook processing failed' });
// Alert on-call, push to dead letter queue, etc.
});
});
async function processEventWithIdempotency(event) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Atomic idempotency claim
const claim = await client.query(`
INSERT INTO processed_webhook_events (stripe_event_id, event_type, status)
VALUES ($1, $2, 'processing')
ON CONFLICT (stripe_event_id) DO NOTHING
RETURNING id
`, [event.id, event.type]);
if (claim.rows.length === 0) {
await client.query('ROLLBACK');
logger.info({ stripeEventId: event.id, msg: 'Duplicate event skipped' });
return;
}
let result;
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object;
const customer = await client.query(
'SELECT id, email FROM customers WHERE stripe_customer_id = $1',
[paymentIntent.customer]
);
if (customer.rows.length === 0) {
throw new Error(`Customer not found for stripe_customer_id: ${paymentIntent.customer}`);
}
const order = await client.query(
`INSERT INTO orders (customer_id, amount, currency, stripe_payment_intent_id, status)
VALUES ($1, $2, $3, $4, 'confirmed')
RETURNING id`,
[customer.rows[0].id, paymentIntent.amount, paymentIntent.currency, paymentIntent.id]
);
result = { orderId: order.rows[0].id, customerId: customer.rows[0].id };
}
// Mark completed in the same transaction
await client.query(
`UPDATE processed_webhook_events SET status = 'completed', processed_at = NOW(), processing_result = $1
WHERE stripe_event_id = $2`,
[JSON.stringify(result), event.id]
);
await client.query('COMMIT');
// Send outcome receipt AFTER commit — proves the data is durable
await sendOutcomeReceipt({
stripeEventId: event.id,
status: 'applied_confirmed',
result,
});
} catch (err) {
await client.query('ROLLBACK');
// Idempotency record rolled back — future retry can attempt again
throw err;
} finally {
client.release();
}
}
The Subtle Failure Mode: Processed but Silently Failed
There is a scenario that even good idempotency handling does not fully address: what if the idempotency record says "completed" but the underlying processing actually failed?
This happens when an error is caught inside the processing logic and the handler proceeds to mark the event as completed anyway:
// This is broken — it marks the event as completed even when processing failed
const claim = await client.query('INSERT INTO processed_webhook_events ...');
try {
await createOrder(paymentIntent);
} catch (err) {
logger.error('Order creation failed', err);
// Falls through — no throw, no rollback
}
await client.query('UPDATE processed_webhook_events SET status = "completed" ...');
await client.query('COMMIT');
// Event is now permanently marked as "completed" but the order was never created
This is the worst outcome: the event cannot be retried (idempotency record says done), but the work was never done.
The fix is strict: any error inside the processing transaction must propagate to the outer try/catch, which rolls back the entire transaction including the idempotency record. The event stays retryable until the work actually succeeds.
The outcome receipt makes this visible in a different way: if the idempotency record says "completed" but no receipt was ever sent, there is a discrepancy. Either the receipt call failed (network error after commit), or the processing succeeded but something in the receipt-sending code threw. HookTunnel shows events in Delivered state without an Applied state, which flags them for investigation regardless of what your internal processed_webhook_events table says.
This is why the receipt is sent from outside the transaction, after commit: it is the external confirmation that the internal state change actually happened. If you can see the receipt, the commit happened. If you cannot see the receipt, you cannot assume the commit happened.
Comparison: Approaches to Idempotency
| Approach | Prevents duplicates under concurrent load | Atomic | Retryable on failure | |----------|------------------------------------------|--------|----------------------| | Application-level check (SELECT before INSERT) | No | No | Yes | | Unique constraint only (no transaction wrap) | Yes | Yes | Depends on error handling | | INSERT ON CONFLICT inside transaction | Yes | Yes | Yes (if transaction rolls back on error) | | INSERT ON CONFLICT + same-transaction processing | Yes | Yes | Yes |
The only approach that is both safe under concurrent load and correctly handles failure is the last one: claim the event and do the work inside the same database transaction, so failures roll back both.
What to Store in the Idempotency Record
The idempotency record is your forensics artifact — it must store enough to diagnose problems after the fact, including intermediate state, attempt counts, and results. See HookTunnel's webhook debugging checklist and webhook inspection features for the tooling layer that complements the database layer. If you want to test your webhook handler locally before deploying, see how to test Stripe webhooks locally.
The processed_webhook_events table should store enough to diagnose problems after the fact:
CREATE TABLE processed_webhook_events (
id SERIAL PRIMARY KEY,
stripe_event_id VARCHAR(255) NOT NULL UNIQUE,
event_type VARCHAR(100) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'processing', -- processing, completed, failed
processing_result JSONB, -- IDs of created records, for debugging
error_message TEXT, -- if failed, what went wrong
attempt_count INTEGER NOT NULL DEFAULT 1,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ, -- when completed
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
The status = 'processing' intermediate state is useful: if your process crashes mid-transaction, the row stays in "processing" state with the transaction rolled back (because the COMMIT never ran). You can query for stale "processing" records as part of your alerting.
The attempt_count column is useful when combined with Stripe's retry history — you can see whether your internal attempt count matches Stripe's delivery count. If Stripe shows 3 deliveries but your table shows 1 completed, two of those deliveries were duplicates that were correctly de-duplicated.
Stop guessing. Start proving.
Generate a webhook URL in one click. No signup required.
Get started free →