# Webhooks

Polling `GET /documents` to notice a new invoice is the wrong shape for automation. Subscribe an HTTPS URL instead and BillOS POSTs you events as they happen: the trigger side of a Make / Zapier / n8n scenario ("catch webhook" / "custom webhook" modules) or of your own worker.

```bash
curl -X POST "$BASE/webhooks" -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://hook.example.com/billos", "events": ["document.issued"]}'
```

The response includes the endpoint's `secret` (`whsec_…`). Keep it; it signs every delivery. One endpoint covers every business under your key, or pass `businessId` to narrow it.

### Events

| Event | Fires when | `data` highlights |
|---|---|---|
| `document.issued` | a document is recorded, **credit notes included** (a cancellation arrives as the credit note's own `document.issued`) | `documentId`, `docType`, `docNumber`, `totalIncVat`, `partyName`, `externalRef` |
| `document.pdf_ready` | the signed מקור + העתק PDFs exist and `prints` will serve them | `documentId`, `files.{origin,copy}.{id,slug}` |
| `document.allocation_assigned` | a מספר הקצאה came back from the Tax Authority after issue | `documentId`, `allocationNumber` |
| `expense.recorded` | an expense became part of the permanent file | `expenseId`, `amountIncVat`, `supplierName` |
| `export.completed` | a מבנה אחיד generation finished | `exportRunId`, `fileId`, `recordCount` |
| `business.status_changed` | a business's standing moved: approved, suspended, returned to the queue, or paused/resumed by you | `businessId`, `approvalStatus`, `previousStatus`, `active`, `writesOpen` |

> **`business.status_changed` is the one event worth wiring before you need it.** Without it, an account we suspend is something your user discovers by getting a 403 mid-invoice; with it, `writesOpen: false` reaches your backend first and you decide what your user sees. It fires for your own `deactivate`/`reactivate` calls too, so a second integration stays in step with the one that made the change.

Payloads are **summaries**, not rows: the event says which thing changed; the full record is one authenticated GET away. Your automation platform's task log should never be where a tax document's contents live.

### The delivery

```json
{
  "id": "del_…",          // the delivery id, DEDUPE ON THIS (retries re-send it)
  "event": "document.issued",
  "mode": "live",          // or "sandbox", matches the X-BillOS-Mode header
  "businessId": "…",
  "createdAt": "2026-08-30T18:00:00.000Z",
  "data": { … }
}
```

Headers: `X-BillOS-Event`, `X-BillOS-Delivery`, `X-BillOS-Mode`, and the signature:

```
X-BillOS-Signature: t=1756576800,v1=5257a86…
```

`v1` is `HMAC-SHA256(secret, "<t>.<raw body>")`. Verify before trusting anything:

```js
import crypto from 'node:crypto'

function verify(secret, header, rawBody) {
  const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300 // 5 min replay window
  return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
}
```

Sign-verify against the **raw body bytes**: a re-serialized JSON object will not match.

### Retries, ordering, at-least-once

Answer **2xx within 10 seconds** (do the work after responding). Anything else is retried with backoff: ~1m, 5m, 30m, 2h, 12h, 24h, then the delivery is marked `failed`. An endpoint failing many times in a row is auto-disabled (`disabledAt` set); fix your side and `PATCH {"active": true}` to re-arm.

Delivery is **at-least-once and unordered**: a retry can duplicate, a slow retry can arrive after a newer event. Dedupe on the delivery `id`; don't infer state from arrival order.

`GET /webhooks/:id/deliveries` shows what was sent and what your endpoint answered, the first place to look when a scenario "didn't fire".

### Testing

`POST /webhooks/:id/test` delivers one synthetic `webhook.test` event immediately: how you wire a Make/Zapier trigger without issuing anything. And webhooks fire in the **sandbox** (section 10) exactly as in live, with `"mode": "sandbox"` in the envelope: build the whole scenario against test data, then swap the key.