CanHook
Features Pricing Docs FAQ Blog Log in Get started free

How to Handle Duplicate Webhook Deliveries and Retries

By The CanHook Team · August 25, 2026 · 1187 words
In short

A duplicate webhook delivery happens when a sender retries an event it isn't sure you processed. Handle it by extracting a stable event ID, checking it against a store of processed IDs before you act, and returning a 2xx status for anything already seen.

Your payment provider redelivers the same webhook, and now your order confirmation email goes out twice, or a customer's card gets charged twice. Duplicate webhook deliveries happen because most webhook systems guarantee at-least-once delivery, not exactly-once: if a sender isn't sure you received an event, it sends it again. CanHook, a webhook inspector and relay pipeline, sees this from both sides — as the endpoint receiving a provider's retries, and as the relay resending your own webhooks downstream. Webhook idempotency is the property that processing the same event twice produces the same result as processing it once, and it isn't automatic — you have to build it. Here's how, and where it fits around a webhook you're already capturing or relaying.

Why Do Webhook Senders Redeliver the Same Event?

A sender only knows a delivery succeeded if your endpoint answers with a 2xx status inside its timeout window. Anything else — a timeout, a 500, a dropped connection, a redirect — reads as failure, and the sender assumes you never got the event. It then retries on a schedule, usually with exponential backoff. Stripe's own webhook documentation describes attempting delivery for up to three days with exponential backoff in live mode, and it explicitly warns that endpoints might occasionally receive the same event more than once.

None of this is a bug in the provider. It is the tradeoff every at-least-once system makes: losing an event silently is worse than delivering it twice, so the sender is built to err toward duplicates. Your handler is the layer that has to absorb that.

What Happens If Your Handler Isn't Idempotent?

Without a duplicate check, every redelivered event repeats whatever side effect the first delivery caused. A payment webhook that fulfills an order fulfills it twice. A subscription-renewed event that extends an access window extends it twice. A notification webhook sends the same email or Slack message again, which looks like a bug to the customer even though your code executed correctly both times — it just executed twice.

The failure is rarely obvious in testing, because a clean test environment sends each event exactly once. It shows up in production, under the exact conditions that trigger retries in the first place: a slow query that pushes your response past the timeout, a deploy that briefly returns 502s, or a network blip between the sender and your server.

How Do You Make a Webhook Handler Idempotent?

The pattern is the same regardless of provider:

  1. Extract a stable identifier from the event — normally the provider's event ID.
  2. Before doing any work, check that identifier against a store of IDs you've already processed.
  3. If it's already there, stop and return a 2xx response without repeating the action.
  4. If it's new, record it and process the event in the same transaction, or as close to it as your system allows.
  5. Return a 2xx response only after that record is durably written.

A database unique constraint is the simplest correct implementation, because it makes the check and the write atomic — two concurrent deliveries of the same event can't both pass the check before either one writes:

app.post('/webhooks/provider', async (req, res) => {
  const eventId = req.body.id;

  try {
    await db.query(
      'INSERT INTO processed_events (event_id) VALUES ($1)',
      [eventId]
    );
  } catch (err) {
    if (err.code === '23505') {
      // unique_violation -- already processed, ack and stop
      return res.status(200).send('duplicate');
    }
    throw err;
  }

  await handleEvent(req.body);
  res.status(200).send('ok');
});

The insert happens before the business logic runs, not after. Check-then-insert-after leaves a window where two near-simultaneous deliveries of the same event can both pass the check before either one finishes.

Where Should You Store Processed Event IDs?

The right store depends on how the rest of your system is built, not on the webhook itself:

StorageDurable across restartsGood for
Database unique constraintYesMost production handlers — ties the dedup check to the same transaction as the write
Redis SETNX with a TTLOnly if persistence is enabledHigh-volume events where a bounded dedup window is acceptable
In-process memory (a Set)NoLocal testing only — cleared on every restart or deploy

Pick a retention period at least as long as the sender's retry window, or a genuine retry can slip through as if it were new. Set the TTL too short and you've reintroduced the exact bug you were trying to fix.

Seeing Duplicate Deliveries Before They Reach Your Code

Before you fix duplicate handling, it helps to confirm a provider is actually resending an event, and see exactly what changed between attempts. A CanHook catch URL logs every hit to an endpoint as its own captured request — headers, body, and timestamp — so two delivery attempts of the same event show up as two separate entries you can compare side by side instead of one merged log line. A catch URL accepts up to 600 requests per token every 60 seconds by default, so replaying a payload a few times while you test your dedup logic won't get throttled.

curl -X POST https://canhook.com/h/abc123def456 \
  -H "Content-Type: application/json" \
  -d '{"id":"evt_test_1","type":"payment.succeeded"}'

Send that same payload twice against your own catch URL and you'll see two captured requests with the same body and event ID but different timestamps — exactly what your production endpoint needs to tell apart before it decides whether to act.

What About Duplicates Your Own Relay Introduces?

If you forward captured webhooks to other services, the same problem exists one layer down. A CanHook relay rule retries a failing destination with exponential backoff — 60 seconds, then 5 minutes, then 25 minutes, then 2 hours — up to the rule's configured retry count. If your destination actually processed the request but its response was slow or dropped before CanHook saw a 2xx, the next attempt looks identical to the destination: same body, same headers, a new delivery.

That means any service on the receiving end of a relay needs the same idempotency check described above, using whichever identifier survives the transform. If you reshape the payload before forwarding, as covered in how to transform a webhook payload before forwarding, make sure the event ID is one of the fields you keep.

Isn't This Overkill for Most Webhooks?

For a low-volume internal tool, maybe — if every action you take is already idempotent, like overwriting a record with the same values, you don't need a dedup table. But the moment a webhook triggers something that isn't naturally repeatable — charging a card, incrementing a counter, sending an email, decrementing inventory — skipping the check is a bet that a retry never happens. Given that retries are the sender's designed response to any timeout, deploy, or network blip on your side, that bet loses eventually, and it loses in production, not in a test suite.

If you want to see this in practice, create a free CanHook endpoint and send it the same test payload twice with a fixed event ID. You'll see two captured requests within seconds, which is the fastest way to confirm your own handler's dedup check actually works before a real provider tests it for you in production.

Frequently asked questions

Why do webhook providers send the same event more than once?

Most providers guarantee at-least-once delivery, not exactly-once. If your endpoint times out, returns a non-2xx status, or the response never arrives due to a network error, the provider assumes delivery failed and resends the same event later, even though your server may have already processed it.

What is a webhook idempotency key?

An idempotency key is a stable identifier, usually the event ID a provider assigns, that your handler checks against previously processed events before doing any work. If the key has already been seen, you skip processing and return a success response instead of repeating the action.

Should I deduplicate by event ID or by the underlying object ID?

Start with the event ID, which is unique per delivery attempt for most providers. Some providers occasionally generate two distinct event objects for what is logically the same change, so for critical actions like charging a card, also check the ID of the object inside the event data.

Does returning a 2xx status stop a provider from retrying?

Yes. A 2xx response tells the sender the event was received and processed, which ends its retry schedule for that delivery. Returning anything else, including a timeout, tells the sender to try again later, which is exactly the behavior that produces duplicates in the first place.

Is it safe to ignore duplicate webhook deliveries entirely?

Only if every action your handler takes is naturally idempotent, like overwriting a record with the same values. Anything that increments a counter, sends an email, or charges a payment method repeats that side effect on every duplicate unless you add an explicit check first.

How long should I keep processed event IDs?

Keep them at least as long as the sender's retry window. Stripe, for example, retries a failed delivery for up to three days in live mode, so a dedup store with a shorter expiry can let a genuine retry through as if it were a brand new event.