Hopp til innholdet

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_…):

RequestBodyResponse
GET /server/webhooks{ endpoints: WebhookEndpoint[] }
POST /server/webhooks{ url, description?, events?, enabled? }201 { endpoint, secret }
PATCH /server/webhooks/:idany of { url, description, events, enabled }{ endpoint }
DELETE /server/webhooks/:id204
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:

TypeWhendata
conversation.createdA visitor (or operator) started a conversationconversationId, visitorId, pageUrl, by + visitor
conversation.requestedThe visitor or the assistant asked for a personconversationId, operatorIds, by + visitor
conversation.closedThe conversation was closedconversationId, operatorId + visitor
message.createdAny 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.createdA callback request was registeredcallbackId, conversationId, name, phone, email, message, by + visitor
conversation.routedThe conversation moved to a department (or out of one)conversationId, departmentId, by, operatorId + visitor
conversation.assignedRound-robin made an operator the ownerconversationId, operatorId, departmentId + visitor
note.mentionedAn operator @-mentioned colleagues in an internal note (staff-side)conversationId, messageId, operatorIds, by + visitor
rating.createdThe 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.updatedThe visitor's profile changedvisitorId + visitor
visitor.deletedEverything about the visitor was erased (privacy.md)visitorId, externalId, by (nothing else)
webhook.testSend test eventendpointId, 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.