DokumentasjonHTTP-APIMarkdown
HTTP API reference (v1)
Base URL: https://<host>/api/v1. JSON request and response bodies (content-type: application/json) unless noted. Times are ISO 8601. Response shapes are the TypeScript types in
packages/sdk/src/types.ts (named in bold below).
Authentication
| Caller | Header | Token |
|---|---|---|
| Visitor (anonymous) | Authorization: Bearer sbv_… | From POST /visitors. Long-lived; keep in localStorage. |
| Visitor (identified) | Authorization: Bearer <jwt> | From POST /server/visitor-tokens (your backend). ≤ 24 h. |
| Operator (embedded) | Authorization: Bearer <jwt> | From POST /server/operator-tokens (your backend). ≤ 24 h, default 1 h. |
| Operator (hosted panel) | Authorization: Bearer <supabase access token> + X-Snakkbar-Tenant: <id> | Supabase Auth session. Operators your backend provisions can have one too: see Panel sign-in for provisioned operators. |
| Your backend | Authorization: Bearer sk_snakkbar_… | Secret API key from the admin. Never in a browser. |
Origins. Browser calls (visitor endpoints, and operator endpoints with a JWT) are rejected with
403 origin_not_allowed unless the Origin is in the tenant's allowed origins (or localhost outside
production). CORS preflight is answered for any origin; the check happens on the real request.
Errors
{ "error": { "code": "rate_limited", "message": "For mange meldinger. Vent litt og prøv igjen." } }| Status | Codes |
|---|---|
| 400 | bad_request |
| 401 | unauthorized (missing/expired/invalid token) |
| 403 | forbidden (role), origin_not_allowed |
| 404 | not_found |
| 409 | conflict (e.g. someone else owns the conversation) |
| 413 | payload_too_large |
| 429 | rate_limited (with Retry-After when known) |
| 500/503 | internal, unavailable |
Public
GET /tenants/:tenantId/config → PublicConfig
No auth, any origin, cached 15 s. Assistant name and portrait, brand colour, launcher label,
welcome text ({ no, en }), suggestions, nearby/online team (team[] with handle, name,
avatarUrl, online, nearby, greeting), humanAvailable, openNow, openingHours,
callbackEnabled, languages, nearbyMinutes, liveVisitors (the tenant's live-visitor switch;
when false, send no activity).
POST /auth/sign-in-code → { sent: true }
No auth. Body { email }. Password-free staff sign-in (the mobile app; /panel/login does the same):
mails a one-time link to the hosted panel and a code, if the address belongs to an active operator
or an existing Snakkbar login. The answer is the same either way. Sign in with the code through
Supabase Auth, verifyOtp({ email, token, type: "email" }). 400 for an invalid address, 429
after 5 per hour per address or 30 per hour per IP.
Visitor
POST /visitors
Body { tenantId, language? } → { visitorId, token }. Creates an anonymous visitor. No auth;
origin checked; 120 per IP per 10 minutes (generous because, with live visitors on, every new
browser that opens a page gets one, and offices share an IP).
GET /visitor/me → VisitorProfile
{ id, externalId, verified, name, email, phone, company, attributes, language }. verified is
true for identified visitors (token from POST /server/visitor-tokens).
PATCH /visitor/me → VisitorProfile
The site tells Snakkbar who the visitor is, from the browser. Body VisitorProfileInput, all fields optional:
| Field | Type | |
|---|---|---|
name | string ≤ 120 | |
email | string ≤ 200 | Must be a valid address. |
phone | string, 3–40 | Digits, spaces and + ( ) . / - only. |
company | string ≤ 120 | |
attributes | object | Your own fields, merged into the stored ones. Keys [A-Za-z0-9_.-]{1,40}; values string ≤ 200, number or boolean; null removes the key (anonymous visitors only, see below). At most 30 keys in total. |
An empty string or null clears a field; a field left out is unchanged. 400 bad_request for an
invalid email or phone, or more than 30 attributes. 20 updates per visitor per minute.
The browser is not trusted. For an anonymous visitor the values are stored as sent. For an
identified visitor, name, email, phone and company are only written while the stored
field is still empty, so what your backend sent with the token request wins (and the browser
cannot clear it). The same goes for attributes: the browser may add keys that don't exist yet,
but not change or remove existing ones. Staff see the profile on the
visitor and in its conversations (visitor and conversation doorbells ring), and the assistant
uses it so it doesn't ask for details it already has; it is told that browser-sent details of an
anonymous visitor are unconfirmed.
POST /visitor/activity → ActivityResult
Live visitors: one heartbeat or live sample batch from a tab. The SDK's startTracking() (and so
the stock widget) sends these for you; implement it yourself only if you can't use the SDK.
| Field | Type | |
|---|---|---|
tabId | string, 4–40 | Random id per tab (page load), so several tabs are told apart. |
url | string ≤ 4000 | location.href. http(s) only (400 otherwise). Cleaned before storage, see security.md. |
title | string ≤ 300 | document.title. |
pageview | boolean | true on the first report of a page: a load, or a URL change in a single-page app. Adds a row to the navigation history. |
referrer | string ≤ 4000 | With pageview: document.referrer (or the previous URL in a single-page app). Stored as origin + path. |
visible | boolean, required | document.visibilityState === "visible". The visible tab owns the visitor's current page. |
viewport | { width, height } | CSS px. |
scroll | { x, y } | scrollX, scrollY. |
documentHeight | integer | document.documentElement.scrollHeight. |
samples | PointerSample[] ≤ 120 | Only while watched: pointer positions since the previous batch. |
clicks | PointerSample[] ≤ 30 | Only while watched: clicks since the previous batch. |
A PointerSample is [t, x, y]: milliseconds since the batch's first sample (0–60000), and
viewport (client) coordinates in CSS px.
Response { watch, interval }. watch: true: an operator is watching; send a batch about every
500 ms until it turns false. Otherwise send the next heartbeat after interval ms (10000).
{ watch: false, interval: 0 } (status 200): the tenant has live visitors off; stop sending.
600 requests per visitor per minute, shared by all its tabs.
A heartbeat after more than 30 minutes of silence starts a new session. Snakkbar then records the user agent (device type, browser, OS), the session's referrer (only when it is another site) and, when the hosting provides geo headers, country and city.
GET /visitor/conversations → { conversations: VisitorConversation[] }
This visitor's conversations, newest first (max 30).
POST /visitor/conversations → VisitorConversationDetail
Body { pageUrl?, pageTitle? }. pageUrl (here and on messages) is cleaned like live-visitor URLs
before it is stored (security.md).
GET /visitor/conversations/:id?language=no → VisitorConversationDetail
Latest 100 messages, with translation into language (default: the visitor's language).
POST /visitor/conversations/:id/messages → text/event-stream
Body:
| Field | Type | |
|---|---|---|
body | string ≤ 1500 | Required unless attachments are sent. |
clientId | string ≤ 64 | Optional idempotency key; echoed on the stored message. Retrying with the same id never duplicates. |
attachmentIds | string[] ≤ 4 | From the attachments endpoint. |
mentions | string[] ≤ 3 | Operator handles the visitor tagged or tapped. |
pageUrl, pageTitle | string | Where the visitor is now. |
language | string | Visitor's language. |
The response streams TurnEvents as Server-Sent Events:
event: message
data: {"type":"message","message":{…the stored visitor message…}}
event: assistant.start
data: {"type":"assistant.start"}
event: assistant.status
data: {"type":"assistant.status","label":"Søker etter produkter"}
event: assistant.delta
data: {"type":"assistant.delta","text":"Vi har tre "}
event: assistant.done
data: {"type":"assistant.done","messages":[{…text…},{…"kind":"products"…}]}Or, when the assistant stays quiet: silent with reason: "human" (an operator owns the
conversation) or "requested" (the visitor asked for a person). An error event ends a failed
turn. The stream closes after the final event.
POST /visitor/conversations/:id/attachments → Attachment
multipart/form-data with file: JPEG, PNG, WebP or GIF (checked by content, not extension).
Snakkbar accepts up to 5 MB, but the hosting caps request bodies at 4.5 MB, so keep uploads under
4 MB. The SDK's upload() re-encodes large photos to a 1600 px JPEG for you. Send the returned
id in attachmentIds within 24 hours.
POST /visitor/conversations/:id/read
Body { through } (time of the newest message seen) → { visitorReadAt }.
POST /visitor/conversations/:id/typing
Body { typing: boolean } → 204. Send at most every 2.5 s.
POST /visitor/conversations/:id/request-human
Body { handles? } → { requested: OperatorRef[], humanAvailable }. When humanAvailable is
false, offer a callback.
POST /visitor/conversations/:id/callback
Body { name, phone?, email?, message? } (phone or email required) → { ok: true }.
GET /visitor/stream?conversationId= → text/event-stream
RealtimeEvents for this visitor's conversations, plus presence and watch (an operator
started or stopped watching this visitor). See realtime.md.
Operator
GET /operator/me
→ { operator: Operator, tenant: TenantSettings, tenants: { id, name }[] }.
PATCH /operator/me → Operator
Body (all optional): { displayName, greeting: { no, en }, language, available }.
available: true = checked in.
POST /operator/me/avatar → Operator
multipart/form-data file (JPEG/PNG/WebP ≤ 2 MB, square recommended).
POST /operator/heartbeat → 204
Keeps the operator online for 60 s. Send every 25 s while your UI is open.
GET /operator/team → { operators: Operator[] }
GET /operator/conversations?filter=all|open|mine&limit=30&cursor=
→ { conversations: OperatorConversation[], nextCursor }, newest activity first. open = not
closed; mine = you participate or were asked for.
GET /operator/conversations/:id?before=<messageId> → OperatorConversationDetail
50 messages per page (older pages with before), translated into your language.
POST /operator/conversations/:id/actions → OperatorConversation
Body { action }:
| Action | Effect |
|---|---|
join | Become a participant (and owner if there is none). Status → human. |
takeover | Become the owner. |
release | Give ownership to the next participant. If nobody else is in the conversation, it goes back to the assistant (ai) and you leave it. |
leave | Stop participating (releases first if you own it). |
close / reopen | Close or reopen. |
POST /operator/conversations/:id/messages
Body { body ≤ 4000, clientId?, attachmentIds? } → { message: Message, assistant: Message[] }.
Joins automatically if nobody owns the conversation (a message that only asks the assistant does not join). Translated for the visitor before it is shown
to them. Writing @<assistant handle> (e.g. @amanda) makes the assistant answer in the
conversation; its messages are in assistant. Creates a pending knowledge suggestion.
POST /operator/conversations/:id/attachments → Attachment
As for visitors.
POST /operator/conversations/:id/navigate → Message
Body { url }. Same site only (a host in siteHosts or an allowed origin). The visitor's widget
moves the browser there.
POST /operator/conversations/:id/read → 204
Body { through }.
POST /operator/conversations/:id/typing → 204
Body { typing }.
DELETE /operator/conversations/:id → 204
Admins only. Deletes messages and images.
GET /operator/requests
→ { requests: { conversationId, at, preview }[] }: conversations where someone asked for you in
the last 30 minutes.
GET /operator/stream → text/event-stream
RealtimeEvents for the whole tenant, including live visitors' visitor and pointer events.
The visitor on OperatorConversation is VisitorInfo: id, externalId, name, email,
phone, company, attributes, language.
GET /operator/dashboard?days=7 → Dashboard
Overview numbers for the last days days (1–90, default 7): whole days in the tenant's time zone
(openingHours.tz, default Europe/Oslo), today included. Any operator.
| Field | Meaning |
|---|---|
days, since, timeZone | The period. |
onlineVisitors | Now: visitors with a heartbeat in the last 35 s. |
activeConversations | Now: not closed, a message in the last 15 minutes. |
waitingNow | Now: not closed, someone asked for a person. |
openCallbacks | Now: callback requests not yet handled. |
visitors, pageviews | In the period, from the navigation history (only browsers that report live-visitor activity). |
conversations | Started in the period, with at least one message. |
withHuman | Of those: someone from the team wrote. The rest were handled by the assistant alone. |
requested | Of those: someone asked for a person. |
callbacks | Callback requests created in the period. |
medianResponseSeconds | Median seconds from the first request for a person to the team's first reply after it; null without data. |
unansweredRequests | Requests in the period without a reply from the team. |
perDay | { date, conversations, withHuman, visitors } per day, oldest first. |
byHour | 24 numbers: conversations started per hour of day. |
topChatPages | Up to 8 { url, title, count }: pages conversations were started from. |
topPages | Up to 8 { url, title, count }: most viewed pages. |
Live visitors
Any operator.
| Method and path | Body / result |
|---|---|
GET /operator/visitors?minutes=30 | → { visitors: LiveVisitor[] }: visitors seen in the last minutes (1–1440, default 30) that have reported a page; online first, then most recently seen. Max 200. |
GET /operator/visitors/:id | → LiveVisitorDetail: a LiveVisitor plus pageviews ({ url, title, tabId, at }, newest first, up to 200) and conversations ({ id, status, createdAt, lastMessageAt, preview }, newest first, up to 30). |
POST /operator/visitors/:id/watch | Starts or renews the live feed → { watchedUntil } (now + 25 s). Renew every ~10 s while your live view is open. |
DELETE /operator/visitors/:id/watch | Stops the live feed → 204. If another operator is still watching, their next renewal starts it again. |
LiveVisitor is VisitorInfo plus verified, online (heartbeat within 35 s), visible
(their tab is in the foreground), firstSeenAt, lastSeenAt, sessionStartedAt, currentPage
({ url, title }), sessionPageCount (pages opened this session), referrer (where the session
came from), device ({ type: "mobile" | "tablet" | "desktop", browser, os }), location
({ country, city } or null), watched (some operator is watching now) and conversation (the
latest one: { id, status, lastMessageAt, preview, requested }, or null).
While a visitor is watched, their browser sends sample batches instead of heartbeats, and each
batch arrives on GET /operator/stream as a pointer event (a LiveFrame). See
realtime.md and, for showing it over the visitor's page,
admin-embed.md.
Knowledge
| Method and path | Body / result |
|---|---|
GET /operator/knowledge/documents | → { documents: KnowledgeDocument[] } |
POST /operator/knowledge/documents | Three forms. Large files (recommended): JSON { filename, mimeType?, size } → { document, upload: { url, method: "PUT", contentType } }; PUT the raw file to upload.url with that Content-Type, then call /ingest. Small files (< 4 MB): multipart file. Web page: JSON { url } (a page on the tenant's site). Types: PDF, DOCX, TXT, MD, CSV, ≤ 50 MB. Multipart and URL forms are indexed before the response. |
POST /operator/knowledge/documents/:id/ingest | After a direct upload: extracts, indexes → KnowledgeDocument (status ready or error) |
DELETE /operator/knowledge/documents/:id | admin → 204 |
GET /operator/knowledge/suggestions?status=pending | → { suggestions: KnowledgeSuggestion[] } |
POST /operator/knowledge/suggestions | { question, answer, conversationId? } → KnowledgeSuggestion |
PATCH /operator/knowledge/suggestions/:id | { status?, question?, answer? } → KnowledgeSuggestion. approved makes it available to the assistant. |
DELETE /operator/knowledge/suggestions/:id | → 204 |
Callbacks
GET /operator/callbacks → { callbacks: CallbackRequest[] };
PATCH /operator/callbacks/:id { handled } → CallbackRequest.
Notifications
The signed-in operator's own notifications (notifications.md).
| Method and path | Body / result |
|---|---|
GET /operator/notifications | → NotificationSettings (preferences, email, emailConfigured, replyByEmail, vapidPublicKey, pushSubscriptions, mobileDevices) |
PATCH /operator/notifications | { preferences: { mine?: { email, push }, … } } → NotificationSettings |
POST /operator/notifications/push | a browser's PushSubscription.toJSON() → NotificationSettings (201) |
DELETE /operator/notifications/push | { endpoint } → 204 |
PUT /operator/notifications/devices | Mobile app: { installationId, pushToken, platform: "ios" | "android" } → NotificationSettings. installationId 8–100 chars [A-Za-z0-9_-]; pushToken ExponentPushToken[…]. With a Supabase login the phone is registered for every tenant the person is an active operator in. 403 while impersonating; rate-limited (20/h). |
DELETE /operator/notifications/devices/:installationId | → 204; removes the installation for the person's (or the token's) operators |
POST /operator/notifications/test | { channel: "push" | "email" } → { sent } |
Tenant settings (admin)
| Method and path | |
|---|---|
GET /operator/tenant | → TenantSettings |
PATCH /operator/tenant | any TenantSettings fields except id, assistantAvatarUrl → TenantSettings. Includes liveVisitors (default true); off, browsers send no activity and POST /visitor/activity answers interval: 0. |
POST /operator/tenant/assistant-avatar | multipart file → TenantSettings |
POST /operator/tenant/assistant-avatar/generate | AI-drawn mascot inspired by the business (never a human; max 5 drawings per day), applied at once → TenantSettings |
POST /operator/tenant/assistant-avatar/draft | draws a candidate mascot without applying it (counts towards the 5 per day) → { url } |
POST /operator/tenant/assistant-avatar/accept | { url } of a candidate → puts it on the chat at once, removes the old avatar → TenantSettings |
POST /operator/tenant/assistant-avatar/discard | { url } of a candidate → deletes it, the current avatar stays → 204 |
GET /operator/api-keys | → { apiKeys: ApiKey[] } |
POST /operator/api-keys | { name } → { apiKey, key } — key is shown only here |
DELETE /operator/api-keys/:id | revoke → 204 |
Server-to-server (secret API key)
POST /server/operator-tokens
Body { externalId, name, email?, avatarUrl?, role?: "agent" | "admin", ttlSeconds? } →
{ token, expiresAt, operator: Operator }. Creates the operator on first use, updates name, email,
avatar and role afterwards. 403 forbidden for a deactivated operator (only PUT reactivates).
GET /server/operators → { operators: ServerOperator[] }
Every operator your backend created (those with an externalId), deactivated ones included.
ServerOperator is Operator plus deactivatedAt: string | null, system: boolean and
panelLogin: boolean (linked to a sign-in to the hosted panel). Use it to reconcile.
GET /server/operators/:externalId → ServerOperator
404 if unknown.
PUT /server/operators/:externalId → ServerOperator
Body { name, email?, avatarUrl?, role?: "agent" | "admin", system?: boolean, invite?: boolean }
(email/avatarUrl may be null to clear). Creates (201) or updates (200) the operator without
issuing a token, and reactivates a deactivated one. Idempotent.
roleis yours: it cannot be changed in Snakkbar's panels for an operator with anexternalId.system: truemarks an integration account (see Departments); onlyPUTsets it.invite: truealso e-mails the operator a sign-in link to the hosted panel, unless they already have a panel login. A new row, a reactivation and a changedemailnever have one, so sendinginvite: trueon those is enough. The response then carriesinvited: boolean;false(no e-mail, rate limit, mail not configured) leaves the operator saved, retry withPOST …/invite.- A changed
email(trimmed, case-insensitive) unlinks the panel login, and so does a reactivation: the address has to sign in again. An old work address never keeps access.
POST /server/operators/:externalId/invite → { sent: true }
E-mails the operator a one-time sign-in link to the hosted panel, opened on your tenant
(/panel?tenant=<id>). No password is involved. 404 unknown operator, 403 deactivated,
409 conflict no e-mail, 429 after 5 invites per operator per hour, 503 if mail could not be
sent.
Panel sign-in for provisioned operators
An operator your backend provisions can answer chats in the hosted panel (/panel) and the mobile
app as well as in your own embedded admin. Both are the same operator row: same id, history,
departments and notification settings; tokens from POST /server/operator-tokens keep working.
- Your backend provisions the operator with their work e-mail, and
invite: true(or callsPOST …/invite). They can also go to/panel/loginand ask for a link or a code themselves. - The first sign-in with that verified e-mail links the operator to the person's Snakkbar login. From then on the link counts, not the address. One login can be linked in several tenants (the panel's tenant switcher), at most one operator per tenant; a conflict is logged and skipped.
- Your backend stays in charge.
DELETEends panel access at once (the next request answers401/403and the tenant leaves the switcher). Changingemailor reactivating unlinks the login; only a sign-in after the change links it again.
DELETE /server/operators/:externalId → 204
Deactivates: tokens and panel sessions stop working at once, the operator disappears from the
team, @-mentions and hand-off, leaves every open conversation (ownership passes on, or back to the
assistant), their e-mail is cleared and their panel login unlinked. Name and avatar remain on past messages. 204 also for unknown or already
deactivated ids.
POST /server/visitor-tokens
Body { externalId, name?, email?, phone?, company?, attributes?, ttlSeconds? } →
{ token, expiresAt, visitorId }. Creates the visitor for externalId on first use. The profile
fields follow the rules of PATCH /visitor/me, but come from your backend and are trusted: they
always overwrite what is stored, including values the browser filled in. A field left out is
unchanged; null clears it; attributes are merged (null removes a key). Minting a token does
not make the visitor appear online.
Departments
The same departments admins manage in Settings → Avdelinger (operator-tools.md),
for keeping them in sync from your backend. ServerDepartment is Department (id, name,
description, memberIds, urlRules, roundRobin) plus memberExternalIds: the externalIds
of the members that have one.
| Method and path | Body / result |
|---|---|
GET /server/departments | → { departments: ServerDepartment[] }, in display order |
GET /server/departments/:id | → ServerDepartment; 404 if unknown |
POST /server/departments | { name, description?, memberIds?, memberExternalIds?, urlRules?, roundRobin? } → ServerDepartment (201). At most 50 departments; 409 if the name is taken. |
PATCH /server/departments/:id | any of the same fields → ServerDepartment. Giving memberIds and/or memberExternalIds replaces the members; members who stay keep their place in the rotation. |
DELETE /server/departments/:id | → 204; its conversations stay, without a department. Idempotent. |
Fields: name 1–60 chars, description up to 300, urlRules up to 30 rules of up to 200 chars,
memberIds (operator ids) and memberExternalIds up to 200 each. When both member lists are given
the members are their union. An unknown, deactivated or system operator in either list is 400.
System operators (system: true, integration accounts) are not part of the team: they are not
shown in the team list, @-mentions, round-robin and requests for a person, alerts or the
dashboard, and cannot be department members. They still sign in and act, and their name stays on
what they wrote.
Rate limits
| Limit | Value |
|---|---|
| Visitor messages per conversation | 8 per 10 min |
| Visitor messages per IP | 30 per 10 min, 200 per day |
| Assistant turns per tenant | 2000 per day (then the turn ends with a rate_limited error event) |
| New visitors per IP | 120 per 10 min |
| Visitor profile updates | 20 per minute per visitor |
| Visitor activity (heartbeats, live batches) | 600 per minute per visitor (all tabs together) |
| New conversations per visitor | 30 per hour |
| Visitor uploads per IP | 12 per 10 min |
| Requests for a person per conversation | 10 per 10 min |
Panel sign-in invites (POST /server/operators/:externalId/invite, invite: true) | 5 per hour per operator |
Sign-in links and codes (/panel/login, POST /auth/sign-in-code) | 5 per hour per address, 30 per hour per IP |
| Callback requests per visitor | 5 per hour |
| Operator messages | 60 per minute per operator |
| Operator uploads | 30 per 10 min per operator |
| AI mascot drawing | 5 per tenant per day |