← All articles

Webhook Ordering: When You Need FIFO Delivery

Webhooker Team 13 min read
Flat illustration of FIFO webhook ordering: five cards queued single file in a channel, released one at a time through a narrow neck into a blue pulse node.

Webhook ordering is not something the HTTP delivery model gives you. Webhooks arrive out of order because parallel workers, per-destination retries, and provider-side concurrency all break the original sequence. Ordered delivery fixes that by holding one in-flight delivery per ordering key, so the next event waits for the previous acknowledgement. The cost is head-of-line blocking: one slow destination stalls everything queued behind it. Ordering also does not remove duplicates, and it orders the requests you receive rather than the work you do after acknowledging them, so idempotent consumers stay mandatory.

Most teams meet this problem the hard way. An event that logically came second lands first, some piece of state gets overwritten with an older value, and the bug resists reproduction because it depends on timing nobody controls. The instinct is to reach for strict ordering. Sometimes that is right. More often a cheaper pattern solves the same problem without the throughput penalty, and it is worth knowing which situation you are in before you turn FIFO on.

Why webhooks arrive out of order

Nothing in the webhook model promises order. Each event is an independent HTTP POST. The sender fires them, the network reorders them, and the receiver has no built-in notion of sequence. Three separate layers each add their own shuffle.

The provider sends concurrently. When one action generates several events, the provider dispatches them from a pool rather than one at a time. Stripe says so plainly in its webhooks documentation: event delivery order is not guaranteed. Most large providers take the same position, because guaranteeing global order across millions of destinations would mean guaranteeing that no destination is ever slow.

Retries move events through time. A delivery that fails at 14:00:01 and finally succeeds on its fourth attempt at 14:00:15 lands after events created ten seconds later. This is not a defect in the retry policy; it is what backoff does. We wrote separately about how retries reshuffle the delivery order, and the short version is that any backoff curve is also a reordering function.

Delivery is parallel by design. In Webhooker, workers claim rows from the PostgreSQL deliveries table with SELECT ... FOR UPDATE SKIP LOCKED and forward them out of band. Ten workers pulling from the same queue will finish in whatever order the destinations respond. That parallelism is exactly what keeps a queue drained during a spike. It is also what destroys ordering.

The bug this causes

The canonical failure looks like this. A customer cancels. Your billing provider emits customer.subscription.updated and then customer.subscription.deleted a few hundred milliseconds apart. The deleted event reaches your endpoint first because it happened to get a warm worker. The updated event, carrying the pre-cancellation state, arrives a second later and writes status: active back over the row you just tombstoned.

The account is live again and nobody is billed for it. It surfaces weeks later when someone asks why a cancelled customer still has API access. Nothing in your logs looks wrong: both events were delivered, both returned 200, both were processed exactly as written. The ordering assumption was never stated anywhere, which is why it was never checked.

When ordering genuinely matters

Ordering is worth paying for when the meaning of an event depends on the events before it. Three patterns qualify:

And the honest inverse. Ordering buys you nothing when:

Be specific about which of your destinations fall into which bucket. In most systems it is one or two, not all of them.

How ordered delivery works

Ordered delivery is a constraint on concurrency, expressed per ordering key — whatever identifies the entity whose history must stay intact, usually a customer ID, a subscription ID, or an order number pulled from a field in the payload.

For each key, the webhook gateway keeps exactly one delivery in flight. Event two is not attempted until event one is acknowledged with a success status or has exhausted its retries. Different keys still run in parallel, so customer_A and customer_B move independently. Order is preserved inside a key and nowhere else.

That is the design decision that matters. A key of customer_id gives you strict ordering per customer and full parallelism across your customer base. A key of source gives you one serial pipeline for every event from that provider, which is technically ordered and practically a bottleneck. The narrower the key, the less you pay. Queue systems make the same trade: AWS documents it as the message group in SQS FIFO queues, where throughput scales with the number of distinct groups.

The guarantee stops at your door. Ordered delivery controls the sequence of HTTP requests, not the sequence in which your handler finishes them. If your endpoint writes the request to your own queue and returns 200 so background workers can pick it up, you have re-parallelised the events the gateway just serialised, and the ordering is gone before anything reaches your database. FIFO delivery becomes FIFO processing only if the handler does the ordering-sensitive work — or at least commits it — before it acknowledges.

What ordering costs

Head-of-line blocking. This is the whole bill in one phrase. If the delivery at the front of a key’s queue is slow, everything behind it waits. If it is failing, everything behind it waits through the entire backoff curve — first attempt, second, fourth, capped — before the queue moves. Retries and the per-destination circuit breaker still apply in ordered mode, which is correct behavior and also means a tripped breaker now holds a queue rather than just one delivery.

Throughput per key collapses to serial. Without ordering, a key’s events are limited by your worker pool. With ordering, they are limited by the round-trip time of your destination, one delivery at a time. We are not going to hand you a benchmark number here: the only number that matters is your own endpoint’s response time multiplied by your events per key.

Latency becomes coupled. An unordered pipeline degrades gracefully: a stuck delivery is one stuck delivery. An ordered pipeline degrades in blocks, and the p99 you care about is no longer a property of a single request. None of this makes FIFO wrong. It makes it a deliberate purchase.

The simplest alternative: refetch the resource

The cheapest fix is to stop trusting the payload. Treat each webhook as a signal that something about an entity changed, discard the body, and read the current state from the provider’s API before you act on it. Whatever order the events arrive in, the read returns the latest truth. A late customer.subscription.updated cannot resurrect a cancelled account, because it triggers a read that comes back cancelled.

This is what Stripe means when it tells you to retrieve the object rather than trust the event, and it is what most teams land on after their first ordering bug. It handles duplicates for free as well: two reads of the same resource produce the same answer, so a redelivery is harmless without any bookkeeping. We made the wider case for it in treating a webhook as a signal rather than a payload.

What you pay is one API call per event, against the provider’s rate limit. At low volume that is nothing. At high volume, or when the provider exposes no read endpoint for what the event describes, it stops being free — and that is where the next two options start earning their keep.

The middle option: version-aware consumers

If the extra read is too expensive, check whether your provider already tells you the order. Most do, through a monotonic sequence number, a version field, or an updated_at timestamp on the resource. If it does, your consumer can reject stale events itself, with no second call:

on_event(event):
    key      = event.payload.subscription_id
    incoming = event.payload.updated_at   # or a version / sequence number

    current = store.get_version(key)
    if current is not null and incoming <= current:
        return 200   # stale or duplicate, already superseded

    store.apply(key, event.payload, version=incoming)

That is the entire pattern. It runs at full parallelism, needs no coordination in the gateway, and has a property FIFO does not: it is also a duplicate filter. A redelivered event carries a version you have already applied, so it is dropped by the same comparison. This is the practical reason why ordering and duplicates are separate problems — one check happens to handle both, but the guarantees are not the same thing.

Two caveats. Timestamps are only safe if they come from the provider and have enough resolution; two events in the same millisecond are a coin flip, so prefer an explicit sequence or version field when one exists. And the pattern needs snapshot payloads, where the newest event contains everything you need. If your events are deltas that must all be applied, discarding the stale one loses data, and you are back to needing real ordering.

Choosing between the three

Table: the three ways to survive out-of-order webhooks, and what each one asks of you.

QuestionRefetch on eventVersion-aware consumerOrdered delivery (FIFO)
Payload shape it needsNone, the body is discardedFull-state snapshotsDeltas or transitions
Provider must supplyA readable endpoint for the resourceA sequence, version, or updated_atNothing extra
Throughput per keyUnbounded, plus one API call eachUnbounded by orderingSerial, one delivery in flight
Effect of a slow destinationThat delivery onlyThat delivery onlyBlocks the whole key
Handles duplicatesYes, the read is idempotentYes, same checkNo — still at-least-once
Where the work livesYour consumer and the provider’s APIYour consumer codeGateway configuration
Plan requiredAnyAnyTeam

Read it left to right and stop at the first column that works. If you can absorb the extra read, refetching is the least machinery for the strongest correctness. If you cannot, but your payloads carry full state and a version, the version check costs nothing at runtime. Only when every event must be applied, and applied in sequence, do you need the queue — and by then you are buying head-of-line blocking deliberately rather than discovering it.

Ordered delivery on Webhooker

Ordered delivery is available on the Team plan — it is not part of Free or Pro, and it is one of the few webhook features that is genuinely tier-gated rather than metered, which is worth knowing when you compare pricing models across vendors. It is configured per destination, so you can serialize the one endpoint that runs a state machine while the rest of your destinations keep delivering in parallel at full speed. That granularity is the point: turning FIFO on globally means paying head-of-line blocking for traffic that never needed ordering.

Everything else stays as it is. Delivery remains at-least-once, retries and the circuit breaker still apply, and the dead-letter queue still catches exhausted deliveries with their full per-attempt history. Ordering constrains when a delivery is attempted; it does not change how many times it may succeed. Pair it with an idempotent consumer, always. The surrounding per-destination delivery controls — transformations, header injection, outbound signing, rate limits — are the same on every plan, so you can start routing events on the free tier before deciding whether you need ordered delivery on the Team plan.

Frequently asked questions

Why do webhooks arrive out of order?

Because three layers each reorder them independently. Providers dispatch events from a worker pool rather than one at a time, so two events created milliseconds apart can leave in either sequence. Retries move a failed event forward in time, past events created after it. And gateways deliver in parallel, so ten workers finish in whatever order the destinations respond. None of this is a bug: guaranteeing global order would mean guaranteeing that no destination is ever slow. Stripe says so in its own webhook documentation, and most large providers take the same position.

Does ordered delivery guarantee no duplicates?

No. Ordering and deduplication are different guarantees. Webhooker delivers at-least-once, which means a delivery whose acknowledgement is lost in transit will be retried and may be processed twice, ordered or not. What ordering guarantees is that event two is not attempted before event one has finished — not that event one is attempted only once. Your consumer still needs to be idempotent, keyed on a stable event identifier, exactly as it would be without ordering enabled.

Does ordered delivery mean my handler processes events in order?

Only if your handler does the work before it responds. Ordered delivery serializes the requests we send: request two is not dispatched until request one is acknowledged. If your endpoint’s answer to request one is to push it onto your own queue and return 200 straight away, your background workers pick both events up concurrently and the ordering you paid for is gone before either one is written. Either do the ordering-sensitive part synchronously, or carry the ordering key into your own queue and serialize on it there.

What happens when a destination is down?

The queue for that ordering key stops. The failing delivery retries with exponential backoff, and every event behind it waits for that curve to finish. If the per-destination circuit breaker trips, deliveries are held until it closes again. Once the delivery exhausts its attempts it moves to the dead-letter queue and the next event proceeds, so the queue is never blocked permanently. This is head-of-line blocking working as designed, and it is the main reason to scope ordering keys narrowly.

Can I order some destinations and not others?

Yes. Ordered delivery is a per-destination setting on the Team plan, not a workspace-wide switch. A typical setup serializes the destination that drives subscription or order state and leaves analytics, Slack notifications, and search indexing running in parallel. Since one inbound event can fan out to several destinations, and fan-out is not billed separately, this costs you nothing extra — the ordered destination simply moves at its own pace while the others keep up with the queue.