← All articles

At-Least-Once vs Exactly-Once Delivery in Webhooks

Webhooker Team Updated 12 min read
Flat illustration of at-least-once versus exactly-once delivery: a pipe forking from a pulse node, one branch spilling duplicate messages, the other pinching down to a single one.

Exactly-once delivery is not something a webhook provider can hand you over HTTP. A delivery can succeed and its acknowledgement can still be lost on the way back, and the sender cannot tell that apart from a failed request. What you can get is at-least-once transport combined with a consumer that ignores repeats, which produces an exactly-once effect. That combination is usually called effectively-once, and it is the target to design for.

The at-least-once vs exactly-once question matters because “exactly-once” appears on a lot of product pages, and developers reasonably read it as “I do not have to think about duplicates.” You still do. Below: the six points where an acknowledgement can be lost, the four questions that show what a vendor’s exactly-once claim actually covers, and what an at-least-once contract should promise in exchange.

The three delivery guarantees a system can offer

Every messaging system picks one of three contracts, and each one accepts a different failure. No contract avoids failure entirely.

Table: the three delivery semantics, the failure each one tolerates, and where each one fits.

SemanticWhat it promisesFailure it acceptsWhere it fits
At-most-onceEach event is delivered zero or one timeEvents can be silently lostMetrics samples, cosmetic notifications
At-least-onceEach accepted event is delivered one or more timesThe consumer sees duplicatesPayments, orders, inventory, anything where loss costs more than repetition
Exactly-onceEach event is delivered once, no loss and no repeatNothing, which is why it does not survive an HTTP boundaryClaimed more often than it is achieved

At-most-once is easy to build and rarely what you want: if a request times out, you move on. That is acceptable for a gauge that gets resampled in fifteen seconds and unacceptable for a payment_succeeded event, where the failure mode is a customer who paid and a system that never noticed. At-least-once flips the tradeoff. You retry until the destination confirms, so nothing is lost, and the destination sometimes sees the same event twice. A duplicate can be handled in code; a lost event usually cannot be recovered.

Why exactly-once breaks over HTTP

Skip the formal proof and watch one delivery fail.

A worker claims delivery 88213 and POSTs the event to your endpoint. Your handler validates the signature, opens a transaction, writes a refunds row, commits, and starts building the 200 OK response. At that instant the process is killed: an OOM kill, a node eviction, a deploy that did not drain. The response never leaves the machine.

From the gateway’s side, all that happened is a request with no response before the timeout expired. The refund row exists, but the gateway cannot learn that: the only channel that could have told it is the channel that just broke. Two options remain, and both are wrong some of the time:

There is no third option. This is the two generals problem in its most practical form: two parties on an unreliable channel cannot both reach certainty about a shared outcome, because the last message in any exchange can always be the one that is lost. Acknowledging the acknowledgement just moves the uncertainty one hop out.

Where the acknowledgement gets lost

The refund crash is one of six moments a delivery can fail. Line them up and the problem is visible in one column: the sender sees nearly the same thing whether or not your side effect happened.

Table: failure points in a single webhook delivery, whether the side effect ran, what the sender observes, and what a retry does.

Failure pointSide effect happened?What the sender seesA retry is
DNS or TCP connect failsNoConnection errorThe first real delivery
Handler crashes before its transaction commitsNo, rolled back5xx or timeoutThe first real delivery
Handler commits, crashes before respondingYesTimeoutA duplicate
200 OK is sent, then dropped by a proxy or the networkYesTimeout or connection resetA duplicate
200 OK arrives after the sender’s timeoutYesTimeoutA duplicate
Sender gets 200 OK, crashes before recording successYesNothing: the success is forgottenA duplicate

Rows two to five look identical from the sending side: a request went out and no usable answer came back. Retry all four and three of them become duplicates. Retry none and the event in row two is lost. Only the first row is safe to decide on, because a request that never connected cannot have run. That table is the whole argument against exactly-once over HTTP.

The last row is the one people forget. The receiver did everything right, the acknowledgement arrived, and the duplicate still happens, because the sender’s own bookkeeping sits on the far side of another crash.

Where duplicates actually come from

In practice they arrive from four directions, and only one of them is under the webhook gateway’s control.

A slow consumer manufactures duplicates on its own. If your handler routinely takes longer than the gateway’s timeout, those deliveries get retried while the first attempt is still running. Acknowledging fast on your side cuts duplicates as well as latency.

When a vendor claims exactly-once

Treat it as a question rather than a red flag. Exactly-once semantics are achievable inside a closed system, where one vendor controls the producer, the log, and the consumer, and can tie offset commits to the same transaction as the write. That is hard to build, and it is a different problem from delivering an HTTP POST to a server the vendor has never seen. So check what the claim covers:

  1. Does it apply end to end, including the final HTTP hop to your endpoint, or only to internal handoffs before that hop? Most claims describe the second.
  2. Over what window does deduplication apply? Five minutes says nothing about a redelivery six hours later, after you fixed a bug.
  3. What happens when your endpoint processes an event and then times out? That is row three of the table. If the answer is “we retry”, the system is at-least-once with dedupe on top. That is fine, but it is not exactly-once at your boundary.
  4. Does it survive a replay from a dead-letter queue, which is a deliberate re-send of something already attempted?

Webhooker provides at-least-once delivery on a durable queue and expects consumers to be idempotent, which puts the consumer’s half of the work in the contract. Ask the same four questions of any vendor you are evaluating. How the main gateways answer them is a shorter list than their feature tables suggest.

What at-least-once actually promises

At-least-once is a checkable contract, and stronger than it sounds. It says: once we have accepted an event we will not lose it, and we will keep trying to deliver it until the destination confirms or the retry budget runs out. Two mechanics make that credible.

The first is persist-before-acknowledge. When a provider POSTs to a Webhooker ingest URL, the event is verified and written to durable storage before the 200 OK goes back, and 99% of events are acknowledged in under 10 ms, measured server-side. The order of those two steps is what makes the guarantee hold. Crash a microsecond after acknowledging and the event is already on disk, so a restarted worker picks it up. A system that acknowledges first and enqueues afterwards has a window where an accepted event exists only in memory, and events die in that window.

The second is out-of-band delivery, and it is the half you inherit the moment you stop polling an API and let events be pushed to you. Workers claim deliveries from a durable queue, and delivery never sits on the provider’s request path, so a destination that takes eight seconds to respond does not slow the acknowledgement Stripe or GitHub is waiting for. On PostgreSQL that queue is a table claimed with FOR UPDATE SKIP LOCKED; the delivery queue design covers the schema, lock timeouts and the reaper that turns a crashed worker into a retry.

What the contract deliberately does not promise is uniqueness. Duplicates are the stated cost of never dropping an event.

Effectively-once: the consumer’s half

Since the transport cannot guarantee uniqueness, the consumer has to:

at-least-once transport + idempotent consumer = exactly-once effect

An idempotent consumer reaches the same end state whether it processes an event once or five times. In practice that means recording the provider’s event id under a unique constraint, in the same transaction as the side effect, so the database rather than your code decides which of two concurrent copies wins. The implementation, the choice of key, and how long to keep keys are in the guide to idempotency keys for webhook consumers.

Ordering is a separate guarantee

At-least-once says nothing about sequence, and the two get conflated constantly. Retries reorder traffic by construction: event A fails on its first attempt and lands eight seconds later, while event B, sent afterwards, succeeds immediately. Your handler sees B then A.

Ordered delivery is its own guarantee with its own cost: serializing a destination’s traffic, and letting one stuck event hold up everything behind it. In Webhooker it is available on the Team plan. It does not remove duplicates, since a strictly ordered stream can still deliver the same event twice in a row. Two cheaper patterns cover most cases, so before you reach for it, read why webhooks arrive out of order and when you need FIFO delivery.

Designing for the contract

Accept at-least-once as the ground truth and a handful of decisions follow.

Of these, the unique constraint and the shared transaction do most of the work. Without them, a duplicate that slips in during a slow deploy can double-charge someone; with them, the second insert hits the constraint, the handler returns 200, and nothing else happens.

Frequently asked questions

What is the difference between at-least-once and exactly-once delivery?

At-least-once delivery means every accepted event reaches the destination one or more times: the sender retries until the receiver confirms, so nothing is lost and duplicates are possible. Exactly-once delivery means every event arrives once and only once, with no loss and no repeat. Over HTTP only the first is deliverable, because a lost acknowledgement is indistinguishable from a failed request. The practical equivalent of the second is at-least-once transport plus an idempotent consumer.

Why can’t webhooks guarantee exactly-once delivery?

Because the acknowledgement can be lost after the work is done. Your handler can write the row, commit, and die before the 200 OK leaves the machine; the sender sees only a timeout. Retrying creates a second side effect, not retrying loses the event, and there is no third option. This is the two generals problem: two parties on an unreliable channel cannot both be certain about a shared outcome, because the last message in any exchange can be the one that is lost.

Can I get exactly-once with a transactional outbox?

An outbox gets you exactly-once within your own database, which is useful. You write the business row and the outbox row in one transaction, so a message is never emitted for work that rolled back. But the relay still sends that message over the network, and it still cannot distinguish a lost acknowledgement from a failed request. The outbox removes the dual-write problem between your database and your queue. It does not remove the ambiguity at the receiving end, so the consumer still needs to be idempotent.

Does using a message queue give me exactly-once?

Not across an HTTP boundary. Several brokers offer exactly-once semantics inside their own ecosystem, where the broker controls the producer, the log, and the consumer, and can commit the read offset in the same transaction as the write. That guarantee holds while messages stay inside the system. The moment the final hop is a POST to an external endpoint, the ack can be lost after the side effect and you are back to at-least-once. A queue improves durability, but the last hop is still HTTP.

How many duplicates should I expect?

There is no universal figure, because it depends on how often your endpoint times out or errors near completion. In a healthy pipeline duplicates are rare and correlate with incidents: a deploy without connection draining, a slow query pushing handler latency past the timeout, a destination flapping. Rather than predicting the number, make it irrelevant. With a unique constraint on the event id, a duplicate rate of 0.01% and one of 2% both end in a skipped insert.