Idempotent Webhook Handling: A Practical Guide
Your webhook handler will receive the same event more than once. Network hiccups, retry logic, and race conditions make duplicates inevitable. Building an idempotent handler is how you stay sane.
Jason Warner
March 25, 2026
Idempotent Webhook Handling: A Practical Guide
An idempotent operation is one where performing it multiple times has the same effect as performing it once. For webhook handlers, this means: processing the same event three times should produce exactly the same result as processing it once.
This is not optional. It is a requirement for any webhook handler in production.
Why Duplicate Events Are Inevitable
Source retries. Stripe retries for 72 hours. GitHub retries 3 times. Shopify retries 19 times. If your endpoint returns a 5xx or times out, the source retries — even if you already processed it before the timeout.
Network duplicates. Rare but possible — TCP-level retransmission can cause a request to arrive twice.
Infrastructure retries. A webhook relay retrying a failed delivery from its side.
Your own replay. When you replay events to recover from an outage, you're intentionally reprocessing.
Design for duplicates from day one.
The Idempotency Key Pattern
Every webhook event has a unique identifier. Use it. Before processing, check whether you've seen that ID before.
async function handleWebhook(event) {
const eventId = event.id;
const seen = await db.query(
'SELECT 1 FROM processed_webhooks WHERE event_id = $1', [eventId]
);
if (seen.rows.length > 0) return { skipped: true };
await processEvent(event);
await db.query(
'INSERT INTO processed_webhooks (event_id, processed_at) VALUES ($1, NOW())',
[eventId]
);
return { processed: true };
}
Where to Find the Idempotency Key
| Service | Key |
|---|---|
| Stripe | event.id (e.g., evt_1234) |
| GitHub | X-GitHub-Delivery header (UUID) |
| Shopify | X-Shopify-Webhook-Id header |
| Twilio | MessageSid in payload |
Handling the Race Condition
The pattern above has a race condition: two instances can both pass the "already seen" check simultaneously.
Use a unique constraint on event_id:
try {
await db.query(
'INSERT INTO processed_webhooks (event_id) VALUES ($1)', [eventId]
);
} catch (err) {
if (err.code === '23505') return { skipped: true }; // unique violation
throw err;
}
// Safe to process — we hold the unique row
await processEvent(event);
Idempotent Business Logic
Beyond deduplication, design your business logic to be safe to run multiple times.
Use upsert instead of insert:
// Fragile
await db.users.create({ stripeId: event.data.customerId });
// Idempotent
await db.users.upsert(
{ stripeId: event.data.customerId },
{ status: 'active', updatedAt: new Date() }
);
Use absolute state, not increments:
// Fragile — double-processing charges twice
await db.accounts.increment('balance', event.data.amount);
// Idempotent — sets to the correct value
await db.accounts.update(
{ id: event.data.accountId },
{ balance: event.data.newBalance, lastEventId: event.id }
);
Check current state before acting:
const sub = await db.subscriptions.findOne({ userId });
if (sub.status === 'active') return; // already done
await activateSubscription(userId);
Retention Policy
Keep processed event IDs long enough to cover the retry window of any source. Stripe retries for 72 hours, so keep IDs for at least 7 days. Add a cleanup job:
DELETE FROM processed_webhooks
WHERE processed_at < NOW() - INTERVAL '7 days';
Testing Idempotency
test('processing the same event twice is idempotent', async () => {
const event = buildTestEvent({ type: 'payment_intent.succeeded', id: 'evt_test' });
await handleWebhook(event);
await handleWebhook(event); // second call — should be a no-op
const activations = await db.subscriptions.count({
where: { stripeEventId: 'evt_test' }
});
expect(activations).toBe(1);
});
Bluejay Relay logs every event with its unique ID, making duplicate delivery auditable. On the paid plans, replay a specific event ID as many times as needed for testing. Start free.
Build more reliable webhook workflows.
Capture, transform, and retry webhooks with full observability. Free to start, no credit card.