Webhooks
The full event catalogue, delivery headers, and how to verify a payload's signature.
See Integrate webhooks for how to register an endpoint and what delivery behavior (retries, auto-disable) to expect. This page covers what you receive and how to trust it.
Headers on every delivery
| Header | Contents |
|---|---|
Brault-Signature | t=<unix timestamp>,v1=<hex HMAC> — see verification below. |
Brault-Webhook-Id | The endpoint that received this delivery. |
Brault-Event-Id | Matches id in the payload — the deduplication key. |
Brault-Event-Type | One catalogue token, e.g. file.created. |
Brault-Delivery-Id | This specific delivery attempt's record id. |
Brault-Delivery-Attempt | 1–6. |
X-Request-Id | Correlates this delivery with the platform request log. |
User-Agent | Brault-Webhooks/1.0 |
Content-Type | application/json; charset=utf-8 |
Content-Length | Byte length of the exact signed body — never chunked transfer. |
Verifying the signature
The signed material is <t>.<raw request body> — the exact bytes you received, before
any re-serialization. Read the raw body first; don't verify against JSON.stringify of a
parsed object, since key order or whitespace differences would silently break every
signature.
Brault-Signature: t=1725609600,v1=5257a869e7bfbe86...- Split the header on
,, then split each part on the first=. - Reject the delivery if
|now − t| > 300seconds (5 minutes) — this bounds replay of an intercepted request. - Compute
hex(hmac-sha256(secret, "<t>.<raw body>")). - Accept if it matches any
v1entry present. There is normally exactly one — but for 24 hours after you roll a webhook's secret, deliveries carry twov1entries (current secret first, then the previous one), so that you can pick up the new secret at your own pace without dropping events mid-rotation.
This bash version is a debugging aid for a quick terminal check, not a template for
production code: it doesn't compare in constant time, and the secret is briefly visible on
the command line (ps) while it runs. For production verification, use the JavaScript or
Python implementation below.
#!/usr/bin/env bash
# Usage: verify.sh <raw-body-file> "<Brault-Signature header value>" <webhook-secret>
body_file="$1"
signature_header="$2"
secret="$3"
t=$(echo "$signature_header" | grep -oE 't=[0-9]+' | head -1 | cut -d= -f2)
now=$(date +%s)
age=$(( now - t ))
[ "${age#-}" -gt 300 ] && { echo "signature too old" >&2; exit 1; }
expected=$(openssl dgst -sha256 -mac HMAC -macopt "key:$secret" -binary \
< <(printf '%s.' "$t"; cat "$body_file") \
| od -An -tx1 | tr -d ' \n')
echo "$signature_header" | grep -oE 'v1=[0-9a-f]+' | cut -d= -f2 | grep -qx "$expected" \
&& echo "valid" \
|| { echo "invalid" >&2; exit 1; }The event envelope
Every delivery's body looks like this:
{
"id": "evt_c0ffee…",
"object": "event",
"type": "file.created",
"occurred_at": "2026-09-05T10:15:00.000Z",
"brandspace_id": "clx…",
"actor": { "type": "user", "id": "clu…" },
"data": { "object": "file", "id": "clf…", "name": "hero.psd", "library_id": "clw…", "folder_id": null },
"attempt": 1
}data is a minimal snapshot (ids and names, not the full resource) — re-fetch the
resource from the API if you need more. actor.type is one of four values: user (the
common case, including events caused by your own key's requests — the key acts as its
creator), api_key (only on webhook.test and the two import events), system (an
automated cause with no person behind it — crons, cascades, the media-processor callback —
always id: null), or anonymous (an unauthenticated visitor: a shared-link visitor or a
transfer recipient, always id: null) — see
Integrate webhooks for what this means for
de-duplicating your own writes.
Event catalogue
events: ["*"] subscribes to every event below, including any a later version adds — the
catalogue only ever grows additively. Fetch it live (any scope will do — the route still
needs a key) at GET /v1/events, or see the Meta reference for the
exact shape of each row.
Files (10)
file.created, file.updated, file.processed, file.moved, file.trashed,
file.restored, file.deleted, file.version.created, file.version.activated,
file.version.deleted
Folders and libraries (9)
folder.created, folder.updated, folder.moved, folder.trashed, folder.restored,
folder.deleted, library.created, library.updated, library.deleted
Boards and properties (13)
board.created, board.updated, board.deleted, board.file.added,
board.file.removed, board.property.created, board.property.updated,
board.property.deleted, board.file.property_updated, property.created,
property.updated, property.deleted, property.value_updated
Comments (8)
comment.created, comment.updated, comment.resolved, comment.reopened,
comment.deleted, reply.created, reply.updated, reply.deleted
Pages (5)
page.created, page.updated, page.published, page.unpublished, page.deleted
page.updated means the page's content was saved — a page rename, tag change, move
or restore arrives as the corresponding file.* event instead, with data.file_type: "canvas" telling you it was a page.
Sharing and members (6)
shared_link.created, shared_link.updated, shared_link.deleted, member.added,
member.removed, member.role_changed
Imports (2)
import.completed, import.failed — report the terminal state of a
POST /v1/files/import job. A successful import also produces a normal file.created;
the two are not the same signal and both fire.
Transfers (4)
transfer.created, transfer.ready, transfer.downloaded, transfer.expired — the life of
a transfer link. transfer.ready is the one that says the zip finished and zip_url now
resolves; transfer.expired fires whenever the transfer stops being available, whether it
expired on its own, was ended early, or its zip could not be built. A transfer you ended
early with DELETE gets transfer.expired twice: once at the call and once more when the
nightly cleanup removes the row, with a different event id each time, so dedupe on data.id.
transfer.downloaded carries actor.type: "anonymous" for a recipient with no account, which
is the usual case.
Platform (1)
webhook.test — the synthetic event POST /v1/webhooks/:id/test sends.