Brandfine Docs
REST API

Live Chat

Public chat endpoints for workspaces running the Brandfine Live Chat plugin — widget config, starting conversations with the publishable key, and sending/reading messages with a per-conversation token.

Five public endpoints under /external/live-chat/*. Unlike the other /external namespaces, Live Chat is authenticated with the scoped publishable key — a brandfine_pk_… credential that is safe to embed in public HTML because it can only start chat conversations for its workspace. The broad workspace API key is deliberately rejected on the browser-facing routes (and vice versa).

MethodPathAuthPurpose
GET/external/live-chat/configpublishable keyRuntime widget config — enabled state, greeting, theme.
POST/external/live-chat/conversationspublishable keyStart a conversation, or resume one by token.
POST/external/live-chat/conversations/:token/messagesconversation tokenVisitor sends a message.
GET/external/live-chat/conversations/:token/messagesconversation tokenHistory / catch-up poll.
GET/external/live-chat/bootstrapbroad workspace keyServer-side SDK bootstrap — returns the publishable key + config.

All endpoints inherit the permissive /external/* CORS. The first four are designed for browser calls — that is the entire point of the publishable key. The bootstrap endpoint is the one exception: it authenticates with the broad workspace key and must only ever be called server-side (your backend, RSC, or build step).

The two-step auth model

  1. Publishable key (data-publishable-key on the widget, or publishableKey from the bootstrap) opens the door: it can fetch the widget config and start/resume a conversation. Nothing else.
  2. Conversation token — returned once by the start endpoint — is the credential for everything after: sending messages and reading history for that one conversation. The widget persists it in localStorage so the visitor resumes the same thread across page loads.

A publishable key presented to any broad /external endpoint (posts, submissions, …) is rejected with 401, and the broad key is rejected on the publishable-only routes. Keys are minted per workspace when Live Chat is first activated; find yours in Plugins → Live Chat → Manage settings → Integrate.


GET /external/live-chat/config

The widget's first call on every page load. Resolved live — never baked into a build — so activating or deactivating the plugin in the CMS takes effect on the visitor's next page load with no redeploy of your site.

Pass ?locale=<tag> (the widget sends the rendered page's language: data-locale → <html lang> → browser) to receive the greeting and away message in that language. Translations are configured per workspace in the CMS; resolution is exact tag → primary subtag (tr-TR → tr) → the default text. Unknown locales simply get the default — never an error.

// GET /external/live-chat/config?locale=tr-TR
{
  "enabled": true,
  "greeting": "Hoş geldiniz! Size nasıl yardımcı olabiliriz?",
  "offlineMessage": "Şu an mesai dışındayız — mesajınızı bırakın.",
  "theme": { "--bf-chat-primary": "#0b1f2a" },
  "online": true
}

Returns { "enabled": false } when the plugin is deactivated, disabled, or the key is unknown — render nothing in that case.

POST /external/live-chat/conversations

Start or resume. Send the widget's stored token to resume; a token that no longer matches an open conversation in this workspace falls through to creating a fresh one.

// request
{
  "visitorSessionId": "9f2c…",        // widget-generated, localStorage
  "conversationToken": "8e5ff9…",     // optional — resume path
  "pageUrl": "https://example.com/pricing",
  "referrer": "https://google.com",
  "locale": "en-US",
  "visitor": {                         // optional — verified identity
    "externalId": "user_42",           // your app's stable id, ≤128
    "name": "Ayşe Yılmaz",             // optional, ≤120
    "email": "ayse@example.com",       // optional, ≤200
    "attributes": { "plan": "starter" },  // optional, ≤10 string entries
    "identityToken": "11ed04…"         // hex HMAC — see below
  }
}

// response
{
  "enabled": true,
  "conversationId": "cmrw…",
  "conversationToken": "8e5ff9…",
  "greeting": "Hi! How can we help?",
  "resumed": false
}

Verified visitor identity

The publishable key is public, so an identity the browser could set freely would be worse than none — anyone could type a customer's name. Identity therefore requires a signature your server computes:

identityToken = hex( HMAC_SHA256( signingSecret, externalId ) )

Two signing secrets are accepted:

  1. Derived (default, zero config) — HMAC_SHA256(workspaceApiKey, "brandfine:live-chat:identity:v1"). Your server already holds the API key; the Brandfine API derives the same value from its stored copy. The SDK's identityToken() does this automatically.
  2. Explicit identity secret (CMS: Plugins → Live Chat → Manage settings → Integrate) — for signing services that shouldn't hold the broad API key, or legacy workspaces without a revealable key.
  • Valid signature → the conversation is labelled with the visitor's name and marked ✓ Verified in the inbox. Conversations sharing an externalId are the same person: a signed start with no stored conversationToken resumes that person's newest open conversation, so the thread continues across devices and sessions.
  • Invalid or unverifiable (bad signature, no secret configured, malformed payload) → the conversation starts anonymously — never an error, so a misconfigured integration can't lock visitors out of support. The failure is logged server-side with the workspace id.
  • Never compute the HMAC in a browser and never ship the identity secret to one — that would let anyone impersonate any visitor. Rotate the secret in the CMS if it leaks.

Prefer bf.liveChat.identityToken() from @brandfine/client over hand-rolling the HMAC.

POST /external/live-chat/conversations/:token/messages

Visitor sends a message (201). Body is capped at 2000 chars; visitorName / visitorEmail are optional and only ever fill gaps — they never overwrite values the visitor gave earlier. Flood-guarded per conversation (409 beyond 20 messages/minute). 409 also when the conversation was closed by the team.

// request
{ "body": "Do you ship to Norway?", "visitorEmail": "sam@example.com" }

// response
{ "id": "cmrw…", "sender": "VISITOR", "body": "Do you ship to Norway?", "createdAt": "2026-07-22T21:26:40.997Z" }

GET /external/live-chat/conversations/:token/messages

History and catch-up poll. Pass ?after=<ISO timestamp> (your last-seen createdAt) to receive only newer messages. Fetching history marks the team's messages as read.

{
  "status": "OPEN",
  "messages": [
    { "id": "…", "sender": "VISITOR", "body": "Do you ship to Norway?", "createdAt": "…" },
    { "id": "…", "sender": "AGENT",   "body": "Yes — free over €100!",   "createdAt": "…" }
  ]
}

Render message bodies as text, never as HTML.

GET /external/live-chat/bootstrap

Server-side only — authenticates with the broad workspace key from your environment. Returns everything the @brandfine/client server half needs to render the widget, including the publishable key to bake into your HTML:

{
  "enabled": true,
  "publishableKey": "brandfine_pk_…",
  "greeting": "Hi! How can we help?",
  "offlineMessage": null,
  "theme": null,
  "scriptPath": "/widgets/live-chat.js"
}

Use it through bf.liveChat.getConfig() rather than calling it directly.

On this page