Hopp til innholdet

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

CallerHeaderToken
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 backendAuthorization: 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

json
{ "error": { "code": "rate_limited", "message": "For mange meldinger. Vent litt og prøv igjen." } }
StatusCodes
400bad_request
401unauthorized (missing/expired/invalid token)
403forbidden (role), origin_not_allowed
404not_found
409conflict (e.g. someone else owns the conversation)
413payload_too_large
429rate_limited (with Retry-After when known)
500/503internal, 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:

FieldType
namestring ≤ 120
emailstring ≤ 200Must be a valid address.
phonestring, 3–40Digits, spaces and + ( ) . / - only.
companystring ≤ 120
attributesobjectYour 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.

FieldType
tabIdstring, 4–40Random id per tab (page load), so several tabs are told apart.
urlstring ≤ 4000location.href. http(s) only (400 otherwise). Cleaned before storage, see security.md.
titlestring ≤ 300document.title.
pageviewbooleantrue 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.
referrerstring ≤ 4000With pageview: document.referrer (or the previous URL in a single-page app). Stored as origin + path.
visibleboolean, requireddocument.visibilityState === "visible". The visible tab owns the visitor's current page.
viewport{ width, height }CSS px.
scroll{ x, y }scrollX, scrollY.
documentHeightintegerdocument.documentElement.scrollHeight.
samplesPointerSample[] ≤ 120Only while watched: pointer positions since the previous batch.
clicksPointerSample[] ≤ 30Only 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:

FieldType
bodystring ≤ 1500Required unless attachments are sent.
clientIdstring ≤ 64Optional idempotency key; echoed on the stored message. Retrying with the same id never duplicates.
attachmentIdsstring[] ≤ 4From the attachments endpoint.
mentionsstring[] ≤ 3Operator handles the visitor tagged or tapped.
pageUrl, pageTitlestringWhere the visitor is now.
languagestringVisitor'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 }:

ActionEffect
joinBecome a participant (and owner if there is none). Status → human.
takeoverBecome the owner.
releaseGive ownership to the next participant. If nobody else is in the conversation, it goes back to the assistant (ai) and you leave it.
leaveStop participating (releases first if you own it).
close / reopenClose 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.

FieldMeaning
days, since, timeZoneThe period.
onlineVisitorsNow: visitors with a heartbeat in the last 35 s.
activeConversationsNow: not closed, a message in the last 15 minutes.
waitingNowNow: not closed, someone asked for a person.
openCallbacksNow: callback requests not yet handled.
visitors, pageviewsIn the period, from the navigation history (only browsers that report live-visitor activity).
conversationsStarted in the period, with at least one message.
withHumanOf those: someone from the team wrote. The rest were handled by the assistant alone.
requestedOf those: someone asked for a person.
callbacksCallback requests created in the period.
medianResponseSecondsMedian seconds from the first request for a person to the team's first reply after it; null without data.
unansweredRequestsRequests in the period without a reply from the team.
perDay{ date, conversations, withHuman, visitors } per day, oldest first.
byHour24 numbers: conversations started per hour of day.
topChatPagesUp to 8 { url, title, count }: pages conversations were started from.
topPagesUp to 8 { url, title, count }: most viewed pages.

Live visitors

Any operator.

Method and pathBody / 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/watchStarts or renews the live feed → { watchedUntil } (now + 25 s). Renew every ~10 s while your live view is open.
DELETE /operator/visitors/:id/watchStops 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 pathBody / result
GET /operator/knowledge/documents→ { documents: KnowledgeDocument[] }
POST /operator/knowledge/documentsThree 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/ingestAfter a direct upload: extracts, indexes → KnowledgeDocument (status ready or error)
DELETE /operator/knowledge/documents/:idadmin → 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 pathBody / result
GET /operator/notifications→ NotificationSettings (preferences, email, emailConfigured, replyByEmail, vapidPublicKey, pushSubscriptions, mobileDevices)
PATCH /operator/notifications{ preferences: { mine?: { email, push }, … } } → NotificationSettings
POST /operator/notifications/pusha browser's PushSubscription.toJSON() → NotificationSettings (201)
DELETE /operator/notifications/push{ endpoint } → 204
PUT /operator/notifications/devicesMobile 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/tenantany 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-avatarmultipart file → TenantSettings
POST /operator/tenant/assistant-avatar/generateAI-drawn mascot inspired by the business (never a human; max 5 drawings per day), applied at once → TenantSettings
POST /operator/tenant/assistant-avatar/draftdraws 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/:idrevoke → 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.

  • role is yours: it cannot be changed in Snakkbar's panels for an operator with an externalId.
  • system: true marks an integration account (see Departments); only PUT sets it.
  • invite: true also 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 changed email never have one, so sending invite: true on those is enough. The response then carries invited: boolean; false (no e-mail, rate limit, mail not configured) leaves the operator saved, retry with POST …/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.

  1. Your backend provisions the operator with their work e-mail, and invite: true (or calls POST …/invite). They can also go to /panel/login and ask for a link or a code themselves.
  2. 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.
  3. Your backend stays in charge. DELETE ends panel access at once (the next request answers 401/403 and the tenant leaves the switcher). Changing email or 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 pathBody / 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/:idany 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

LimitValue
Visitor messages per conversation8 per 10 min
Visitor messages per IP30 per 10 min, 200 per day
Assistant turns per tenant2000 per day (then the turn ends with a rate_limited error event)
New visitors per IP120 per 10 min
Visitor profile updates20 per minute per visitor
Visitor activity (heartbeats, live batches)600 per minute per visitor (all tabs together)
New conversations per visitor30 per hour
Visitor uploads per IP12 per 10 min
Requests for a person per conversation10 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 visitor5 per hour
Operator messages60 per minute per operator
Operator uploads30 per 10 min per operator
AI mascot drawing5 per tenant per day