Delivery guarantees
At least once, no ordering. What a successful publish promises, how retries work, and how endpoint changes affect queued work.
The rules
- Accepted means stored. When
publish()returns, the event and its recipients are committed (or written in your transaction, committed when you commit). - At least once. The worker keeps trying each delivery until it succeeds, fails for good, or runs out of retries. A receiver may get the same event more than once.
- No ordering. Events can arrive in any order. Retries, parallel workers, and replays all reorder.
- Recipients are fixed at publish. Subscription changes affect later events only.
- Endpoint state is read fresh. URL, secrets, and pause or delete take effect at the next attempt.
Receivers handle rules 2 and 3 by processing each event.id once.
A delivery's life
- pendingWaiting for a worker, or for its next retry time.
- in_flightA worker holds it and is sending the request.
- succeeded / failed / cancelled
A
2xx; a permanent failure or retries used up; or the endpoint was deleted.
What happens after each attempt depends on the response:
| Response | Result |
|---|---|
2xx | succeeded |
Network error, 408, 425, 429, 5xx | Back to pending until the next retry time |
Any other 4xx | failed, no retry |
3xx | failed. Redirects are never followed |
Retry schedule
By default a delivery gets up to seven attempts within 24 hours:
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| Waits | now | 1 s | 5 s | 30 s | 2 m | 10 m | 30 m |
Change it with retry: { delaysMs, maxAgeMs } on createWebhooks() or createDelivery(). Every retry keeps the event ID and body and gets a fresh timestamp and signature.
Publication
matchRecipients() runs once, when you publish. It returns every subscribed endpoint in the scope, paused ones included. The recipient list is stored with the event and never recomputed.
- If endpoint management can't be reached,
publish()throws. An outage never turns into "no recipients". - Endpoint edits made at the same moment may or may not be seen. There is no scope-wide snapshot.
Idempotency
Publishing again with the same scope and idempotencyKey returns the original event with duplicate: true, without looking up recipients again. That works even if management storage is down. The same key with a different type or payload throws IDEMPOTENCY_CONFLICT.
Keys stay reserved until the event is pruned. If you need deduplication for longer, keep your own record.
Endpoint changes while work is queued
The worker reads current endpoint state before every attempt:
| Change | Effect on queued deliveries |
|---|---|
| Pause | Held until resume, or until the delivery passes its age limit |
| Resume | Eligible deliveries send again |
| Delete | Pending work is cancelled when the worker sees it. Secrets are cleared |
| Change URL | The next attempt uses the new URL |
| Change subscriptions | Later events only. Already accepted recipients stay |
| Rotate signing secret | The next attempt signs with the current secrets (both, during the overlap) |
A request that was already being sent when you paused or deleted can still arrive. Management calls can't recall it.
If endpoint management is unreachable at attempt time, the delivery waits without using up an attempt, and the worker reports the error. An outage is never treated as a deletion.
Transactions
publish(input, { transaction }) writes inside your PostgreSQL transaction, in a savepoint, so a failed publish doesn't break it. It covers delivery storage only. Finding recipients is a separate read, so a Better Auth or second-database write is not part of the commit. See publish inside your transaction.