Launch special — let's split the check with SPLITCHECK for 50% off
4 min read

Webhook Idempotency: Why It Matters and How to Implement It

Webhook providers retry deliveries. Without idempotency, retries cause double charges and duplicate emails. Here's how to build idempotent handlers.

IdempotencyReliabilityWebhooks
O

Ozer

Developer & Founder of HookSense

Webhook providers retry. They retry on network blips, on your server's 500s, on missed acknowledgments. By the time your handler has seen 100,000 webhooks, it has probably seen some of them more than once.

If your handler isn't idempotent, retries cause double charges, duplicate emails, twice-deducted inventory. The fix isn't complicated — but it has to be designed in, not bolted on. This guide covers why idempotency matters, the standard implementation, and the subtle edge cases that trip up production systems.

Why providers retry

Three reasons:

  1. Your handler returned a non-2xx status. Stripe, GitHub, Shopify all retry on 4xx/5xx (with limits — see retry strategies).
  2. Your handler timed out. Stripe gives you 20 seconds. If your handler is still working when the timeout hits, Stripe assumes failure and retries.
  3. The acknowledgment was lost. Your handler returned 200, but the response never reached Stripe. Stripe retries; your handler processes the event a second time.

Case 3 is the sneaky one. You returned success, you logged success, your monitoring shows success — and yet the same event arrives again 5 minutes later. There's no signal you can give the provider to say "I already handled this." The only defense is on your side.

The standard implementation: event ID dedup

Every reputable webhook provider includes a unique event ID. Stripe's evt_*. GitHub's X-GitHub-Delivery. Shopify's X-Shopify-Webhook-Id. Slack's event_id. The pattern:

async function handle(event: WebhookEvent) {
  // Try to insert the event ID. If it already exists, we've seen this event.
  const { rowCount } = await db.query(
    "INSERT INTO processed_events (id, received_at) VALUES ($1, NOW()) ON CONFLICT DO NOTHING",
    [event.id],
  );

  if (rowCount === 0) {
    // Already processed — return success without re-running side effects
    return;
  }

  await applyBusinessLogic(event);
}

The unique constraint on processed_events.id does the heavy lifting — even under concurrent requests for the same event, only one INSERT succeeds.

Three subtle mistakes

1. Inserting after the work

Tempting:

await applyBusinessLogic(event);
await db.insert("processed_events", { id: event.id });

Problem: if your handler crashes between line 1 and line 2, the next retry sees an unrecorded event ID and re-runs the business logic. The insert must come before the side effects, ideally in the same transaction.

2. Forgetting the transaction

If applyBusinessLogic writes to the database, wrap both the dedup insert and the business logic in a single transaction. Otherwise a crash between them leaves you with a "processed" record but no actual work done — and the retry skips the event, leaving you in an inconsistent state.

3. Not handling partial work

If your handler does multiple side effects (debit account, send email, update analytics), partial completion on the first attempt means the retry needs to know what's already done. Two approaches:

  • State machine: the event itself is a row with a status column. Each side effect checks the status before running.
  • Per-side-effect dedup keys: the email-sending step has its own unique key (event.id + ":email"), the debit step has another (event.id + ":debit").

The state machine approach is cleaner for complex handlers; the per-side-effect dedup is cleaner for handlers where each step is independent.

Cleanup: don't store every event ID forever

The processed_events table will grow without bound if you don't trim it. Providers typically don't retry beyond a few days; keep records for 30 days and delete older ones nightly. Use the received_at column for the cleanup query.

Testing idempotency

HookSense's Replay feature is purpose-built for this: capture an event, replay it five times against your endpoint, confirm side effects only happen once. If you see duplicate emails or double charges, your dedup logic has a bug — find it before production retries find it for you.

Further reading

Related posts

Try HookSense Free

Inspect, debug, and replay webhooks in real-time. No credit card required.

Get Started Free