Webhooks¶
Webhooks let your app react to changes without polling. A subscription is
per application (webhooks:manage scope + the can_use_webhooks
capability), with an https url, a list of event_types, and a secret used
to sign every delivery.
Thin events¶
Every delivered event is deliberately minimal — no business data:
{
"id": "evt_…",
"type": "bill.created",
"org_id": "…",
"resource_type": "bill",
"resource_id": "…",
"occurred_at": "2026-09-22T10:00:00Z"
}
Fetch the actual resource with your own access token once you've received the event. Events are only emitted for organisations where your app holds an active connection.
Setting up a receiver¶
Your endpoint must satisfy all of the following, on every request LuciBook sends it (challenge and delivery alike):
- https only. Plain http and any other scheme are refused outright.
- No private, loopback, link-local, or metadata-service destination.
This includes RFC1918 ranges,
127.0.0.0/8,169.254.0.0/16(which covers the cloud metadata address169.254.169.254), and equivalent IPv6 ranges. LuciBook re-resolves and re-validates the destination IP on every single delivery and every challenge call — not once when you first save the URL, and not just at connect time for a given attempt. This closes DNS-rebinding: a hostname that resolves to a public IP at save time but a private IP at delivery time is refused at delivery time, every time. - No redirects. LuciBook does not follow
3xxresponses; a redirect is treated as a failed attempt. - Respond within the timeout. See
developer.webhook.timeout_secondsin policy defaults — this bounds the whole call (connect + read), not just the socket. - Request and response body caps. The delivery request body itself is
the thin event payload (small). Your response body is read only up to
developer.webhook.response_cap_bytes(see policy defaults); anything beyond that is discarded. The one-time verification challenge response is capped much lower — see below.
Challenge / ownership verification¶
Before a subscription goes active, LuciBook sends a one-time ownership
challenge:
- LuciBook appends a
challengequery parameter to your subscribedurl— using&if the URL already has a query string, or starting a new query string withchallenge=if it doesn't — and issues an HTTPSGETto that URL. - Your receiver must respond
HTTP 200with a body equal to the raw challenge token, once whitespace is stripped from both ends. Nothing else — no JSON wrapper, no extra text.{"challenge": "<token>"}or a trailing newline-plus-log-line both fail; only the bare token (trailing\nis fine, it's stripped) passes. - The response body is capped at 200 bytes — a larger correct-looking body that pushes the token past that cap will not verify.
- Any non-200 status, a body that doesn't match, or an egress refusal
(private IP, wrong scheme, timeout, …) leaves the subscription
unverified; the portal surfaces the reason via
last_challenge_error(e.g.status_500,body_mismatch,egress:non_public) and you can retry with the "(re-)send challenge" action.
See Signatures to verify every delivery, and Delivery and retries for the retry schedule and auto-pause behaviour.
Delivery headers¶
Every delivery (including ping) carries these headers:
| Header | Meaning |
|---|---|
X-LuciBook-Signature |
t=<unix-ts>,v1=<sig>[,v1=<sig>...] — see Signatures. |
X-LuciBook-Event-Id |
The event's stable id (also present as id in the JSON body). Dedupe on this, not on the delivery id. |
X-LuciBook-Event-Type |
e.g. bill.created — matches the body's type. |
X-LuciBook-Delivery-Id |
This delivery attempt sequence's id. An automatic retry (timeout, 5xx, 429, …) reuses the same X-LuciBook-Delivery-Id — only X-LuciBook-Attempt increments. A replay you (or an operator) trigger from the portal/API creates a new delivery row with a new X-LuciBook-Delivery-Id, but the same X-LuciBook-Event-Id. |
X-LuciBook-Attempt |
1-based attempt number for this delivery. |
Dedupe on the event id, never the delivery id. A portal/API replay of a
delivery resends the same event under a fresh delivery id — if you key
your idempotency store on X-LuciBook-Delivery-Id instead of
X-LuciBook-Event-Id, a replay will look like a brand-new event and you'll
process it twice.
Error shape¶
Failures from this surface are NOT the platform error envelope — they are a
bare {"error": <code>} body with an optional details string:
{ "error": "invalid_url", "details": "scheme" }
| Status | error code |
Meaning |
|---|---|---|
| 400 | invalid_url |
the url is not a valid, publicly-routable https URL |
| 400 | invalid_event_type |
event_types contains an unknown or malformed value |
| 400 | duplicate_url |
this app already subscribes an identical url |
| 403 | webhooks_restricted |
your app's integration profile lacks the can_use_webhooks capability — creating a subscription is refused until webhooks access is enabled for the app |
| 404 | not_found |
the subscription or delivery id doesn't exist, or belongs to another app — a mismatched id always 404s, never 403, so ids from other apps aren't confirmed to exist |
| 409 | webhook_limit_reached |
the app has reached its subscription limit |
| 409 | subscription_not_active |
an action that requires an active subscription (e.g. replaying a delivery) was attempted while it is paused, pending verification, or disabled |
| 409 | delivery_in_progress |
you tried to replay a delivery that is still pending or in_flight |
| 409 | subscription_not_paused |
you tried to resume a subscription that isn't currently paused |
| 409 | resume_requires_staff |
resuming this paused subscription requires staff/operator action (LuciBook backoffice), not the app's own token |
| 409 | subscription_not_verified |
you tried to resume a subscription that was paused before it ever completed ownership verification |
Auth failures on this surface (missing/invalid token, missing
webhooks:manage scope) return the standard 401/403 platform auth
error, not this bare shape — see the general errors guide.
See Events for the full catalogue.