Webhooks and event stream
Everything that happens in Spend is an event on the tenant’s audit ledger: invoice.received, invoice.approved, payment_run.released, bid.rejected_late and hundreds more. You can have them pushed to you or pull them.
Event shape
Section titled “Event shape”{ "id": "evt_ten_acme_48211", "seq": 48211, "type": "invoice.approved", "at": "2026-10-28T17:02:11.418Z", "entity": { "kind": "invoice", "id": "inv_..." }, "related": [{ "kind": "purchase_order", "id": "po_..." }], "actor": { "type": "user", "id": "usr_..." }, "data": { "...": "..." }, "hash": "9f2c..."}seq increases by one for every event in the tenant, so it is a reliable cursor. hash is the event’s position in the hash chain. Values whose names look sensitive (account, routing, TIN, secret, token, password) are replaced with [redacted] before they leave Spend.
Pull: the event stream
Section titled “Pull: the event stream”GET /api/events?after=48000&types=invoice.*,payment_run.*&limit=500Returns { events, cursor, head, more }, oldest first. Store cursor and pass it as after next time; keep reading while more is true. Type patterns take * or a prefix such as invoice.*. The caller needs audit.view.
Push: webhooks
Section titled “Push: webhooks”An administrator with integration.admin manages subscriptions on Administration › Integrations, or through the API:
POST /api/webhooks{ "name": "Data warehouse", "url": "https://hooks.example.gov/spend", "events": ["invoice.*", "payment_run.released"], "fromSeq": 48000 }The response includes the signing secret, shown only once. POST /api/webhooks/:id with { "rotateSecret": true } issues a new one; { "active": false } pauses delivery.
Delivery rules:
- Only public
https://URLs are accepted; private and loopback addresses are refused. - Each event is one
POSTwith the event as the JSON body, inseqorder, up to 50 per subscription per round. - Any 2xx counts as delivered. Anything else is retried with backoff (1, 2, 5, 15, 30, 60, 120, 240 minutes).
GET /api/webhooks/:idshows recent deliveries and how far behind the subscription is.
Headers on each delivery:
| Header | Value |
|---|---|
x-spend-event | The event type |
x-spend-delivery | <subscription id>.<seq>; use it to drop duplicates |
x-spend-signature | t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>"> |
Verify the signature
Section titled “Verify the signature”import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, header, toleranceSec = 300) { const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? ""); if (!m) return false; if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSec) return false; const expected = createHmac("sha256", secret).update(`${m[1]}.${rawBody}`).digest("hex"); return timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));}Verify against the raw request body, before parsing it.