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).
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /external/live-chat/config | publishable key | Runtime widget config — enabled state, greeting, theme. |
POST | /external/live-chat/conversations | publishable key | Start a conversation, or resume one by token. |
POST | /external/live-chat/conversations/:token/messages | conversation token | Visitor sends a message. |
GET | /external/live-chat/conversations/:token/messages | conversation token | History / catch-up poll. |
GET | /external/live-chat/bootstrap | broad workspace key | Server-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
- Publishable key (
data-publishable-keyon the widget, orpublishableKeyfrom the bootstrap) opens the door: it can fetch the widget config and start/resume a conversation. Nothing else. - 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
localStorageso 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:
- 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'sidentityToken()does this automatically. - 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
externalIdare the same person: a signed start with no storedconversationTokenresumes 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.