Webhooks
Signed thin events: the poke, then you fetch.
Bridge notifies your systems about changes with thin, signed events: the payload tells you what happened to which resource — you fetch current state through the API. This "poke, then fetch" model is deliberate: payloads never carry entity bodies, so a leaked delivery leaks references, not books, and you always render from fresh state.
Delivery contract — read this before building
- At-least-once. The same event can arrive more than once (retries, redeliveries).
Dedupe on the event
id— process each id once, respond 2xx to duplicates. - Unordered. Events can arrive out of order. Never reconstruct state from event sequence; fetch the resource instead.
- Retries: non-2xx responses are retried on an exponential schedule with jitter —
roughly 30s, 2m, 10m, 30m, 1h, 3h, 8h — 8 attempts across ~24 hours, then the
delivery is parked as
dead. - Auto-disable: after 3 consecutive dead deliveries the endpoint is disabled with a
recorded reason. Inspect
GET …/webhooks/{id}and the delivery log, fix your receiver, then re-enable by updating the endpoint (a URL change re-runs verification). - Self-debugging:
GET …/webhooks/{id}/deliverieslists every attempt with status code, error, and the exact payload. - Respond 2xx quickly (ack, then process async). Timeouts (10s) count as failures.
Event payload
{
"id": "9a3f6d0e-8c2b-4d51-b1a7-2f4e5d6c7b8a",
"type": "company.created",
"occurred_at": "2026-08-20T12:34:56.789Z",
"data": {
"resource_type": "company",
"resource_id": "b1c2d3e4-…",
"organization_id": "a0b1c2d3-…"
}
}
Event types mirror the audited actions, e.g. company.created, company.updated,
company.disabled, company.deleted, company.restored, access_policy.created,
access_policy.assigned, access_policy.revoked, webhook_endpoint.created,
job.succeeded, job.failed. Subscribe to specific types at registration, or to
everything with an empty filter (note: an all-events endpoint also receives its own
webhook_endpoint.* lifecycle events).
Registration handshake
A new endpoint starts in pending_verification. Bridge POSTs a
webhook.verification event carrying a challenge:
{ "type": "webhook.verification", "data": { "challenge": "f1ac40a6…" } }
Respond 2xx with the challenge echoed anywhere in the response body (e.g.
{"challenge": "f1ac40a6…"}). The endpoint then turns active and events flow.
Changing the endpoint's URL re-runs the handshake.
Signature verification
Every delivery carries:
Webhook-Signature: t=<unix-seconds>,v1=<hex-hmac-sha256>
where v1 = HMAC-SHA256(secret, "<t>" + "." + <raw request body>), hex-encoded.
The secret (whsec_…) is returned exactly once when you create the endpoint or
rotate the secret.
Verify like this:
- Parse
tandv1from the header. - Reject if
|now − t|exceeds your tolerance (5 minutes is a good default) — this defeats replay of captured deliveries. - Compute
HMAC-SHA256(secret, t + "." + body)over the raw body bytes (before any JSON parsing) and compare tov1with a constant-time comparison.
Test vector
| field | value |
|---|---|
| secret | whsec_testsecret |
| t | 1700000000 |
| body | {"id":"evt_00000000-0000-4000-8000-000000000001","type":"company.created"} |
| signature | t=1700000000,v1=c9be8761917e51cda36abc9ae6eaa0d6cb5c4a0874f22dd6efb6c3655b6ff018 |
(Asserted by WebhookSignerTest — the vector is a wire contract.)
Pseudocode:
import hmac, hashlib, time
def verify(header: str, body: bytes, secret: str, tolerance_s: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = int(parts["t"]), parts["v1"]
if abs(time.time() - t) > tolerance_s:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
Endpoint requirements
- https only, resolving to a public address (loopback/private/link-local targets are rejected). The sandbox relaxes this for local testing.
- Redirects are not followed — a 3xx counts as a failed delivery.