Accountflow Developer next

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

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:

  1. Parse t and v1 from the header.
  2. Reject if |now − t| exceeds your tolerance (5 minutes is a good default) — this defeats replay of captured deliveries.
  3. Compute HMAC-SHA256(secret, t + "." + body) over the raw body bytes (before any JSON parsing) and compare to v1 with 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