Webhooks can arrive more than once. Network timeouts, retries, and at-least-once delivery guarantees mean your handler might receive the same event two or three times. If your handler is not idempotent, you risk processing an order twice, sending duplicate emails, or corrupting your database.
This guide shows you how to design webhook handlers that produce the same outcome no matter how many times they run.
Step 1: Understand Why Idempotency Matters
When AsyncQueue executes a task and calls your targetUrl, the request might be retried if:
- Your server returns a 5xx error
- The connection times out before your response reaches the queue
- A network partition causes the delivery to appear failed
In all these cases, the task queue retries the request. Your handler might see the same payload twice with different timing.
What goes wrong without idempotency:
// DANGEROUS - not idempotentapp.post('/api/process-order', async (req, res) => { const { orderId, amount } = req.body;
// This runs EVERY time the webhook fires await db.orders.insert({ orderId, amount, status: 'paid' }); await chargeCustomer(orderId, amount); // double charge! await sendConfirmationEmail(orderId); // duplicate email!
res.json({ received: true });});Step 2: Use Unique Task IDs as Idempotency Keys
Every task in AsyncQueue carries a unique identifier. Use that ID to track which events you have already handled.
app.post('/api/process-order', async (req, res) => { const taskId = req.headers['x-asyncqueue-task-id'] || req.body.taskId;
// Check if we already processed this task const existing = await db.processedEvents.findOne({ taskId }); if (existing) { // Already handled, return success without doing anything return res.json({ received: true, deduplicated: true }); }
// Process the event await db.orders.update(req.body.orderId, { status: 'paid' }); await chargeCustomer(req.body.orderId, req.body.amount); await sendConfirmationEmail(req.body.orderId);
// Record that we processed this task await db.processedEvents.insert({ taskId, processedAt: new Date() });
res.json({ received: true });});Tip: Clean up old entries from your processed events table on a schedule. Events older than 7 days are unlikely to be retried.
Step 3: Make Database Operations Idempotent
Even with deduplication, individual database operations should be safe to repeat.
Use upserts instead of inserts:
// BAD - fails or duplicates on retryawait db.orders.insert({ orderId, status: 'paid', amount: 99.99 });
// GOOD - safe to repeatawait db.orders.upsert( { orderId }, // match key { orderId, status: 'paid', amount: 99.99 } // values);Use conditional updates:
// BAD - overwrites regardless of current stateawait db.orders.update(orderId, { status: 'shipped' });
// GOOD - only transitions from expected stateconst result = await db.orders.updateWhere( { orderId, status: 'paid' }, // only update if currently 'paid' { status: 'shipped' });
if (result.modifiedCount === 0) { console.log('Order already shipped or in unexpected state');}Use database transactions for multi-step operations:
await db.transaction(async (tx) => { const order = await tx.orders.findForUpdate(orderId);
// Skip if already processed if (order.status !== 'pending') return;
await tx.orders.update(orderId, { status: 'paid' }); await tx.ledger.insert({ orderId, amount, type: 'payment' });});Step 4: Handle Side Effects Safely
Side effects like sending emails, calling external APIs, or charging credit cards cannot be reversed by a database rollback. Protect them with guard checks.
Pattern: guard with a status flag
app.post('/api/fulfill-order', async (req, res) => { const { orderId } = req.body; const order = await db.orders.findOne({ orderId });
if (order.emailSent) { return res.json({ received: true }); }
// Send the email await sendConfirmationEmail(order.customerEmail, orderId);
// Mark as sent AFTER successful send await db.orders.update(orderId, { emailSent: true });
res.json({ received: true });});Pattern: use external idempotency keys
Many payment APIs support idempotency keys. Pass your task ID:
await stripe.charges.create( { amount: 9999, currency: 'usd', customer: customerId }, { idempotencyKey: taskId } // Stripe deduplicates for you);Step 5: Test for Idempotency
The simplest test: call your webhook handler twice with the same payload and verify the side effects fire only once.
// Test: verify idempotencyconst payload = { orderId: 'test_123', amount: 50 };
// First call - should processconst res1 = await fetch('/api/process-order', { method: 'POST', body: JSON.stringify(payload),});assert(res1.status === 200);
// Second call with same payload - should not duplicateconst res2 = await fetch('/api/process-order', { method: 'POST', body: JSON.stringify(payload),});assert(res2.status === 200);
// Verify: only one order, one charge, one emailconst orders = await db.orders.find({ orderId: 'test_123' });assert(orders.length === 1);Checklist for idempotent handlers:
| Check | Question |
|---|---|
| Deduplication | Do you track processed event IDs? |
| Database ops | Are all writes upserts or conditional updates? |
| Side effects | Are emails, charges, and notifications guarded by status flags? |
| Concurrency | Can two simultaneous deliveries cause a race condition? |
| Cleanup | Do you expire old deduplication records? |