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: a non-2xx response or a timeout is retried with jitter 2m, 10m, 30m,
1h, 3h, 8h and 11h after the failed attempt — 8 attempts over roughly 24
hours — then the delivery is parked as
dead. - Auto-disable: after 3 consecutive dead deliveries the endpoint is
disabledwith a recorded reason, and every delivery still queued for it is cancelled (never sent — events that occur while an endpoint is disabled are not held for it either). InspectGET …/webhooks/{id}and the delivery log, fix your receiver, then re-enable it: a disabled endpoint is re-enabled only by aPATCHthat changesurl, which re-runs verification. APATCHwhoseurlis unchanged (renaming, changing the event filter) neither re-enables nor re-verifies. - Deleting an endpoint cancels its queued deliveries — nothing is sent to a URL after its endpoint is gone.
- Self-debugging:
GET …/webhooks/{id}/deliveriesis the delivery log: one row per delivery (one event to one endpoint), not one per attempt, withstatus(pending | delivered | dead; a cancelled delivery isdeadwithlastErrorstarting withcancelled:and nolastStatusCode),attempts,lastStatusCode,lastError, the exactpayload, anddeliveredAt. - Respond 2xx quickly (ack, then process async). Timeouts (10s) count as failures. Bridge reads at most 64 KiB of your response body; keep responses small (an empty body is fine outside the verification handshake).
- Where deliveries go. Your URL's host must resolve only to public addresses; Bridge checks the addresses it actually connects to on every delivery (not a separate lookup), refuses a host with any private, loopback or reserved address, and never follows redirects.
Event payload
{
"id": "9a3f6d0e-8c2b-4d51-b1a7-2f4e5d6c7b8a",
"type": "company.created",
"occurredAt": "2026-08-20T12:34:56.789Z",
"data": {
"resourceType": "company",
"resourceId": "b1c2d3e4-…",
"organizationId": "a0b1c2d3-…"
}
}
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). New event types may appear at any time — ignore types you do not know.
Event catalog
Event types mirror the audited actions. This is the complete list the API emits today:
| Type | Resource | When |
|---|---|---|
company.created |
company | POST /organizations/{organizationId}/companies |
company.updated |
company | PATCH /companies/{companyId} |
company.disabled |
company | POST /companies/{companyId}/actions/disable |
company.enabled |
company | POST /companies/{companyId}/actions/enable |
company.deleted |
company | DELETE /companies/{companyId} (soft delete) |
company.restored |
company | POST /companies/{companyId}/actions/restore |
access_policy.created |
access policy | policy created |
access_policy.updated |
access policy | policy or its rules changed |
access_policy.archived |
access policy | POST …/access-policies/{policyId}/actions/archive |
access_policy.assigned |
access policy | a user was assigned the policy |
access_policy.revoked |
access policy | an assignment was revoked |
document.uploaded |
document | an upload slot was completed (POST /documents/{documentId}/actions/complete) |
document.renamed |
document | PATCH /documents/{documentId} |
document.deleted |
document | DELETE /documents/{documentId} |
document.restored |
document | POST /documents/{documentId}/actions/restore |
reconciliation.created |
reconciliation | a GL line was reconciled against a bank transaction |
reconciliation.deleted |
reconciliation | a match was undone |
webhook_endpoint.created |
webhook endpoint | endpoint registered |
webhook_endpoint.updated |
webhook endpoint | endpoint patched |
webhook_endpoint.deleted |
webhook endpoint | endpoint deleted |
webhook_endpoint.secret_rotated |
webhook endpoint | POST …/webhooks/{webhookId}/actions/rotate-secret |
import.requested |
import | an import was accepted (202); its completion arrives as job.succeeded / job.failed for the job the import names |
correction.requested |
import | a deletion (correction) was accepted (202); completion as above |
job.succeeded |
job | a job finished successfully |
job.failed |
job | a job failed — including an import settled as failed because its job expired or crashed |
webhook.verification is not an event type you subscribe to: it is the handshake
below, sent once per new or re-pointed endpoint.
Registration handshake
A new endpoint starts in pending_verification. Bridge POSTs a
webhook.verification event carrying a challenge (signed like every delivery):
{ "type": "webhook.verification", "data": { "challenge": "f1ac40a6c8e44f5b9e2d7a31b0c65d18" } }
Respond 2xx with the challenge's HMAC in the response body: the lowercase hex
HMAC-SHA256(secret, challenge), keyed with the endpoint's signing secret (the whole
whsec_… string, as for signatures) over the challenge string. Anywhere in the body
works; we suggest:
{ "challengeResponse": "2aacbbe8c5fcbb8b1428b009fa873d65e747b56eab9fb0c432c152c4604aae20" }
That is the answer for secret whsec_testsecret and the challenge above — use it as a
test vector. The body is read up to 64 KiB. Echoing the challenge itself is not
enough: the answer proves that whoever owns the URL also holds the secret, so a URL that
merely reflects requests cannot be registered. The endpoint then turns active and
events flow. Changing the endpoint's URL re-runs the handshake; a PATCH that leaves the
URL as it is does not.
import hashlib, hmac, json
def answer_challenge(body: bytes, secret: str) -> bytes:
challenge = json.loads(body)["data"]["challenge"]
digest = hmac.new(secret.encode(), challenge.encode(), hashlib.sha256).hexdigest()
return json.dumps({"challengeResponse": digest}).encode()
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 and unique-local IPv6 targets are rejected, at registration and again at send time). This holds in every environment, the sandbox included — there is no insecure mode. For local testing, put a public https tunnel in front of your receiver.
- Redirects are not followed — a 3xx counts as a failed delivery.
- Responses are read up to 64 KiB; anything beyond is ignored.