Skip to content
Developer

Outbound webhooks

HTTP delivery of collection events with HMAC signing, replay-safe signatures, durable retry, and an auto-disable circuit breaker.

A webhook POSTs a JSON payload to your endpoint whenever a matching event fires (item created/updated/deleted, auth events, file events, …). Delivery is durable — every dispatch is a webhook.deliver job, so a failing receiver is retried with exponential backoff and dead-lettered rather than lost.

All webhook management is admin-only and workspace-scoped. The admin lives at Webhooks in the sidebar; the API is under /api/webhooks.

POST /api/webhooks
{
"name": "Slack #content",
"url": "https://api.example.com/webhooks/backlex",
"events": ["items:posts:created", "items:*:deleted"],
"secret": "whsec_…",
"headers": { "Authorization": "Bearer …" } // optional custom headers
}

Event patterns are colon-separated and match <channel>:<event> — the channel for a collection is items:<slug>, so a create on posts is items:posts:created. * stands in for one segment (items:*:created matches every collection’s create), and a prefix matches everything under it (items alone matches every item event). A pattern spelled with dots is a single segment: it never matches, the hook is created without complaint, and Test still passes — the test send skips matching by design — so nothing tells you until the deliveries page stays empty.

The payload body is:

{ "channel": "items:posts", "event": "created", "data": { }, "deliveredAt": "" }

By default data is the whole row. A hook that only needs an id and a status to kick off a job somewhere else is also handed the customer’s address, the internal note, and whatever column got added last week — to a third-party endpoint, over the public internet, indefinitely, because nobody revisits a webhook after it starts working.

payloadFields is a per-hook allow-list of top-level data keys:

POST /api/webhooks
{
"name": "Fulfilment",
"url": "https://api.example.com/orders",
"events": ["items:orders:created"],
"payloadFields": ["id", "status", "updated_at"]
}
{ "channel": "items:orders", "event": "created",
"data": { "id": "", "status": "paid", "updated_at": "" },
"deliveredAt": "" }
  • Omit it, or send null, for the whole row. That is the default, so no existing hook changes shape.
  • An empty list also means the whole row, not an empty body — a cleared list must not silently blank every delivery.
  • It is an allow-list, deliberately. A field added to the collection next month does not start flowing to an endpoint that was configured before it existed; you add it when you mean to.
  • A listed key the row doesn’t have is simply absent — never an explicit null, which a receiver would read as “this was cleared”.
  • Only the top level is projected. A named object field is sent whole.
  • Non-object payloads (system events whose data is an array or a scalar) pass through untouched — there are no keys to choose from.
  • The body is built per hook, so two hooks on the same event can receive different payloads, and each signature covers the body that hook actually received.

Also on Webhooks → edit → Fields to send in the admin, payloadFields in GraphQL and MCP, and --fields id,status on backlex webhooks create.

When a hook has a secret, every delivery is signed. Three headers travel with each request:

HeaderValue
X-Backlex-TimestampUnix seconds when the delivery was signed.
X-Backlex-SignatureHMAC-SHA256(secret, body) — the legacy scheme.
X-Backlex-Signature-V2HMAC-SHA256(secret, "{timestamp}.{body}")replay-safe.

Prefer V2: because the timestamp is part of the signed content, a receiver can reject a captured-and-replayed request by checking that the timestamp is recent. The legacy X-Backlex-Signature is still sent unchanged so existing receivers keep working.

The SDK ships a constant-time verifier that runs anywhere Web Crypto is available (Workers, Node 18+, Bun, Deno):

import { verifyWebhook } from "backlex/webhook";
// inside your receiver (raw body — do NOT re-stringify a parsed object):
const body = await req.text();
const ok = await verifyWebhook({
secret: process.env.BACKLEX_WEBHOOK_SECRET!,
body,
signature: req.headers.get("x-backlex-signature-v2")!,
timestamp: req.headers.get("x-backlex-timestamp")!,
// toleranceSec: 300 // default; set 0 to disable the freshness check
});
if (!ok) return new Response("bad signature", { status: 401 });

Omitting timestamp falls back to verifying the legacy X-Backlex-Signature over the bare body (no replay window). verifyWebhook returns false — never throws — on any missing input, stale timestamp, or mismatch.

Content-Type and the X-Backlex-* headers are reserved; custom headers you configure can’t override them.

  • Retry is automatic: a non-2xx (or a network failure) requeues the webhook.deliver job with exponential backoff until maxAttempts, then dead-letters. See Job queue.
  • Replay a past delivery from the admin (or POST /api/webhooks/_deliveries/{id}/retry) — re-sends with the original headers + signature.
  • Test fires a synthetic webhook.test event (POST /api/webhooks/{id}/test or the Send test button) so you can confirm DNS/auth without waiting for a real event.

The Recent deliveries panel shows status, latency, and event per attempt.

A dead endpoint shouldn’t burn the queue forever. Each hook tracks consecutive_failures — bumped on every failed delivery attempt, reset to 0 on any 2xx. Once it crosses the threshold (15 consecutive failures) the hook is auto-disabled:

  • active flips to false and disabled_reason records why (surfaced as an auto-disabled badge on the Webhooks page).
  • A broadcast notification is sent to admins, and an audit row (webhook.auto_disabled) is written.
  • New events stop enqueuing and any in-flight job becomes a terminal no-op, so the receiver gets a break instead of an unbounded retry storm.

Resume clears the breaker: toggling the hook back to active (the Resume action, or PATCH /api/webhooks/{id} with { "active": true }) resets consecutive_failures to 0 and clears disabled_reason for a clean slate.

consecutiveFailures, lastFailureAt, and disabledReason are returned on every GET /api/webhooks row so you can monitor hook health programmatically.

Every attempt writes a webhook_deliveries row carrying a truncated response body, and nothing pruned them before — an active integration wrote this table forever. The daily cron tick now deletes attempts older than WEBHOOK_DELIVERIES_RETENTION_DAYS (default 30). Set it to 0 to keep them.

This one is instance-wide rather than per-workspace, and not because that is simpler: webhook_deliveries carries no tenant_id column, so a per-workspace policy is not expressible against it.