← All articles

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

Webhooker Team 11 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 genuine failure. 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 worth designing for.

This 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 do. So it is worth reading webhook delivery guarantees the way an engineer reads them: as a statement about which failure the system has chosen to accept.

The three delivery guarantees a system can offer

Every messaging system picks one of three contracts. The choice is not about quality; it is about which failure you are willing to accept, because you have to accept one.

Table: the three delivery semantics, the failure each one tolerates, and where each shows up in practice.

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 shrug and move on. Defensible for a gauge that gets resampled in fifteen seconds, indefensible 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 price is that the destination sometimes sees the same event twice. That price is payable. Loss usually is not.

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, and no amount of engineering produces one. 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.

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 a PostgreSQL deliveries table before the 200 OK goes back, with a p99 target under 10 ms server-side. The ordering is the whole point. 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 rows with SELECT ... FOR UPDATE SKIP LOCKED, which lets many workers pull from one table without blocking each other (PostgreSQL row locking documents the semantics, and how Postgres works as a job queue shows the full schema and claim query). 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.

What the contract deliberately does not promise is uniqueness. Duplicates are not a defect here; they are the stated cost of never dropping an event.

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. Fast acknowledgement on your side is a duplicate-reduction strategy, not only a latency one.

Effectively-once: the practical target

Since the transport cannot guarantee uniqueness, the consumer has to. That is the whole trick:

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. The usual implementation records the identifier the provider assigned to the event, in a table with a unique constraint:

INSERT INTO processed_events (event_id, processed_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;

If that insert affects zero rows, you have already handled the event and can return 200 immediately. Two details decide whether this holds under load:

  1. Uniqueness must be enforced by the database, not by a SELECT followed by an INSERT. Two workers handling the same duplicate concurrently will both see “not processed yet” and both proceed. Only a unique constraint arbitrates that race.
  2. The dedupe record and the side effect belong in the same transaction. Insert the marker, commit, then create the refund, and a crash in between leaves an event marked done that was never done.

We go through the failure modes and key-selection tradeoffs in the guide on how to make handlers idempotent with a stable event id. The short version: use the provider’s own event id, never a hash of the payload alone, and never a timestamp.

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 a real engineering achievement, and a different problem from delivering an HTTP POST to a server the vendor has never seen. So check what the claim covers:

Our own position is on the record: Webhooker is at-least-once delivery on a PostgreSQL-backed queue, and we tell you to keep your consumers idempotent. That is not a smaller promise. It is the same promise with the consumer’s half of the work named out loud. 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.

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. Before you reach for it, it is worth knowing why webhooks arrive out of order and when you actually need FIFO delivery — two cheaper patterns cover most cases.

Designing for the contract

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

That list is the difference between a pipeline that occasionally double-charges someone and one where a duplicate is a non-event.

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 genuinely 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; it does not change the physics of the last hop.

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. The useful goal is not predicting the number but making it irrelevant. With a unique constraint on the event id, a duplicate rate of 0.01% and one of 2% cost you the same thing, which is nothing.