Skip to main content

Troubleshooting: duplicate webhook events and consumer-side dedup

Your ledger shows two credits for one delivered message, a bulk replay fired ten thousand events your consumer already processed, or your handler rejects an event it has genuinely never seen. Almost every “webhook sends duplicates” report lands in one of three buckets: Orbit retried because it could not see your ack, you replayed deliberately and the consumer could not recognize the re-delivery, or the consumer is hashing the wrong key. This page maps each symptom, then gives you the consumer pattern that makes all three safe. Failure diagnosis — your endpoint returning 5xx, timing out, or deliveries landing in the dead-letter queue — is a different loop; start with Troubleshooting: failed webhook deliveries, retries, DLQ, and replay and come back here once deliveries flow.

Symptom map

Match the duplicate you see to its cause before changing any code: Each row expands below.

How Orbit emits a duplicate: the dedup model

Orbit’s dispatch pipeline is at-least-once by design. One event — one evt_... id — is persisted once in the webhook-events registry, then attempted against your endpoint up to 10 times (initial delivery plus 9 retries over roughly 4.3 hours) until an ack lands or the row reaches the DLQ. Two of those attempts produce a delivery you must treat as a duplicate:
  • Duplicate on retry. A retry is only started when Orbit did not see a 2xx in time. If your handler did its side effect and then timed out — or returned the ack after Orbit’s per-endpoint timeout_seconds (30-second default) — the retry you receive is a duplicate of an event you already processed.
  • Duplicate on replay. Replay re-dispatches the stored payload through the same pipeline, by hand. It works on succeeded rows too: that is the feature, and it always re-sends the same event id.
The dedup anchor is stable across both paths and lives in two places:
  • The envelope id"id": "evt_..." in the JSON body.
  • The Idempotency-Key header — mirrors body.id, so you can dedup before parsing the body.
Both stay identical across every retry and every replay of one event. The header shape is documented on Webhook event payloads; the dispatch/retry semantics behind them are on Webhook security.

The durable-consumer pattern

Orbit’s guarantee ends at “the same event id may arrive more than once.” The idempotency guarantee on your side is tenant-owned: ack fast, process through a queue, and persist the event id before you ack.
  1. Verify the signature, then read body.id (or the Idempotency-Key header — same string).
  2. Persist the id into a table or key store with a unique constraint, keeping it for at least 7 days (the full retry window plus the DLQ retention window).
  3. Ack immediately. If the insert hit the unique constraint, the event is a duplicate: still return 200. A fast duplicate ack stops the retry cadence and does not re-fire your billable work.
  4. Process through a queue. Push the event onto your own worker queue and let the worker, not the HTTP handler, run the side effect. If the worker crashes, the event retries on your side — Orbit never has to.
The full build, with a worked Node and Python receiver, is in Build a durable webhook consumer. Two consequences fall out of the pattern:
  • The only correct key is the event id — never the delivery id (each attempt has its own), never a hash of the payload body (the same logical event can legitimately be replayed with identical bytes, and identical bytes keyed wrong still collapse into one row).
  • Ack the duplicate, don’t 4xx it. Returning anything but 2xx burns retry budget on an event you already processed and can push a healthy endpoint toward auto-disable (50 consecutive failures).

Replay-side dedup

Replay re-sends events that may already have succeeded, so “what to dedup” depends on which replay you ran:
  • DLQ requeue is safe by construction. POST /api/v1/webhooks/dlq/{deliveryId}/requeue only moves rows that never succeeded back into the retry scheduler — your consumer has not seen those event ids yet.
  • Single-delivery replayPOST /api/v1/webhooks/{id}/deliveries/{deliveryId}/replay — re-sends one stored event. If the original attempt succeeded, the consumer’s event-id insert collapses it to a no-op; coverage is not lost because the key is the same.
  • Bulk replay over a windowPOST /api/v1/webhooks/{id}/replay-range — is the dedup-critical case. The window covers every stored delivery, not just failed ones, so the burst contains a share of duplicates by definition. Keep the window tight around the outage, scope it with eventTypeFilter where the outage was event-specific, and let the durable-consumer pattern above absorb the share that already succeeded. Replaying a wide window to a non-durable consumer is the one case that turns a recovery tool into a double-processing storm.
The full requeue-versus-replay decision, the one-job-per-endpoint 409 constraint, and job polling are on Troubleshooting: failed webhook deliveries, retries, DLQ, and replay.

Case studies

Case 1 — duplicate message.delivered

Symptom. A customer’s SMS ledger posts two credits for one delivered message. The dashboard duplicates page shows two delivery rows for the same endpoint, 30 seconds apart. Confirm. Pull the deliveries for the endpoint:
You see two rows that share one event_id (evt_...) but carry different delivery ids. The first row’s attempt ends in a timeout or a non-2xx; the second succeeds. Orbit retried because it never saw the ack — this is the delivered-as-designed duplicate your consumer is expected to absorb. Fix. The handler was posting the credit synchronously and acking after the database write. Move the credit into a queue job, persist body.id before acking, and return 200 as soon as the id is durably stored. The next retry of that event id is no longer a second credit — it is an ack from your dedup store.

Case 2 — duplicate conference.participant_joined

Symptom. After a receiver outage, you bulk-replayed a three-hour window. Every participant row doubled — the “joined” count is now twice the real attendance. Confirm. Fetch the replay job (GET /api/v1/webhooks/{id}/replay-range/{jobId}) and compare its from/to against the deliveries list. The window you replayed contains deliveries that had already succeeded before the outage, so each event in that share arrived twice — with the same evt_... id both times. Fix. The consumer was keyed on (conference_id, participant_session_id) derived from the payload, not on the event id, so nothing it persisted could recognize the replay. Switch the dedup key to body.id with the 7-day retention from the durable-consumer pattern. Re-run a tight replay window after the fix — succeeded rows no-op, the rows that had genuinely never landed recover, and the count converges.

What to send support

If you are seeing duplicate deliveries whose delivery rows do not share an event id (the same event genuinely minted twice), or duplicates persist after your consumer acks in under a second, write in with:
  • Event id (evt_...) from the duplicate pair — the single strongest signal.
  • Delivery ids (wdl_... / the delivery row ids) of both attempts, from the deliveries list.
  • Consumer-side timestamps — when your endpoint received each attempt and which status code it returned, in UTC.
  • Webhook endpoint id and tenant id (Settings → Organization).
  • For replay-caused duplicates, the job id and the from/to window you replayed.
That set lets support line up our dispatch log against your access log in one pass instead of three.

See also