# Webhooks

Snakkbar can POST every event in a tenant (a new conversation, a request for a person, a callback
request, a message, an erased visitor, …) to your own HTTPS endpoints as it happens. Use it to
create leads in a CRM, ping a Slack channel, or mirror conversations into your own system.

- Up to 10 endpoints per tenant. Each chooses **all events** (including types added later) or a
  list of types.
- Every request is **signed** with the endpoint's secret (`Snakkbar-Signature`, HMAC-SHA256).
- Failed deliveries are **retried** for about 8.5 hours; an endpoint that fails every attempt for
  a day is **switched off** with a visible reason.
- Endpoints must be public HTTPS URLs; private, loopback and link-local addresses are refused.

## Managing endpoints

**Admin UI.** Settings → *Webhooks* (admins only): add an endpoint (URL, description, events), see
its state, switch it on or off, edit it, rotate its secret, delete it, *Send test event*, and the
delivery log (the last 100 deliveries: time, event, status, response code, duration, next retry,
the payload and the receiver's response, with *Resend*).

**Your backend** (secret API key, `Authorization: Bearer sk_snakkbar_…`):

| Request | Body | Response |
| --- | --- | --- |
| `GET /server/webhooks` | | `{ endpoints: WebhookEndpoint[] }` |
| `POST /server/webhooks` | `{ url, description?, events?, enabled? }` | `201 { endpoint, secret }` |
| `PATCH /server/webhooks/:id` | any of `{ url, description, events, enabled }` | `{ endpoint }` |
| `DELETE /server/webhooks/:id` | | `204` |
| `POST /server/webhooks/:id/rotate-secret` | | `{ endpoint, secret }` |

The admin UI uses the same operations under `/operator/webhooks` (admin operators), plus
`GET /operator/webhooks/:id/deliveries`, `POST /operator/webhooks/:id/deliveries/:deliveryId/resend`
and `POST /operator/webhooks/:id/test`. In the SDK: `operatorClient.webhooks.*`.

`events: []` (or omitted) means every event type. The full `secret` (`whsec_…`) is returned only on
creation and rotation; afterwards only `secretPrefix` is shown. Re-enabling an endpoint that was
switched off resets its failure count.

## The request

```http
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Snakkbar-Webhooks/1.0 (+https://snakkbar.app/docs/webhooks)
Snakkbar-Signature: t=1792152000,v1=5f2b…c9
Snakkbar-Event-Id: evt_10432
Snakkbar-Event-Type: callback.created
Snakkbar-Delivery-Id: 7d3e…
Snakkbar-Delivery-Attempt: 1

{
  "id": "evt_10432",
  "type": "callback.created",
  "tenantId": "amatec",
  "createdAt": "2026-10-17T09:20:00.000Z",
  "data": {
    "callbackId": "…",
    "conversationId": "…",
    "name": "Kari Nordmann",
    "phone": "+47 900 00 000",
    "email": null,
    "message": "Ring etter kl. 12",
    "by": "assistant",
    "visitor": { "id": "…", "externalId": "crm-10422", "name": "Kari Nordmann", "email": "kari@firma.no", "phone": null, "company": "Firma AS", "attributes": {} }
  }
}
```

Answer with any `2xx` within **5 seconds**. Anything else (another status, a redirect, a timeout,
a connection error) counts as a failure. Do slow work after answering (queue it). The response
body is stored (first 2000 characters) for the delivery log, so keep it short.

### Events

`data` is the event's data as typed in `SnakkbarEvent` (`packages/sdk/src/types.ts`), plus
snapshots added for integrators, since there is no API to look these up later:

| Type | When | `data` |
| --- | --- | --- |
| `conversation.created` | A visitor (or operator) started a conversation | `conversationId, visitorId, pageUrl, by` + `visitor` |
| `conversation.requested` | The visitor or the assistant asked for a person | `conversationId, operatorIds, by` + `visitor` |
| `conversation.closed` | The conversation was closed | `conversationId, operatorId` + `visitor` |
| `message.created` | Any new message (visitor, assistant, operator, system, and internal notes with `kind: "note"`, which are staff-side) | `conversationId, messageId, role, kind, authorOperatorId` + `visitor`, `message: { body, authorName, language, createdAt }` |
| `callback.created` | A callback request was registered | `callbackId, conversationId, name, phone, email, message, by` + `visitor` |
| `conversation.routed` | The conversation moved to a department (or out of one) | `conversationId, departmentId, by, operatorId` + `visitor` |
| `conversation.assigned` | Round-robin made an operator the owner | `conversationId, operatorId, departmentId` + `visitor` |
| `note.mentioned` | An operator @-mentioned colleagues in an internal note (staff-side) | `conversationId, messageId, operatorIds, by` + `visitor` |
| `rating.created` | The visitor gave an answer a thumbs up/down or rated the conversation (also on a change) | `conversationId, target, messageId, thumbs, score, comment, updated` + `visitor` |
| `visitor.updated` | The visitor's profile changed | `visitorId` + `visitor` |
| `visitor.deleted` | Everything about the visitor was erased ([privacy.md](privacy.md)) | `visitorId, externalId, by` (nothing else) |
| `webhook.test` | *Send test event* | `endpointId, message` |

`visitor` is `{ id, externalId, name, email, phone, company, attributes }` as it was when the event
was fanned out, and is present whenever the event concerns a visitor or one of their
conversations. New event types may appear; endpoints on "all events" receive them, so ignore types
you do not handle. Fields are only ever added, never renamed or removed.

**Idempotency.** `id` (also in `Snakkbar-Event-Id`) is the same on every retry and resend. Store
the ids you have processed and skip repeats. **Ordering** is not guaranteed (a retry can arrive
after a later event); use `createdAt` when order matters.

`message.created` fires for every message, including the assistant's and system notices (`kind`
`joined`, `requested`, `closed`, …); filter on `role`/`kind`.

## Verifying the signature

`Snakkbar-Signature: t=<unix seconds>,v1=<hex>`: `v1` is the HMAC-SHA256 of `"<t>.<raw body>"`
with the endpoint's secret as the key. During the 24 hours after a secret rotation there are two
`v1` values (new and old secret); accept the request if either matches. Verify against the **raw
body bytes** (before JSON parsing), compare in constant time, and refuse timestamps older than 5
minutes to stop replays.

```js
// Node.js (Express shown; any framework that gives you the raw body works)
import { createHmac, timingSafeEqual } from "node:crypto";

function verifySnakkbar(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = header.split(",").map((part) => part.trim().split("="));
  const t = Number(parts.find(([key]) => key === "t")?.[1]);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return parts.some(([key, value]) => key === "v1" && /^[0-9a-f]{64}$/.test(value) && timingSafeEqual(Buffer.from(value, "hex"), expected));
}

app.post("/hooks/snakkbar", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifySnakkbar(req.body.toString("utf8"), req.get("Snakkbar-Signature") ?? "", process.env.SNAKKBAR_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  res.sendStatus(204); // answer first, then work
  queue.add(event);
});
```

The same in Python: `hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()`
compared with `hmac.compare_digest`.

**Rotating a secret** without downtime: *New signing secret* (or `rotate-secret`) → deploy the new
secret to your receiver within 24 hours → the old one stops signing by itself.

## Retries and switching off

Each event is one *delivery* per endpoint. The first attempt is made right away; a failed attempt
is retried after **1 min, 5 min, 30 min, 2 h and 6 h** (6 attempts in total). After the last one the
delivery is marked *failed*; *Resend* in the log makes one more attempt at any time (a delivery that
is still waiting is simply sent now and keeps its remaining retries).

An endpoint whose attempts fail **15 times in a row over at least 24 hours** is switched off
(`enabled: false`, `disabledReason: "failures"`); its waiting deliveries are marked failed with
`endpoint_disabled`. A single success resets the count. Admins see the reason in Settings; fix the
receiver, *Send test event*, and switch it back on. Test events never count towards this.

Error codes in the log (`WebhookDelivery.error`): `http_<status>`, `timeout`, `network`, `dns`,
`blocked_address`, `endpoint_disabled`, `endpoint_deleted`.

The delivery log keeps 30 days (the admin shows the last 100 per endpoint).

## Network rules

- `https://` only; no credentials in the URL; no single-label, `localhost`, `.local`, `.internal`
  and similar hosts.
- The host must resolve only to public addresses. Private (10/8, 172.16/12, 192.168/16, fc00::/7),
  loopback, link-local (169.254/16 incl. cloud metadata, fe80::/10), carrier-grade NAT, multicast,
  documentation and other reserved ranges are refused, also as IPv4-mapped IPv6. The check runs
  when the URL is saved and again inside the DNS lookup of every connection, so a host cannot
  change its address to an internal one later (DNS rebinding).
- Redirects are not followed.

## How it works (for maintainers)

The event outbox (`events`, see `server/events.ts`) hands every event to the `webhooks` handler
(`server/webhooks/index.ts`), which snapshots the payload into one `webhook_deliveries` row per
matching endpoint and sends them straight away. The cron `GET /api/v1/internal/webhooks` (every
minute, `CRON_SECRET`) sends what is due and prunes old logs. Signing, the retry schedule, the
switch-off rule and the address checks are pure functions in `server/webhooks/core.ts`
(`npm run test:server`); the HTTPS client with the checked DNS lookup is `server/webhooks/send.ts`.
