Skip to content

Delivery and retries

Webhook retry schedule

Delivery is retried at (seconds after the previous attempt): 60, 300, 1800, 7200, 43200.

Retry timing is measured from the previous attempt, not from the original event. If your endpoint returns anything other than a 2xx — including a timeout — the delivery is retried on that schedule, up to len(schedule) + 1 total attempts. Once the schedule is exhausted, the delivery simply stops being retried — its status stays failed, it is not marked dead. dead is reserved for a 410 Gone response, a terminal egress refusal (destination resolves to a non-public IP, uses a disallowed scheme, or is a blocked hostname), or the in-flight reaper reclaiming a lease-expired delivery that has no attempts left. A failed delivery that ran out of scheduled retries can still be replayed manually from the portal or the API once the subscription is active (resume it first if it was auto-paused).

Current defaults (see policy defaults for the source of truth — these can change without a docs update):

  • retry schedule: 60, 300, 1800, 7200, 43200 seconds (developer.webhook.retry_schedule_seconds)
  • developer.webhook.disable_after_failures: 50 consecutive failures
  • developer.webhook.signature_tolerance_seconds: 300 seconds

Auto-pause

After developer.webhook.disable_after_failures consecutive failed deliveries (currently 50 — see policy defaults), the subscription is paused and the app owner is e-mailed. Re-enable it from the portal once your endpoint is healthy again.

Not every failed attempt counts toward this counter — see the per-status breakdown below.

Retry semantics by response

Response Counts toward auto-pause? What happens
2xx — Delivery marked succeeded; consecutive_failures resets to 0.
429 No Always treated as a backoff signal, never a failure. If Retry-After is present, the next attempt is scheduled from it (clamped — see below); otherwise the normal schedule applies.
5xx (other than the 503 cases below) Yes Treated as an ordinary failed attempt: retried on the schedule, consecutive_failures increments.
503 with Retry-After Yes Failure count increments and the next attempt honours Retry-After (clamped). Use this to signal backpressure with a specific resume time.
503 without Retry-After Yes Treated the same as any other 5xx — retried on the schedule, no special timing.
Timeout (connect or read) Yes Treated as a failed attempt on the normal schedule.
410 Gone Yes, and terminal The delivery is marked dead immediately — no further retries for this delivery. Return 410 when you never want a given delivery retried again (e.g. you've since unsubscribed from that event server-side).
Non-public destination, blocked hostname, or disallowed scheme Yes, and terminal Same as 410 — the delivery is marked dead without further retries, since retrying can't fix a destination LuciBook refuses to connect to.

Recommendation: if your endpoint needs to shed load, prefer 429 with an explicit Retry-After — it's the only response that pauses delivery without also burning down your consecutive_failures budget toward auto-pause.

Respecting Retry-After

If your endpoint responds 429 or 503 with a Retry-After header (seconds, or an HTTP-date), LuciBook honours it, clamped into [schedule[attempt], 43200] seconds — i.e. never faster than that attempt's own scheduled wait, and never slower than 12 hours. A 429 response does not count towards the auto-pause failure counter; a 5xx or timeout does (see the table above).

Egress rules your receiver must satisfy

Your webhook URL must be a public https endpoint. LuciBook resolves and validates the destination on every delivery and challenge call — not once when the subscription is created — and refuses loopback, link-local (including the cloud metadata address 169.254.169.254), and RFC1918 private addresses, whichever IP the hostname resolves to at send time. Redirects are not followed. Requests time out after developer.webhook.timeout_seconds (currently 5 seconds, bounding connect + read together) and cap the response body at developer.webhook.response_cap_bytes (currently 64 KiB). See the receiver checklist for the full list.