Best Practices January 27, 2026

Webhook Idempotency: How to Handle Duplicate Events Safely

Learn how to build idempotent webhook handlers that safely process duplicate events. Prevent double charges, duplicate orders, and data inconsistencies.

8 min read

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.

Real consequences

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.

javascript
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' };
}
Tip: Use unique constraints

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.

javascript
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.

javascript
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.

javascript
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:

  1. Capture a real webhook
  2. Replay it to your localhost
  3. Check your database
  4. Replay the same webhook again
  5. Verify the database looks the same (no duplicates)
terminal
$ 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.

sql
-- 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.

Test your idempotency

Use HookReplay to replay webhooks multiple times and verify your handlers are bulletproof.

Get Started Free
HR

HookReplay Team

We're building the fastest way to debug webhooks on localhost. Follow us on Twitter for tips and updates.