DokumentasjonWebhooksMarkdown
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
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) | 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.
// 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,.internaland 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.