Consumer-Side Idempotency
The provider promised at-least-once, so duplicates are not a bug — they are scheduled. Exactly-once processing is an illusion the consumer manufactures locally: record the event_id, process each id exactly once, and make the recording atomic with the effects.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Duplicates are scheduled, not exceptional
Walk the ordinary failure: the provider POSTs order.paid, your handler processes it in 9 seconds, the provider's timeout was 8. From the provider's ledger the attempt failed, so 30 seconds later the same event arrives again — same event_id, new delivery_id. Nothing malfunctioned. Both sides followed their contract exactly, and you shipped the order twice.
Redelivery also arrives in bulk: a Webhook Delivery: States, Retries, Redrive redrive after your outage replays thousands of old events, some of which you *did* process before crashing. And it arrives concurrently: a retry can land while the original attempt is still executing, so two workers process the same event side by side. A dedup design has to survive all three shapes — the lone retry, the bulk replay, the concurrent double — not just the first.
POST /hooks/orders HTTP/1.1
Host: consumer.example
X-Delivery-Id: dlv_5c09e2 ← differs from the first attempt
X-Event-Id: evt_8f2c1a ← identical to the first attempt
Content-Type: application/json
{
"event_id": "evt_8f2c1a",
"type": "order.paid",
"occurred_at": "2026-08-25T09:14:03Z",
"data": { "order_id": "ord_4211" }
}HTTP/1.1 200 OK
{ "received": true }
← the consumer returns 200 even for a duplicate:
"I have durably accepted this event_id" is true
whether processing ran now or last time. A 409
here would just make the provider retry again.Dedup that survives concurrency: one atomic claim
The broken version of dedup is check-then-act: look up event_id in a processed-set, and if absent, process and then record it. Two concurrent deliveries of the same event both pass the check, both process. The fix is to make the *claim* atomic: insert the event_id into a table with a unique constraint (or SET NX in Redis, with the durability caveat that a cache flush reopens the duplicate window). Exactly one worker wins the insert; the loser stops.
Claiming is half the job — the claim and the effects must not be separable by a crash. If you record the event as processed and crash before shipping, the order never ships and the retry is refused; if you ship and crash before recording, the retry ships again. When the effects live in your own database, put the insert and the effects in one transaction. When they cross a process boundary (an email API, a payment call), the transaction cannot cover them — record the event and the *intent* atomically, then execute the external effect from a worker with its own idempotency protection, ideally passing the provider's event_id onward as your Idempotency Keys: The Mechanism key for the downstream call.
1def process(event):2 if db.exists("processed", event.id): # both workers: false3 return4 ship_order(event.data.order_id) # both workers ship5 db.insert("processed", event.id) # second insert fails,6 # too late to matter1def process(event):2 with db.transaction():3 ok = db.try_insert("processed_events",4 id=event.id) # unique constraint5 if not ok:6 return # someone else won7 db.insert("shipments",8 order_id=event.data.order_id)9 # external effects go through the outbox/worker,10 # keyed on event.id downstreamThe unique constraint turns "have I seen this?" into a race-free claim: the database serializes the two workers, exactly one proceeds, and a crash rolls back claim and effects together. Check-then-act passes every single-threaded test and fails the first concurrent retry.
Scope, retention, and what to dedup on
Dedup on `event_id`, never on delivery_id (which changes per attempt, by design) and never on a payload hash (two legitimate distinct events can carry identical payloads — two order.paid events for the same amount — and would falsely collapse). The provider's event id is the identity of the business fact; that is the entire reason Webhooks: The Inverted Contract requires it in the envelope.
Retention must outlast every path by which an old event can reappear: retry horizon plus redrive window plus your own replay tooling. If the provider retries for 72 hours and offers 30-day redrive, keeping processed ids for 45–60 days is cheap insurance — at one row per event, ten million events a month costs you a few hundred MB, versus one duplicated shipment costing more than the table ever will. This is Idempotency vs Deduplication from the consumer's chair: you are deduplicating *messages* here; whether your business effect is idempotent is a separate property worth having anyway, as the second line of defense when the dedup store lets you down.
- Key:
event_id— stable across retries and redrives; that is its job. - Not
delivery_id— unique per attempt; deduping on it dedups nothing. - Not payload hashes — distinct facts can look identical; collapsing them is silent data loss.
- Retention: ≥ provider retry horizon + redrive window; document your own number.
- Store: durable and transactional beats fast and flushable — a Redis-only dedup set reopens the window on every failover.
Key points
- At-least-once delivery makes duplicates a scheduled event, arriving as lone retries, bulk redrives, and concurrent doubles.
- Exactly-once exists only as consumer-side processing: claim the event_id atomically, exactly one claimant proceeds.
- Check-then-act dedup fails under concurrency; a unique-constraint insert in the same transaction as the effects does not.
- External side effects cannot ride in your transaction — record intent atomically, execute via a worker, and pass event_id downstream as the idempotency key.
- Return 200 for duplicates: "durably accepted" is true either way, and any error status just provokes another retry.
- Retention outlasts retry horizon plus redrive window; dedup on event_id, never delivery_id or payload hashes.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Consumer → handler: processes events directly, no dedup — the demo never sees a duplicate, so the code assumes there are none.
- 2Provider → consumer: an 8s timeout fires on a 9s handler; the retry arrives while the original is still running.
- 3Both workers → database: check-then-act dedup passes twice; the order ships twice.
- 4Consumer → patch: adds a Redis processed-set with a 24h TTL; the provider's 72h retry horizon sails past it.
- 5Provider → redrive: an outage recovery replays 40,000 events; every one older than 24 hours processes again.
- Duplicate irreversible effects: double shipments, double emails, double credits — each a customer-facing incident with a refund path.
- Reconciliation drift: consumer records disagree with provider records, and without recorded event ids nobody can prove which duplicates ran.
- Redrive becomes unusable: the provider's recovery tool, which assumes consumer dedup, multiplies damage instead of repairing it.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Claim event_id via unique-constraint insert in the same transaction as local effects; treat the losing insert as success.
- • Route external side effects through a worker that receives the event_id and uses it as the downstream idempotency key.
- • Size dedup retention from the provider's published retry horizon and redrive window, and write your number down.
- • Make business effects idempotent where the domain allows (upsert the shipment keyed by order_id) as a second layer under the dedup store.
- • Count duplicate claims (losing inserts) per event type: a baseline trickle is health; a spike means your handler latency crossed the provider timeout or a redrive is running.
- • Alert when dedup-store retention is shorter than the provider's configured retry horizon — the drift usually happens when someone "optimizes" the TTL.
- • Sample-audit effects against events: shipments without a claimed event_id are the leak your dedup missed.
- • Dedup lives entirely on the consumer side, so it can be retrofitted without provider changes — but backfilling identities for already-processed events requires a reconciliation pass first.
- • If the provider later shortens or lengthens its retry horizon, retention is one config value — provided you recorded which provider promise it was derived from.
- • A durable dedup store adds a write to every event and a table that grows with traffic; the cost is trivial next to one duplicated shipment, but it is not zero.
- • Transactional claiming serializes concurrent deliveries of the same event, adding contention exactly during retry storms — which is the correct place to be slow.
- • Idempotent business effects (upserts keyed on business ids) can mask provider bugs that dedup would have surfaced; keep the duplicate-claim metric even when the effect layer absorbs them.