Skip to content

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.

{
"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.

GET /api/events?after=48000&types=invoice.*,payment_run.*&limit=500

Returns { 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.

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 POST with the event as the JSON body, in seq order, 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/:id shows recent deliveries and how far behind the subscription is.

Headers on each delivery:

HeaderValue
x-spend-eventThe event type
x-spend-delivery<subscription id>.<seq>; use it to drop duplicates
x-spend-signaturet=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">
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.