# Webhooks

Webhooks let Boya push events to your server as they happen, so you don't have to poll.
Each delivery is **signed** so you can verify it came from Boya and was not tampered
with.

## 1. Register an endpoint

Create a webhook endpoint from **Settings → Webhooks** in the dashboard, or via the API:

```bash
curl -X POST https://api.boyahq.com/v2/webhooks \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/boya/webhooks",
    "events": ["expense.created", "payment.completed", "bill.paid"]
  }'
```

The response includes a **signing secret** (prefix `whsec_`). **It is shown once** —
store it securely; you'll use it to verify signatures.

## 2. Receive events

Boya sends an HTTP `POST` with a JSON body and these headers:

| Header | Description |
|  --- | --- |
| `Boya-Event-Id` | Unique event id (use for idempotency) |
| `Boya-Event-Type` | e.g. `payment.completed` |
| `Boya-Signature` | `t=<unix-seconds>,v1=<hex HMAC-SHA256>` |


Example body:

```json
{
  "id": "evt_1a2b3c",
  "type": "payment.completed",
  "created": 1754899200,
  "data": { "payment_ref": "PMT-2026-000123", "amount": 15000, "currency": "KES" }
}
```

## 3. Verify the signature

The signature is `HMAC-SHA256(secret, "{timestamp}.{raw_request_body}")`, hex-encoded.
Compute it over the **raw** body (before JSON parsing) and compare in constant time.

**Node.js**

```js
const crypto = require("crypto");

function verifyBoyaWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(signatureHeader.split(",").map(p => p.split("=")));
  const timestamp = parts.t;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 || "");
  const valid = a.length === b.length && crypto.timingSafeEqual(a, b);

  // Reject old timestamps to prevent replay.
  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= toleranceSec;
  return valid && fresh;
}
```

**Python**

```python
import hashlib, hmac, time

def verify_boya_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance=300) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    signed = f"{parts['t']}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        return False
    return abs(time.time() - int(parts["t"])) <= tolerance
```

Return `2xx` quickly once verified. Do heavy work asynchronously.

## Delivery semantics

- **At-least-once.** The same event may arrive more than once — **deduplicate on
`Boya-Event-Id`**.
- **Retries.** Non-`2xx` responses (or timeouts) are retried with exponential backoff
for up to ~24 hours, then the endpoint is flagged in the dashboard.
- **Replay protection.** Reject deliveries whose `t` is outside a tolerance window
(5 minutes recommended).
- **Ordering** is not guaranteed; use `created` / resource state to reconcile.


## Event catalogue (initial)

| Event | Fired when |
|  --- | --- |
| `expense.created` | A new expense is recorded |
| `expense.approved` | An expense is approved |
| `expense.rejected` | An expense is rejected |
| `transaction.created` | A new ledger transaction is posted |
| `bill.created` | A bill is created |
| `bill.paid` | A bill is fully paid |
| `payment.completed` | A payout completes *(private label)* |
| `payment.failed` | A payout fails *(private label)* |
| `card.transaction.created` | A card transaction is authorized *(private label)* |


Events for private-label domains are only delivered to entitled clients. The full,
up-to-date list is in the dashboard when you select events for an endpoint.