Good Webhooks

Delivery guarantees

At least once, no ordering. What a successful publish promises, how retries work, and how endpoint changes affect queued work.

The rules

  1. Accepted means stored. When publish() returns, the event and its recipients are committed (or written in your transaction, committed when you commit).
  2. 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.
  3. No ordering. Events can arrive in any order. Retries, parallel workers, and replays all reorder.
  4. Recipients are fixed at publish. Subscription changes affect later events only.
  5. 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

  1. pendingWaiting for a worker, or for its next retry time.
  2. in_flightA worker holds it and is sending the request.
  3. succeeded / failed / cancelled

    A 2xx; a permanent failure or retries used up; or the endpoint was deleted.

Delivery statuses

What happens after each attempt depends on the response:

ResponseResult
2xxsucceeded
Network error, 408, 425, 429, 5xxBack to pending until the next retry time
Any other 4xxfailed, no retry
3xxfailed. Redirects are never followed

Retry schedule

By default a delivery gets up to seven attempts within 24 hours:

Attempt1234567
Waitsnow1 s5 s30 s2 m10 m30 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:

ChangeEffect on queued deliveries
PauseHeld until resume, or until the delivery passes its age limit
ResumeEligible deliveries send again
DeletePending work is cancelled when the worker sees it. Secrets are cleared
Change URLThe next attempt uses the new URL
Change subscriptionsLater events only. Already accepted recipients stay
Rotate signing secretThe 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.

On this page