Your webhook handler works perfectly. Then one day, a customer gets charged twice. Or an order is created twice. Or a notification is sent five times.
What happened? The webhook was delivered more than once, and your handler processed it every time.
This is why idempotency matters.
Why Webhooks Get Delivered Multiple Times
Webhook providers like Stripe, Shopify, and GitHub will retry webhooks when:
- Your server was slow — Took too long to respond, provider assumed failure
- Network issues — Response got lost, provider retried
- 5xx errors — Your server returned an error, provider retried
- Timeout — Connection timed out before response
This isn't a bug — it's a feature. Providers retry to ensure you don't miss important events. But it means your handler must be ready for duplicates.
Without idempotency, duplicate webhooks can cause double charges, duplicate database entries, multiple emails, or corrupted data. These are hard bugs to debug after the fact.
What Is Idempotency?
An operation is idempotent if running it multiple times produces the same result as running it once. For webhooks, this means:
- Processing the same event twice should have the same effect as processing it once
- Your database should look the same whether the webhook arrived 1 time or 5 times
- Side effects (emails, charges) should only happen once
Implementing Idempotency
There are several strategies for making your webhook handlers idempotent. The right approach depends on your use case.
Strategy 1: Track Event IDs
Most webhook providers include a unique event ID. Store this ID and check it before processing.
async function handleWebhook(event) {
// Check if we've already processed this event
const existing = await db.processedEvents.findOne({
eventId: event.id
});
if (existing) {
console.log(`Event ${event.id} already processed, skipping`);
return { status: 'already_processed' };
}
// Process the event
await processPayment(event);
// Mark as processed
await db.processedEvents.insert({
eventId: event.id,
processedAt: new Date()
});
return { status: 'processed' };
}
Add a unique constraint on the event ID column. This prevents race conditions where two requests try to insert the same ID simultaneously.
Strategy 2: Use Database Transactions
For complex operations, wrap everything in a transaction with the event ID check.
async function handleOrderWebhook(event) {
await db.transaction(async (tx) => {
// Try to insert the event ID (will fail if duplicate)
try {
await tx.processedEvents.insert({ eventId: event.id });
} catch (error) {
if (error.code === 'UNIQUE_VIOLATION') {
return; // Already processed
}
throw error;
}
// Process within the same transaction
await tx.orders.insert(event.data.order);
await tx.inventory.decrement(event.data.items);
});
}
Strategy 3: Upsert Instead of Insert
If you're syncing data (like customer info), use upsert operations that update existing records or create new ones.
async function handleCustomerUpdated(event) {
const customer = event.data.customer;
// Upsert: create if new, update if exists
await db.customers.upsert({
where: { stripeId: customer.id },
update: {
email: customer.email,
name: customer.name,
updatedAt: new Date()
},
create: {
stripeId: customer.id,
email: customer.email,
name: customer.name
}
});
}
This approach is naturally idempotent — running it twice just updates the record to the same values.
Strategy 4: Check State Before Acting
For some operations, check if the action has already been taken.
async function handleSubscriptionCreated(event) {
const subscription = event.data.subscription;
// Check if user already has an active subscription
const user = await db.users.findOne({
stripeCustomerId: subscription.customer
});
if (user.subscriptionStatus === 'active') {
console.log('User already has active subscription');
return;
}
// Activate the subscription
await db.users.update({
where: { id: user.id },
data: {
subscriptionStatus: 'active',
subscriptionId: subscription.id
}
});
}
Testing Idempotency
How do you verify your handler is idempotent? Replay the same webhook multiple times and check that the result is correct.
This is where capture-and-replay tools shine. With HookReplay:
- Capture a real webhook
- Replay it to your localhost
- Check your database
- Replay the same webhook again
- Verify the database looks the same (no duplicates)
$ npx hookreplay ● hookreplay> connect ✓ Connected! # First replay ↳ Replaying payment_intent.succeeded to localhost:3000 ✓ 200 OK # Replay again - should still succeed, no duplicate created ↳ Replaying payment_intent.succeeded to localhost:3000 ✓ 200 OK (already processed)
Common Pitfalls
Pitfall 1: Only checking in memory
Don't rely on in-memory caches for deduplication. If your server restarts, the cache is gone. Always persist processed event IDs to a database.
Pitfall 2: Responding before processing
If you return 200 OK before finishing processing, and then crash, the provider won't retry — but you never completed the work. Make sure critical processing happens before responding.
Pitfall 3: Ignoring race conditions
If two identical webhooks arrive simultaneously, both might pass the "not yet processed" check. Use database constraints or locks to prevent this.
Cleanup Old Event IDs
Your processed_events table will grow over time. Set up a job to clean up old entries (e.g., older than 30 days). Most providers won't retry after a few days, so keeping ancient event IDs isn't necessary.
-- Clean up events older than 30 days DELETE FROM processed_events WHERE processed_at < NOW() - INTERVAL '30 days';
Summary
Webhook idempotency isn't optional — it's essential. Without it, you'll eventually face duplicate charges, duplicate data, or corrupted state.
Key takeaways:
- Webhooks can and will be delivered multiple times
- Track event IDs to detect duplicates
- Use database constraints to handle race conditions
- Test by replaying the same webhook multiple times
- Clean up old event IDs periodically
Build your handlers assuming every webhook might arrive twice. Your future self (and your customers) will thank you.