Register an endpoint in Settings → Webhooks and Amolfi POSTs you a signed JSON body when the business moves. Thirty-one events exist, in twelve families, and every one of them fires from real code — registration rejects any name that does not.
The catalog
deal · contact · client · project created / updated
invoice created / paid
contract sent / signed / declined / expired
run waiting_approval / completed / failed / cancelled
booking created / updated / cancelled
member invited / joined / removed
domain verified
apikey created / revoked
file available / held / updated / archivedSome absences are deliberate, so you do not sit waiting for them. There is no invoice.overdue: overdue is derived at read time from an invoice’s due date and status, so there is no moment to fire on — compute it from the payloads you already get. Imports and backfills never fire events, so nobody floods your endpoint by loading history. And invoice.paid fires only on the terminal transition to fully paid, never on a partial payment.
The envelope
{ "event": "invoice.paid", "timestamp": "<ISO-8601>", "data": { "id": "…" } }CRM-family payloads carry the entity’s fields. Every other family is an explicit allow-list projection — thin identity and state, chosen field by field, so internals never leave the workspace by accident.
- invoice — numbers, status, currency, amounts, client, dates. No notes, no metadata.
- contract — identity and state only. No signer names or emails; fetch detail through the API.
- run — id, status, title, summary, and the decision id when one is waiting.
- booking — identity and the time window. No attendees, description, or location.
- member — ids, status, and role. No email address, in any form — not plaintext, not hashed. Resolve identity with an authenticated roster read, where your own permissions apply.
- domain — the domain, its purpose, and when it verified.
- apikey — the key’s public id and status. Never the key, its hash, or any prefix bytes.
- file — identity, lifecycle status, content type, byte size, rights state, and scan verdict. Never names, descriptions, tags, content, storage coordinates, or scanner threat names.
Delivery is at-least-once
Retries ride a queue, and every attempt is ledgered with a dedupe check taken *before* the POST, so an acknowledged delivery is not re-sent. Even so, treat your handler as idempotent: dedupe on the X-Amolfi-Delivery header for exact retries, and on (event, data.id) if your handler must act strictly once per business moment.
Verifying the signature — read this part carefully
Every delivery carries three headers: X-Amolfi-Event, X-Amolfi-Delivery, and the signature.
X-Amolfi-Signature: sha256=<hex hmac>The HMAC key is not your raw `whsec_…` secret. Amolfi never stores that secret — you are shown it once and only its SHA-256 is kept — so the key both sides share is the hex digest of your secret, and that is what you must HMAC with. Every subscriber that skips this step sees valid deliveries as forgeries.
signing_key = sha256_hex(raw_whsec_secret) // the hex digest STRING
expected = "sha256=" + hmac_sha256_hex(signing_key, raw_request_body)
valid = timing_safe_equal(expected, X-Amolfi-Signature)In Node:
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, whsecSecret) {
const signingKey = createHash('sha256').update(whsecSecret).digest('hex');
const expected = `sha256=${createHmac('sha256', signingKey).update(rawBody).digest('hex')}`;
const a = Buffer.from(expected);
const b = Buffer.from(header || '');
return a.length === b.length && timingSafeEqual(a, b);
}Three rules that go with it: HMAC the raw request bytes, not an object you re-serialized — re-serializing changes them. Always compare in constant time. And reject anything unsigned or mis-signed; a delivery that fails this check is not from Amolfi.