# How delivery works

A webhook is not a fire-and-forget HTTP call. Every event is written to a queue first, delivered from there, and retried on a schedule that spans three days.

## The path an event takes

<Mermaid chart={`
sequenceDiagram
    autonumber
    participant P as Payment
    participant Q as Event queue
    participant W as Delivery worker
    participant You as Your endpoint

    P->>Q: payment.success recorded
    Note over Q: Durable. Survives<br/>a restart on our side.
    W->>Q: Claim next due delivery
    W->>You: POST with signature
    alt 2xx
        You-->>W: 200 OK
        W->>Q: Mark delivered
    else non-2xx, or timeout
        You-->>W: 500 / no answer
        W->>Q: Schedule retry with backoff
        Note over W,Q: Up to 13 attempts<br/>across ~72 hours
    end
`} />

The queue is the important part. The event is durable *before* anyone tries to deliver it, so an event is never lost because a delivery attempt happened at an unlucky moment.

## The retry schedule

Thirteen attempts, spanning roughly 72 hours:

| Attempt | When |
| --- | --- |
| 1 | Immediately |
| 2 | +1 minute |
| 3 | +5 minutes |
| 4 | +30 minutes |
| 5 | +2 hours |
| 6–8 | +4h, +8h, +12h |
| 9–13 | every 12 hours out to +72h |

Tight early because most failures are transient — a deploy, a restart, a blip. Sparse later because an endpoint that has been down for a day is not coming back in the next minute, and hammering it costs us both.

Each delay carries ±10% jitter. When an endpoint recovers, everything queued behind it would otherwise fire in the same instant and knock it straight back over.

**Your endpoint can be down for a full working day and still receive everything.** The window is sized for that on purpose: a deploy, an incident or an overnight outage should cost you latency, never events.

## What "at least once" asks of you

We guarantee delivery *at least* once, not *exactly* once. Exactly-once delivery is not something anyone can offer over a network; the honest engineering is to deliver repeatedly and let you deduplicate.

So your handler must be idempotent. Deduplicate on the event id, or drive fulfilment off a state transition your own database owns:

```js
const changed = await db.payments.updateOne(
    { paymentId, status: { $ne: "SUCCESS" } },
    { $set: { status: "SUCCESS" } },
);
if (changed.modifiedCount === 1) {
    await fulfilOrder(paymentId);   // exactly once, whatever we deliver
}
```

## What your endpoint should do

**Answer `2xx` fast.** Any `2xx` is success; anything else is a retry. Verify the signature, write the event down, return. Do the slow work afterwards.

**Do not fulfil inline.** If shipping an order takes eight seconds, you will time out, we will retry, and you will ship twice. Acknowledge, then process from your own queue.

**Do not trust the body without checking the signature.** The URL is public. See [verifying signatures](/en/payments/webhooks).

**Return `2xx` for events you do not care about.** A `404` on an event type you have not implemented looks like an outage and burns all 13 attempts.

## Things that surprise people

**Order is not guaranteed.** A retried `payment.success` can arrive after a later event. Your state machine should reject backwards transitions rather than assume arrival order.

**A `3xx` is not success.** We do not follow redirects on delivery. Give us the final URL.

**Slow is failure.** An endpoint that takes 30 seconds to answer is treated as down, because from our side it is indistinguishable from down.

**Manual resend exists.** If you lost events during an outage that outlasted the window, resend from the dashboard rather than reconciling by hand.

## Next steps

- [Webhooks overview](/en/payments/webhooks) — the event catalogue and signature verification
- [Payment states](/en/concepts/payment-states) — making fulfilment exactly-once
- [How a payment works](/en/concepts/how-payments-work) — why webhooks exist at all
