> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vantr.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive signed, retried event deliveries and verify they came from Vantr.

# Webhooks

Webhooks push events to your server as they happen, so you don't have to poll. When something
changes in a tenant — a bill is created, an order is fulfilled, stock runs low — Vantr
sends an HTTPS `POST` to the endpoints you've registered.

Every delivery is **signed** with a per-endpoint secret so you can prove it came from us and was
not tampered with, and deliveries are **retried with backoff** so a brief outage on your side
doesn't drop events.

## Register an endpoint

Register endpoints in the developer portal under **Webhooks**, or programmatically with the
`/v2/webhooks` API (scopes `webhooks.read` / `webhooks.write`):

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X POST https://api.vantr.ai/v2/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.yourapp.com/vantr",
    "events": ["invoice.created", "invoice.paid", "order.fulfilled"]
  }'
```

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "success": true,
  "webhook": {
    "id": "5f1c…",
    "url": "https://hooks.yourapp.com/vantr",
    "events": ["invoice.created", "invoice.paid", "order.fulfilled"],
    "active": true,
    "signed": true,
    "secret": "whsec_9b2f…"
  }
}
```

<Warning>
  The signing **secret is returned only once**, on create. Store it securely — you'll need it to
  verify deliveries. If you lose it, rotate it with `PATCH /v2/webhooks/{id}` (sending a new
  `secret`) and update your server. Omit `events` (or send `[]`) to subscribe to everything.
</Warning>

Endpoints must be **public HTTPS URLs**. Plain `http://`, `localhost`, and private / internal /
link-local hosts are rejected at save time, and re-checked (with DNS resolution) before every
delivery to prevent SSRF.

## The request we send

```http theme={"theme":{"light":"github-light","dark":"github-dark"}}
POST /vantr HTTP/1.1
Content-Type: application/json
X-Webhook-Id: 3a0e8b6c-…              ← unique per delivery; use it to dedupe
X-Webhook-Event: invoice.created
X-Webhook-Signature: t=1719360000,v1=4f1e…  ← HMAC, see below

{
  "id": "3a0e8b6c-…",
  "event": "invoice.created",
  "tenant_id": "…",
  "timestamp": "2026-06-25T12:00:00.000Z",
  "data": { … }
}
```

## Verify the signature

`X-Webhook-Signature` has two parts:

* `t` — the unix timestamp (seconds) when we signed the delivery.
* `v1` — `HMAC_SHA256(secret, "{t}.{rawBody}")` as hex.

To verify a delivery:

1. Read the **raw request body** (the exact bytes — don't re-serialize a parsed object).
2. Parse `t` and `v1` from `X-Webhook-Signature`.
3. Recompute `HMAC_SHA256(secret, "{t}.{rawBody}")` and **constant-time compare** it to `v1`.
4. Reject deliveries whose `t` is outside your tolerance window (we recommend ±5 minutes) to
   prevent replay.

```js theme={"theme":{"light":"github-light","dark":"github-dark"}}
const crypto = require('crypto');

function verifyVantrWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((kv) => kv.split('='))
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!Number.isFinite(t) || !v1) return false;

  // Replay protection
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;

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

// Express — capture the RAW body for verification:
app.post('/vantr', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verifyVantrWebhook(raw, req.get('X-Webhook-Signature'), process.env.WEBHOOK_SECRET)) {
    return res.status(400).send('bad signature');
  }
  const event = JSON.parse(raw);
  // Dedupe on event.id (X-Webhook-Id) — deliveries are at-least-once.
  res.sendStatus(200); // ack fast; do the work asynchronously
  // … enqueue event for processing …
});
```

## Delivery, retries, and idempotency

* **Acknowledge fast.** Respond `2xx` within \~10 seconds. Any non-`2xx` (or a timeout, connection
  error, or redirect — we do **not** follow redirects) is treated as a failed delivery.
* **Retries.** Failed deliveries are retried with exponential backoff and jitter (≈30s, 1m, 2m, …
  up to \~1h) for several attempts over a few hours, then marked dead. The retried request is
  **byte-identical** (same `X-Webhook-Id`, same body, same signature).
* **At-least-once.** A delivery can arrive more than once (e.g. you `2xx`'d but we didn't see it).
  Make your handler idempotent by deduping on `X-Webhook-Id`.
* **Order is not guaranteed.** Use the `timestamp` / your own state to resolve ordering if it
  matters.

## Events

| Event                       | Fires when                                      |
| --------------------------- | ----------------------------------------------- |
| `invoice.created`           | A bill (AP invoice) is created                  |
| `invoice.updated`           | A bill is updated                               |
| `invoice.paid`              | A bill is marked paid                           |
| `vendor.created`            | A vendor is created                             |
| `vendor.updated`            | A vendor is updated                             |
| `inventory.low_stock`       | An item drops to/below its reorder point        |
| `inventory.count_completed` | An inventory count is approved                  |
| `order.created`             | An order is created (e.g. a POS sale completes) |
| `order.updated`             | An order is invoiced/updated                    |
| `order.fulfilled`           | An order is fulfilled                           |

Subscribe to specific events with the `events` array, use a `prefix.*` wildcard (e.g.
`invoice.*`), or subscribe to everything by omitting `events`.
