logo

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 idempotent
app.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 retry
await db.orders.insert({ orderId, status: 'paid', amount: 99.99 });
// GOOD - safe to repeat
await db.orders.upsert(
{ orderId }, // match key
{ orderId, status: 'paid', amount: 99.99 } // values
);

Use conditional updates:

// BAD - overwrites regardless of current state
await db.orders.update(orderId, { status: 'shipped' });
// GOOD - only transitions from expected state
const 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 idempotency
const payload = { orderId: 'test_123', amount: 50 };
// First call - should process
const res1 = await fetch('/api/process-order', {
method: 'POST',
body: JSON.stringify(payload),
});
assert(res1.status === 200);
// Second call with same payload - should not duplicate
const res2 = await fetch('/api/process-order', {
method: 'POST',
body: JSON.stringify(payload),
});
assert(res2.status === 200);
// Verify: only one order, one charge, one email
const orders = await db.orders.find({ orderId: 'test_123' });
assert(orders.length === 1);

Checklist for idempotent handlers:

CheckQuestion
DeduplicationDo you track processed event IDs?
Database opsAre all writes upserts or conditional updates?
Side effectsAre emails, charges, and notifications guarded by status flags?
ConcurrencyCan two simultaneous deliveries cause a race condition?
CleanupDo you expire old deduplication records?