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, 43200seconds (developer.webhook.retry_schedule_seconds) developer.webhook.disable_after_failures: 50 consecutive failuresdeveloper.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.