> For AI assistants: read [llms.txt](https://docs.saturation.io/llms.txt) first for the complete documentation map, API contract, and endpoint Markdown files.

# Webhook events

Webhooks deliver a signed POST to your HTTPS endpoint when something changes. Register an endpoint and pick event types in [Subscribe to webhooks](/guides.md).

The catalog below is the full emitted set. Every event is in the `WebhookEvent` enum in the [OpenAPI](/api-reference.md) spec.

## Event catalog

| Event | Fires when |
|---|---|
| `transaction.created` | A transaction is created (manual or batch). |
| `transaction.updated` | A transaction or its items change. |
| `budget.changed` | Any budget change. Covers the budget, a line, phase data, a tag assign or unassign, or a rate-pack edit. |
| `purchaseOrder.created` | A purchase order is created. |
| `purchaseOrder.pending` | A PO enters `pending` (submitted into an approval flow). |
| `purchaseOrder.approved` | A PO enters `approved`. |
| `purchaseOrder.rejected` | A PO enters `rejected`. |
| `purchaseOrder.actualizing` | A PO enters `actualizing`. |
| `purchaseOrder.paid` | A PO enters `paid`. |
| `purchaseOrder.void` | A PO is voided. |
| `document.created` | A document is dropped into the workspace. |
| `document.linked` | A document is linked to another record. |
| `document.unlinked` | A document link is removed. |
| `document.deleted` | A document is deleted. |
| `incentive.added` | An incentive program is added to a project. |
| `pack.installed` | A rate pack is added to a project. |
| `pack.uninstalled` | A rate pack is removed from a project. |

> **About the `purchaseOrder.*` events.** The suffix is the exact `status` the PO moved into, set by the approval flow.

## Delivery shape

The default `thin` shape carries the changed object's id. Your handler re-fetches the object from the API.

```json
{
  "id": "evt_5d1a",
  "event": "transaction.created",
  "workspaceId": "ws_2b9d7a1f",
  "projectId": "prj_a1",
  "occurredAt": "2026-05-28T18:42:11.000Z",
  "data": { "kind": "transaction", "id": "txn_8f2a1c9e" }
}
```

`projectId` is present for project-scoped events.

## Handle a thin event

| `data.kind` | Fetch next |
|---|---|
| `transaction` | `GET /transactions/{id}` |
| `document` | `GET /documents/{id}` |
| `budgetLine` | `GET /projects/{projectId}/budget/lines/{id}` |
| budget change | `GET /projects/{projectId}/budget/totals` |

## Signature verification

Verify the signature on each delivery before you act on it. Every delivery carries four headers.

| Header | Value |
|---|---|
| `X-Saturation-Signature` | `sha256=<hex>`. |
| `X-Saturation-Timestamp` | ISO-8601 send time. |
| `X-Saturation-Delivery-Id` | Stable id for this delivery retry sequence. |
| `X-Saturation-Event` | Event type, for routing before parsing the body. |

The signature is HMAC-SHA256 of `X-Saturation-Timestamp + "." + rawBody`, keyed by the subscription's signing secret. Reject deliveries outside your timestamp tolerance window. Five minutes is a good default.

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(
  rawBody: string,
  signature: string,
  timestamp: string,
  secret: string,
  now = Date.now(),
): boolean {
  if (!signature.startsWith('sha256=')) return false;
  const sentAt = Date.parse(timestamp);
  if (!Number.isFinite(sentAt) || Math.abs(now - sentAt) > 5 * 60 * 1000) return false;

  const expected = 'sha256=' + createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

## Delivery semantics

- **At-least-once.** A delivery is attempted up to five times with exponential backoff starting at 30 seconds. Its `id` stays stable across retries, so use it to dedupe.
- **Permission-bound.** Deliveries contain ids. Re-fetch the resource so the API applies current permissions.
- **Test and inspect.** `POST /webhooks/{id}/test-delivery` sends a test delivery. `GET /webhooks/{id}/deliveries` returns recent attempts and their outcome (`pending`, `success`, `failed`, `blocked`, or `dropped`).

## Next

- [Subscribe to webhooks](/guides.md): register an endpoint and pick event types.
- [Errors](/errors.md): API request failures and retry guidance.
