Skip to content

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 address 169.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 3xx responses; a redirect is treated as a failed attempt.
  • Respond within the timeout. See developer.webhook.timeout_seconds in 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:

  1. LuciBook appends a challenge query parameter to your subscribed url — using & if the URL already has a query string, or starting a new query string with challenge= if it doesn't — and issues an HTTPS GET to that URL.
  2. Your receiver must respond HTTP 200 with 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 \n is fine, it's stripped) passes.
  3. The response body is capped at 200 bytes — a larger correct-looking body that pushes the token past that cap will not verify.
  4. 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.