Every webhook consumer eventually meets the same problem. A submission lands, the webhook fires, the consumer processes it — and then later the same webhook arrives again. Maybe twice. Maybe four times over the next ten minutes. The consumer, naively written, creates a duplicate CRM contact, sends a duplicate email, charges a duplicate invoice. Then someone files a support ticket, and the team spends a Friday afternoon writing a script to clean up the mess.

This is the practical guide to webhook idempotency — what it actually means, why duplicate deliveries are unavoidable, and the small amount of consumer code that makes the problem disappear.

What is a webhook idempotency key?

An idempotency key is a unique identifier the sender attaches to a webhook delivery, and the consumer uses to recognise "I've already seen this one". The contract is simple:

  • The sender generates a stable ID for each logical event — typically the submission ID, or a delivery ID that wraps it.
  • The sender includes that ID in every retry of the same delivery. Three retries of the same webhook all carry the same ID.
  • The consumer records every ID it has successfully processed.
  • When a webhook arrives, the consumer checks its record. If the ID is new, process. If the ID has been seen before, acknowledge with success and do nothing else.

The result: duplicate deliveries become harmless. The consumer's downstream side effects (database writes, emails, charges) only fire on the first delivery. Subsequent retries return 200 OK to the sender, the sender stops retrying, and nobody notices.

The mechanic is older than webhooks — payment processors and database engines have used idempotency keys for decades. The IETF has been formalising the pattern at the HTTP layer with the Idempotency-Key header draft, which most modern senders mirror in spirit if not in exact spelling. The 2020s shift is that nearly every well-designed webhook sender now exposes one in the headers, typically as X-Webhook-Id, X-Idempotency-Key, or a vendor-specific equivalent. Senders that don't expose one are the ones that cause the most consumer pain.

Why do duplicate deliveries happen in the first place?

Duplicates are not bugs — they are the inevitable consequence of how retries work over an unreliable network. The categories worth knowing:

Lost acknowledgement. The consumer received the webhook, processed it, returned 200 OK. The 200 OK gets lost on the way back. The sender, having no proof of success, retries.

Timeout on the sender side. The consumer is slow. The sender's HTTP client times out, queues a retry, and the consumer eventually returns 200. Both deliveries land successfully.

Network partition during retry. A transient network failure causes overlapping retries. Both succeed.

Manual replay. An operator clicks "replay this webhook" in a dashboard. The replay carries the same idempotency key — and if it didn't, every manual replay would be a duplicate processing event.

In our experience, the rate of duplicates to a busy consumer is often non-trivial — small enough to ignore in testing, large enough to leave a mess by the end of a busy day.

What does idempotent consumer code actually look like?

The shape of a correctly idempotent webhook handler:

text
function handleWebhook(request) {
  const id = request.headers['x-webhook-id'];
  if (!id) {
    return respond(400, 'missing idempotency key');
  }

  const existing = idempotencyStore.get(id);
  if (existing) {
    return respond(200, existing.responseBody);
  }

  const result = processBusinessLogic(request.body);
  idempotencyStore.put(id, { responseBody: result }, ttl = '7 days');
  return respond(200, result);
}

Five lines of real logic, embedded in a transaction or atomic check-and-set. The pattern works because:

  • The check happens before any side effect.
  • The recording of the ID happens in the same atomic operation as the side effect — either both happen or neither does.
  • The response is cached, so a duplicate delivery gets back the exact same response the original did, which is what well-behaved senders expect.

The implementation choice that matters is the store. A Redis SET … NX operation with a TTL is the canonical approach. A database table with a unique constraint on the idempotency key works too, particularly if the side effect is already a database write — you can wrap both in the same transaction and let the database enforce the constraint.

The implementation that fails subtly is "check the store, then do the side effect, then record the ID". The window between check and record is where two concurrent duplicate deliveries can both pass the check and both fire the side effect. Atomic check-and-set is mandatory, not optional.

How long should the idempotency window be?

The window is how long the consumer remembers an ID before considering it a fresh event. Too short and late retries arrive after the window expires and get processed twice. Too long and the store grows without bound.

The sender's retry policy sets the floor. If the sender retries for 24 hours with exponential backoff, the consumer's window must be at least 24 hours, with margin. A reasonable default is 48 hours — longer than any well-designed retry policy, short enough that the store stays manageable.

Manual replay extends the relevant window. If operators can replay a webhook from a dashboard a week later, and the consumer should treat that replay as the same logical event, the window has to cover that too. In our experience, a seven-day window is the common upper bound — long enough to cover any reasonable manual replay scenario, short enough that an intentional re-fire after a month gets treated as a new event.

The window can be shorter than the data retention. The idempotency key only needs to live long enough to deduplicate within the retry window; the downstream record (the CRM contact, the email log) can live forever. Conflating these two is a common architectural mistake — the idempotency store grows unbounded because nobody set a TTL.

Why is payload hashing a worse choice than explicit keys?

A tempting shortcut: instead of relying on a sender-provided ID, hash the payload. Same payload, same hash, must be a duplicate.

This is wrong in a subtle way. Payload hashing collapses events that are intentionally identical. A user submits the same contact form twice because they thought it didn't go through. A scheduled job fires the same status webhook every hour. A retry that adds a retry_count field breaks the hash and slips past the dedup. Each of these breaks the "same bytes equals duplicate" assumption.

The explicit idempotency key, generated once at the moment the logical event is created and carried through every retry, captures the right invariant: "this is the same logical event". Use payload hashing only as a fallback when the sender refuses to provide an explicit key.

Is idempotency the same as deduplication?

They overlap but are not the same. Idempotency is a property of the consumer's processing logic — receiving the same event twice has the same effect as receiving it once. Deduplication is a property of an event stream — identical events get collapsed before downstream consumers see them.

For webhook reliability, idempotency on the consumer is the right answer. It is robust to any sender retry behaviour, requires no coordination, and works correctly even if multiple consumers receive the same event independently.

How does idempotency interact with replaying failed webhooks?

The interaction is the point of idempotency. A failed webhook gets replayed, often hours or days later, possibly by a different operator than the original sender. Without idempotency keys, the consumer has no way to tell whether the replay is a new event or a duplicate of one that already succeeded silently.

The flow that works:

  • Original webhook fires, fails or times out, retried by the sender with the same idempotency key.
  • Sender's retry budget eventually exhausts, sender marks the delivery as failed.
  • Operator manually replays from the dashboard.
  • Replayed webhook carries the same idempotency key.
  • Consumer checks its store, finds the ID, returns the cached success response.
  • No duplicate side effect.

The combination of exponential-backoff retry strategy on the sender and idempotency on the consumer turns webhook delivery from a problem that requires manual reconciliation into a problem that just resolves itself over hours.

Without idempotency, manual replay is dangerous — the operator has to know whether the original succeeded or not before replaying, which is exactly the question they can't easily answer. With idempotency, replay is safe by construction. Click the button as many times as you want.

What about the security side?

A short note on verification because it interacts with idempotency. The idempotency key is in the headers, where it is vulnerable to tampering by anyone who can intercept the request. The fix is to verify the HMAC signature on the webhook before checking the idempotency key — see the OWASP guidance on cryptographic storage and constant-time comparison for the primitives. Signature verification confirms the sender is authentic; idempotency check then confirms the event is fresh.

The order matters. Checking the idempotency key first opens a denial-of-service path: an attacker who can guess or harvest valid keys can mark them as processed in your store, causing legitimate retries to be silently dropped. Verifying the signature first means only authentic deliveries ever touch the idempotency store.

When is idempotency overkill?

If the webhook's side effect is itself naturally idempotent — a database upsert by a stable external ID, a single-field update to a fixed value, a fire-and-forget metric increment — you do not need an extra layer. The database or downstream system already enforces it.

The cases where idempotency is mandatory: any webhook that creates new records, sends external communications, touches money, or fans out to non-idempotent downstreams. Most consumers fall in this second category. In our experience, the cost of adding the layer once is small enough that it is worth doing regardless.

Related from this desk

The minimum viable idempotency

If you take nothing else from this:

  • The sender must include a stable, per-logical-event ID in a header.
  • The consumer must check that ID against a store before doing any side effect.
  • The check and the side effect must be atomic.
  • The store entry must outlive the sender's retry policy.
  • Verify the signature before the idempotency check.

Those five rules turn webhook duplicates from a recurring incident into a non-event. The infrastructure cost is small. The Friday-afternoon cleanup that you don't have to do is large.