Webhook providers guarantee at-least-once delivery, not exactly-once. This means:
- The same event may be delivered multiple times
- Network issues can cause retries even after successful processing
- Your handler must safely handle duplicate events
Without idempotency, you might:
- Charge a customer twice
- Send duplicate emails
- Create duplicate records
- Corrupt data with duplicate operations
Every webhook provider includes a unique event ID. Use this to detect duplicates.
const eventId = event.id; // evt_1234567890const eventId = req.headers['x-shopify-webhook-id'];const eventId = req.headers['x-github-delivery']; // GUIDThe most reliable approach uses your database to track processed events.
CREATE TABLE processed_webhook_events (
event_id VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(100),
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
payload JSONB
);async function handleWebhook(event) {
const eventId = event.id;
// Check if already processed
const existing = await db.query(
'SELECT 1 FROM processed_webhook_events WHERE event_id = $1',
[eventId]
);
if (existing.rows.length > 0) {
console.log(`Event ${eventId} already processed, skipping`);
return { status: 'duplicate' };
}
// Process the event
await processEvent(event);
// Mark as processed
await db.query(
'INSERT INTO processed_webhook_events (event_id, event_type, payload) VALUES ($1, $2, $3)',
[eventId, event.type, event]
);
return { status: 'processed' };
}For critical operations, use database transactions:
async function handlePaymentWebhook(event) {
const eventId = event.id;
await db.transaction(async (trx) => {
// Lock to prevent race conditions
const existing = await trx.query(
'SELECT 1 FROM processed_webhook_events WHERE event_id = $1 FOR UPDATE SKIP LOCKED',
[eventId]
);
if (existing.rows.length > 0) {
return; // Already processed
}
// Process payment
await trx.query(
'UPDATE orders SET status = $1 WHERE payment_intent_id = $2',
['paid', event.data.object.id]
);
// Record that we processed this event
await trx.query(
'INSERT INTO processed_webhook_events (event_id, event_type) VALUES ($1, $2)',
[eventId, event.type]
);
});
}For operations with external side effects (emails, API calls), use idempotency keys:
async function sendOrderConfirmation(orderId, eventId) {
// Use event ID as idempotency key
const idempotencyKey = `order-confirmation-${orderId}-${eventId}`;
const alreadySent = await redis.get(idempotencyKey);
if (alreadySent) {
console.log('Confirmation already sent');
return;
}
await emailService.send({
to: order.email,
template: 'order-confirmation',
data: order
});
// Mark as sent with TTL (e.g., 7 days)
await redis.set(idempotencyKey, '1', 'EX', 7 * 24 * 60 * 60);
}When multiple instances of your application receive the same webhook simultaneously:
-- Primary key prevents duplicate inserts
INSERT INTO processed_webhook_events (event_id, event_type)
VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;const result = await db.query(
'INSERT INTO processed_webhook_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id',
[eventId]
);
if (result.rows.length === 0) {
// Another instance already processed this
return;
}
// We won the race, process the event
await processEvent(event);For complex processing that can't use transactions:
const lock = await redis.set(
`webhook-lock:${eventId}`,
'1',
'NX', // Only set if not exists
'EX', 300 // Expire after 5 minutes
);
if (!lock) {
console.log('Another instance is processing this event');
return;
}
try {
await processEvent(event);
} finally {
await redis.del(`webhook-lock:${eventId}`);
}Don't keep processed events forever:
-- Clean up events older than 30 days
DELETE FROM processed_webhook_events
WHERE processed_at < NOW() - INTERVAL '30 days';Or use TTL in Redis:
await redis.set(`processed:${eventId}`, '1', 'EX', 30 * 24 * 60 * 60);Always test that your handlers are idempotent:
test('handles duplicate events idempotently', async () => {
const event = createTestEvent('payment_intent.succeeded');
// Process the event twice
await handleWebhook(event);
await handleWebhook(event);
// Verify side effects only happened once
const orders = await db.query('SELECT * FROM orders WHERE payment_id = $1', [event.data.object.id]);
expect(orders.rows.length).toBe(1);
const emails = emailMock.getSentEmails();
expect(emails.length).toBe(1);
});