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 — oneevt_... 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
2xxin time. If your handler did its side effect and then timed out — or returned the ack after Orbit’s per-endpointtimeout_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 envelope
id—"id": "evt_..."in the JSON body. - The
Idempotency-Keyheader — mirrorsbody.id, so you can dedup before parsing the body.
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.- Verify the signature, then read
body.id(or theIdempotency-Keyheader — same string). - 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).
- 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. - 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 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
2xxburns 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}/requeueonly moves rows that never succeeded back into the retry scheduler — your consumer has not seen those event ids yet. - Single-delivery replay —
POST /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 window —
POST /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 witheventTypeFilterwhere 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.
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:
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/towindow you replayed.
See also
- Troubleshooting: failed webhook deliveries, retries, DLQ, and replay — the failure-and-recovery loop this page deliberately skips.
- Build a durable webhook consumer — the worked receiver that implements the pattern above.
- Webhook event payloads — the envelope and
the
Idempotency-Keyheader contract. - Webhook security — delivery, retry, and signature semantics.
- Webhook deliveries: explore, inspect, replay — the dashboard surfaces for reading attempts.