Walker Formulation Academy for developers API reference Error catalog OpenAPI
Sections

#Client Chat API

Status: Available. The /api/widget/* routes work today and are used by the widget itself. They are not packaged as a versioned contract (no version prefix, no OpenAPI): the field set is described from the code as of 12 Oct 2026 (widget v2: header by state, visitor files, cards). Only init sends CORS headers; read Known limitations before you start.

Purpose: build your own chat interface ("headless"): the same calls the widget frame makes, with your own design.

#Contents

  1. When to use it
  2. Access and general rules
  3. How it works
  4. Session token
  5. POST /api/widget/init
  6. POST /api/widget/messages
  7. Consent to the terms
  8. GET /api/widget/history
  9. GET /api/widget/poll
  10. GET /api/widget/events (SSE)
  11. POST /api/widget/upload
  12. Conversation states
  13. Card in a message
  14. Limits
  15. Error summary
  16. Example: a chat in plain JavaScript
  17. Example: a React component
  18. Known limitations
  19. Notes
  20. Open questions

#When to use it

If the standard window is enough, use the widget. If you need the reply on your own server or in your own messenger, use the server API (Available).

#Access and general rules

Base URLhttps://<cell domain>: RU https://formula-cream.pro, EN https://wfacademy.org, ID https://wfacademy.id
Authenticationno key. The channel is identified by the public channel_id, and the visitor by a signed session token
FormatJSON, UTF-8, Content-Type: application/json (except upload)
Conditionthe channel is of type web_chat and active; the tenant is active
CORSinit only (Access-Control-Allow-Origin: *). The other routes do not return responses to a foreign origin (limitations)
Channel ID in the examples00000000-0000-4000-8000-000000000000
Moneythe AI reply is charged to the tenant's wallet (Overview)
Versioningnone. The field set may grow: ignore unknown fields

The server API conventions (Conventions: problem+json, the code catalog, Idempotency-Key, cursors, operation) do not apply to these routes: client API responses are plain {"error": "…"} objects, not application/problem+json. The same case of an exhausted daily limit is reported by the server API with the field ai_reply: {status: "skipped", reason: "daily_limit"}.

#How it works

  Your interface                                     Cell
      │  POST /init   {channel_id} ───────────────►  name, color, greeting,
      │ ◄─────────────────────────────────────────  form, consent, header (assistant,
      │                                              queue), label, file rules
      │
      │  POST /messages {channel_id, content,       first message: no session yet
      │                  [session_token]} ────────►
      │ ◄── {session_token, response, queued?} ───  save session_token
      │
      │  GET /events?st=<token>  (SSE) ◄──────────  operator replies, typing
      │  or GET /poll?st=<token> every 2.5 s
      │  GET /history?st=<token>  on open           conversation history
      │  POST /upload?st=<token> (multipart)        file → message with an attachment

#Session token

The token is issued in the response to the first message (POST /messages) and refreshed in every response. Save the latest one.

PropertyValue
Formatw1.<base64url payload>.<HMAC signature>; at most 1024 characters
Payloadconversation and channel identifiers, version, expiry; signed but not encrypted: do not put anything in the token that you are not ready to show the user
Lifetime30 days from issue
What it grantsaccess to its own conversation: history, poll, events, upload, and continuing through messages
Where to store itthe widget frame uses sessionStorage (sapport_session_v2_<channel_id>), that is, a single tab. The choice is yours: localStorage keeps the conversation across tabs
How to pass itin the body (session_token) for messages and upload; in the query (st) for history, poll, events
Invalid tokenmessages does not treat it as an error: a new conversation and a new token are created. history, poll, events, upload respond 401

The token is the key to the conversation. Do not write it to logs or to addresses that third parties can see. A token in the query (st) ends up in the logs of your proxies and server.

The conversation ID is not returned in responses: it is needed only inside the token.

#POST /api/widget/init

Status: Available.

Get the channel settings for rendering the interface. The only route with CORS.

#Request

POST /api/widget/init

FieldTypeRequiredConstraintsExample
channel_idstring (UUID)yesa web_chat channel ID00000000-0000-4000-8000-000000000000
curl -X POST https://formula-cream.pro/api/widget/init \
  -H 'Content-Type: application/json' \
  -d '{"channel_id":"00000000-0000-4000-8000-000000000000"}'

#Response 200

FieldTypeDescription
channel_idstringchannel ID
namestringchannel name
greetingstringgreeting. If it is not set, a greeting in the channel's or market's language is substituted
colorstringaccent color, #rrggbb; #6366f1 by default
avatar_urlstring or nullavatar URL
pre_chat_formobject or nullsettings of the form shown before the chat: the fields name, email, phone and a required flag
localestring or nullchannel language
consentobject, optionalpresent only if the tenant set a consent: {text, url, version}
assistantobjectthe header in the ai state: {name, subtitle, avatar_url}. The name defaults to the channel name; avatar_url is null if no icon is set
queueobjectthe header in the queued state (the conversation is waiting for an operator): {title, subtitle, icon_url}
powered_byobject or nullthe label under the window: {text, url}; null means the tenant hid the label
filesobjectthe visitor file rules: {visitor_upload, max_mb, accept}: whether uploads are allowed, the size limit in MB, the list of allowed MIME types

The tenant sets assistant, queue, powered_by, and files in the dashboard or through the tenant API; details are in Embedding, branding. Paths on the tenant's own site in icon addresses are returned as an absolute address on the cell. The top-level name and avatar_url are kept for older clients: avatar_url equals assistant.avatar_url.

{
  "channel_id": "00000000-0000-4000-8000-000000000000",
  "name": "Formula Cream",
  "greeting": "Hello! How can we help?",
  "color": "#6366f1",
  "avatar_url": null,
  "pre_chat_form": null,
  "locale": "ru",
  "assistant": { "name": "School assistant", "subtitle": "We reply instantly", "avatar_url": null },
  "queue": { "title": "Waiting for an operator", "subtitle": "An operator will join shortly", "icon_url": null },
  "powered_by": { "text": "Powered by Formula AI", "url": null },
  "files": { "visitor_upload": true, "max_mb": 10, "accept": ["image/jpeg", "image/png", "application/pdf"] },
  "consent": {
    "text": "I agree to the terms of the offer",
    "url": "https://formula-cream.pro/offer",
    "version": "3a7bd3e2360a3d29"
  }
}

The greeting is local: you need to display it yourself. It does not come from the history and is not stored as a message.

#Errors

StatusBodyWhen
400{"error":"Missing channel_id"}channel_id is missing
404{"error":"Channel not found or inactive"}the channel is not found, is disabled, is not web_chat, or the tenant is inactive
500{"error":"Internal server error"}server failure

OPTIONS returns 204 with CORS headers.

init does not check whether your domain is allowed for the chat window (frame-ancestors). That applies only to the embedded frame; it does not affect your own interface.

#POST /api/widget/messages

Status: Available.

Send a visitor's message and get the AI reply. A paid operation.

#Request

POST /api/widget/messages, Content-Type: application/json

FieldTypeRequiredConstraintsExample
channel_idstring (UUID)yes00000000-0000-4000-8000-000000000000
contentstringyesat most 4000 charactersHow much does the course cost?
session_tokenstringnothe token from the previous response. Without it: a new visitor and a new conversationw1.eyJ….Zm9v
sender_namestringnothe visitor's nameAnna
languagestringnoaccepted; according to the project's list of discrepancies it does not affect the replyru
page_urlstringnoup to 300 characters; the page addresshttps://shop.example.com/courses/hydrolat-basics
page_titlestringnoup to 200 characters; up to 150 go into the promptHydrolat Basics
referrerstringnoup to 300 charactershttps://yandex.ru/
first_touchobjectnothe first touch, up to 1200 characters as JSON (fields){"source":"yandex","medium":"cpc"}
consent_versionstringnothe version of the consent to the terms; required if the tenant set a consent3a7bd3e2360a3d29

The server does not read the fields sender_email and sender_phone that the widget frame sends: do not rely on them (limitations).

curl -X POST https://formula-cream.pro/api/widget/messages \
  -H 'Content-Type: application/json' \
  -d '{
    "channel_id": "00000000-0000-4000-8000-000000000000",
    "content": "How much does the course cost?",
    "sender_name": "Anna",
    "page_url": "https://shop.example.com/courses/hydrolat-basics",
    "page_title": "Hydrolat Basics",
    "consent_version": "3a7bd3e2360a3d29"
  }'

#Response 200

FieldTypeDescription
session_tokenstringthe session token; save the new one
responsestring or nullthe AI reply. null if the AI did not reply (an operator is handling the conversation, and so on)
queuedboolean, optionaltrue if the daily AI limit is exhausted (daily_limit): the message is saved, the conversation moved to the handoff_pending state and was handed to an operator, and there will be no AI reply
{
  "session_token": "w1.eyJjIjoiMDAwMDAwMDAifQ.c2lnbmF0dXJl",
  "response": "The \"Hydrolat Basics\" course costs 9,900 RUB. Would you like to hear what is included?"
}
{
  "session_token": "w1.eyJjIjoiMDAwMDAwMDAifQ.c2lnbmF0dXJl",
  "response": null,
  "queued": true
}

The shape of the response field when there is no AI reply (an empty string or null) is not fixed in the code: handle both. The exact reasons for the absence of a reply are not disclosed in the client API; they are available only in the server API.

#Errors

StatusBodyWhenWhat to do
400{"error":"Missing required fields: channel_id, content"}channel_id or content is missingFix the request
400{"error":"consent_required","consent":{"text","url","version"}}the tenant requires consent, and the version was not passed or is out of dateShow the text, get the consent, and retry with consent_version (below)
404{"error":"Channel not found or inactive"}the channel is not found or is disabledCheck channel_id
413{"error":"message_too_long","max_length":4000}the text is longer than 4000 charactersShorten it; put the text back in the input field
429{"error":"Too many requests"}the rate limit is exceededWait and retry
500{"error":"Internal server error"}server failureRetry after a pause
503{"error":"widget_not_configured"}session signing is not configured on the serverContact the cell's support

A retry after 429, 500, or 503 may record the message a second time: this route has no idempotency. If in doubt, read history and resend only if the message is not there.

#What happens on the server

If the tenant set a consent_text, the server does not accept messages without consent.

Procedure:

  1. init returns consent: {text, url, version}. If the key is absent, no consent is required.
  2. Show the text and (if present) the url link with a checkbox. Do not send a message until the checkbox is ticked.
  3. Pass consent_version = consent.version in every POST /messages. Passing it in the first one is enough: the consent is recorded in the conversation.
  4. If the tenant changes the text or the link, the version changes. The server will respond 400 consent_required with the current consent: show it again and retry with the new version.

The version is the first 16 hexadecimal characters of sha256(text + "\n" + url). Do not compute it yourself: take the ready value from init or from the 400 response.

The consent record: kind: "offer", version, accepted_at, text_sha256, url, ip (the visitor's original IP; see the limitation).

{
  "error": "consent_required",
  "consent": {
    "text": "I agree to the terms of the offer",
    "url": "https://formula-cream.pro/offer",
    "version": "3a7bd3e2360a3d29"
  }
}

#GET /api/widget/history

Status: Available.

The conversation history, for restoring the window when the page reloads.

#Request

GET /api/widget/history?st=<session_token>

ParameterTypeRequiredDescription
ststringyesthe session token

#Response 200

{
  "messages": [
    { "id": "11111111-1111-4111-8111-111111111111", "sender": "user", "content": "How much does the course cost?", "created_at": "2026-10-11T08:15:30Z" },
    { "id": "22222222-2222-4222-8222-222222222222", "sender": "ai", "content": "The course costs 9,900 RUB.", "created_at": "2026-10-11T08:15:33Z" },
    {
      "id": "44444444-4444-4444-8444-444444444444", "sender": "operator", "content": "Here is the diagram and the course card.",
      "created_at": "2026-10-11T08:17:10Z",
      "operator": { "name": "Anna", "avatar_url": "https://formula-cream.pro/storage/operators/anna.jpg" },
      "attachments": [
        { "name": "scheme.pdf", "type": "application/pdf", "size": 84213, "url": "https://formula-cream.pro/storage/…", "expires_at": "2026-10-11T09:17:10Z" }
      ],
      "cards": [
        {
          "code": "hydrolat-basics", "name": "Hydrolat Basics", "description": "A course for beginners", "image_url": null,
          "price_text": "9,900 RUB", "badge": "Bestseller", "page_url": "/courses/hydrolat-basics",
          "buttons": [ { "label": "Enroll", "url": "https://formula-cream.pro/courses/hydrolat-basics" } ]
        }
      ]
    }
  ],
  "state": "operator",
  "operator": { "name": "Anna", "avatar_url": "https://formula-cream.pro/storage/operators/anna.jpg" }
}
FieldTypeDescription
messages[].idstringthe message ID in the database
messages[].senderstringuser is the visitor; ai; operator; system
messages[].contentstringthe text. For a "file only" or "cards only" message it may be empty: the attachments and cards show it
messages[].created_atstringcreation time
messages[].operatorobject, optional{name, avatar_url}: the byline of an operator message. Present only on operator messages; an AI reply is never signed as a person
messages[].attachmentsarray, optionalthe message attachments, up to 5: {name, type, size, url, expires_at}. url is a signed link to a private file, valid for about an hour (expires_at); after it expires, read history again
messages[].cardsarray, optionalproduct cards (format)
statestringwho is handling the conversation: ai, queued (waiting for an operator), operator (states)
operatorobject or nullthe public profile of the conversation's operator: {name, avatar_url}; avatar_url may be null

At most 500 messages are returned, in ascending time order. Staff internal notes (is_internal) are not included.

#Errors

StatusBodyWhen
401{"error":"Session not available"}no token, or an invalid token
404{"error":"Session not available"}the channel or conversation is not found or is inactive
500{"error":"Internal server error"}server or database failure

#GET /api/widget/poll

Status: Available.

A fallback way to receive new messages when the event stream (SSE) is unavailable. The widget frame polls every 2.5 seconds. Without after, every response contains all the AI and operator messages of the conversation, so keep track of the ids you have shown on the client side, or pass after with the time of the last message.

#Request

GET /api/widget/poll?st=<session_token>[&after=<time>]

ParameterTypeRequiredDescription
ststringyesthe session token
afterstring (ISO 8601 time)nofilters out messages whose created_at is not later than this value. The widget frame does not pass this parameter (limitations)

#Response 200

{
  "new_messages": [
    {
      "id": "33333333-3333-4333-8333-333333333333",
      "sender": "operator",
      "content": "Hello! My name is Anna, I'll tell you more.",
      "created_at": "2026-10-11T08:17:02Z",
      "operator": { "name": "Anna", "avatar_url": null }
    }
  ],
  "state": "operator",
  "operator": { "name": "Anna", "avatar_url": null },
  "typing": false,
  "typing_name": ""
}
FieldTypeDescription
new_messages[]arrayAI and operator messages in the same form as in history (operator, attachments, cards)
new_messages[].senderstringoperator or ai
statestringai, queued, or operator (states)
operatorobject or nullthe public profile of the conversation's operator: {name, avatar_url}
typingbooleanwhether the operator is typing. The flag stays active for 5 seconds after the last keystroke
typing_namestringthe name of the person typing

Errors are the same as for history.

#GET /api/widget/events (SSE)

Status: Available.

A Server-Sent Events stream: operator and AI replies arrive without polling.

#Request

GET /api/widget/events?st=<session_token>, through EventSource.

Response headers: Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, X-Accel-Buffering: no. The first line of the stream is : connected.

#Events

EventDataWhen
messagethe same as a history item: {id, sender, content, created_at, operator?, attachments?, cards?} (sender: operator or ai)a message not from the visitor appeared in the conversation (internal ones are skipped)
conversation:update{status, state, operator}the conversation status, the AI mode, or the assigned operator changed. state is ai, queued, or operator; operator is {name, avatar_url} or null. The operator's internal ID is not given to the visitor
typing{active, name}the operator is typing (active=true for 5 s)
status{status}the subscription state (for example, SUBSCRIBED)
heartbeat{ts}every 25 seconds
closed{reason: "session_unavailable"}access to the conversation is closed (the channel is disabled, the tenant is turned off, or the session is invalid). The stream closes
: connected

event: status
data: {"status":"SUBSCRIBED"}

event: message
data: {"id":"33333333-3333-4333-8333-333333333333","sender":"operator","content":"Hello!","created_at":"2026-10-11T08:17:02Z"}

event: heartbeat
data: {"ts":1760170000000}

Properties:

#POST /api/widget/upload

Status: Available. The visitor's file is saved to the conversation's private storage and creates a visitor message with an attachment; the operator sees it in "Conversations".

Upload a file on behalf of the visitor. Uploads can be turned off in the channel settings (files.visitor_upload, see init).

#Request

POST /api/widget/upload?st=<session_token>, Content-Type: multipart/form-data

The session token is accepted in the address (st, as the frame sends it) or in the form field session_token. The Content-Length header is required: without it the response is 411. The size is checked before the body is parsed.

Form fieldTypeRequiredConstraintsExample
filefileyesat most files.max_mb from init (10 by default, 10 MB at most)scheme.pdf
channel_idstringyesthe channel the token was issued for00000000-0000-4000-8000-000000000000
session_tokenstringyes, if there is no st in the addressthe token of an active sessionw1.…

Allowed types: JPEG, PNG, GIF, and WebP images; PDF; DOC, DOCX; XLS, XLSX; TXT, CSV, MD. Archives are not accepted. The extension, MIME type, and file signature are checked; dangerous extensions are blocked. The channel returns the current list of MIME types in init (files.accept).

You can upload once the conversation has started: there is an AI or operator reply, or the visitor has written two messages. Otherwise the response is 409 conversation_not_ready.

curl -X POST 'https://formula-cream.pro/api/widget/upload?st=w1.…' \
  -F '[email protected]' \
  -F 'channel_id=00000000-0000-4000-8000-000000000000'

#Response 200

{
  "message_id": "55555555-5555-4555-8555-555555555555",
  "created_at": "2026-10-11T08:20:00Z",
  "attachment": {
    "name": "scheme.pdf",
    "type": "application/pdf",
    "size": 84213,
    "url": "https://formula-cream.pro/storage/…",
    "expires_at": "2026-10-11T09:20:00Z"
  }
}

url is a signed link to a private file; it is valid for about an hour. The file is not public: to get a fresh link, read history. The message is already created; you do not need to send a separate POST /messages with the address. The file is not passed to the AI model.

#Quotas

WhatLimit
Files in one messageup to 5
Conversation50 files and 200 MB
Channela daily quota on files and volume
Tenanta daily quota on volume

#Errors

StatusBodyWhen
400{"error":"file and channel_id required"}no file or channel
401{"error":"Session not available"}no token, it is invalid, or it was issued for another channel
403{"error":"uploads_disabled"}the tenant turned off visitor files
404{"error":"Session not available"}the channel or conversation is not found
409{"error":"conversation_not_ready"}the conversation has not started: an upload before the first exchange of messages
411{"error":"length_required"}no Content-Length header
413{"error":"file_too_large","max_mb":10}the file is larger than the channel limit; max_mb is the current limit
413{"error":"quota_exceeded"} or {"error":"too_many_files"}the conversation quota is exhausted or there are too many files
415{"error":"file_type_not_allowed"}the file type is not allowed
429{"error":"Too many requests"}more than 5 uploads per minute per IP, or 30 per minute per channel
429{"error":"channel_daily_quota"} or {"error":"tenant_daily_quota"}the daily quota of the channel or tenant is exhausted
503{"error":"rate_limit_unavailable"}the quota counter is temporarily unavailable; retry later
500{"error":"Upload failed"}upload failure

The per-IP rate check runs before the body is parsed.

#Conversation states

Every message, history, and poll carry the conversation state, and SSE reports a change of it with the conversation:update event. The widget changes its header accordingly.

stateWhat it meansWidget header
aithe AI is replyingassistant.name and assistant.subtitle, icon assistant.avatar_url
queuedthe conversation is handed to an operator and waiting for one (or the daily limit of AI replies is exhausted)queue.title, queue.subtitle, icon queue.icon_url
operatoran operator is handling the conversationthe operator's photo and name (operator), the subtitle "Online"

Build your own interface the same way: take the title and subtitle from init while state is ai or queued, and from operator when state is operator.

#Card in a message

Product cards arrive in the cards field of a message (up to 10 in one message). The tenant creates them in the dashboard or through the API, and an operator, the AI seller, or a server call sends them into the conversation.

FieldTypeDescription
codestringthe card code, up to 40 characters: lowercase Latin letters, digits, _, -
namestringthe name, up to 120 characters
descriptionstring or nullthe description, up to 500 characters
image_urlstring or nullthe picture: https or a /… path on the tenant's own site
price_textstring or nullthe price as text ("9,900 RUB")
badgestring or nullthe label ("Bestseller"), up to 30 characters
page_urlstring or nullthe card page: https or a /… path
buttonsarrayup to 3 buttons {label ≤ 30, url}; url is only https or a /… path

Display the texts as text, not as HTML. Do not render or follow addresses with other schemes (javascript:, data:).

#Limits

WhatLimitScopeCounter storage
Message length4000 charactersmessagenone
Message rate20 per minutechannel + IPprocess memory
Message rate240 per minutechannelprocess memory
Daily AI limit200 by default, 10,000 at most (ai_daily_limit)tenant channeldatabase
File sizefiles.max_mb from init, at most 10 MBfilenone
Files per message5messagenone
Files per conversation50 files and 200 MBconversationdatabase and counter
File uploads5 per minuteIPprocess memory
File uploads30 per minutechannelprocess memory
History500 messageshistory requestnone
Token lifetime30 daystokennone
Token size1024 characterstokennone

Counters in process memory are not shared between several processes and reset on restart. The client routes have no Retry-After headers or remaining-limit headers: on 429, wait a minute.

#Error summary

CodeRoutesMeaning
400init, messages, uploadinvalid request; consent_required
403uploaduploads_disabled: visitor files are turned off
409uploadconversation_not_ready: the conversation has not started
411uploadno Content-Length
415uploadfile_type_not_allowed
401history, poll, events, uploadmissing or invalid token
404allthe channel, conversation, or session is not found or is inactive
413messages, uploadmessage too long, file too large, quota_exceeded, too_many_files
429messages, uploadrate limit; for upload, also the daily quotas (channel_daily_quota, tenant_daily_quota)
500allserver failure
503messages, uploadmessages: session signing is not configured; upload: rate_limit_unavailable

The error body is {"error": "<code or text>"}; for events it is text. Branch your logic on the status and on the error string where it is described above; the texts Session not available and Too many requests are not meant to be parsed.

#Example: a chat in plain JavaScript

The working file is examples/custom-chat-vanilla.html, with a configurable API_BASE (see CORS). The main fragments follow.

const API_BASE = 'https://formula-cream.pro';          // your cell
const CHANNEL_ID = '00000000-0000-4000-8000-000000000000';
const TOKEN_KEY = 'my_chat_token';

let token = localStorage.getItem(TOKEN_KEY) || '';
let consentVersion = '';
const shown = new Set();                                // IDs of messages already shown

async function api(path, options) {
  const res = await fetch(API_BASE + path, options);
  let body = null;
  try { body = await res.json(); } catch { /* not JSON */ }
  return { ok: res.ok, status: res.status, body };
}

// 1. Channel settings and consent
async function init() {
  const { ok, body } = await api('/api/widget/init', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ channel_id: CHANNEL_ID })
  });
  if (!ok) throw new Error('Channel unavailable');
  addMessage('ai', body.greeting);                      // we display the greeting ourselves
  if (body.consent) consentVersion = body.consent.version;
  return body;
}

// 2. Sending a message
async function send(text) {
  const payload = {
    channel_id: CHANNEL_ID,
    content: text,
    session_token: token || undefined,
    page_url: location.href,
    page_title: document.title,
    consent_version: consentVersion || undefined
  };
  const { ok, status, body } = await api('/api/widget/messages', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  });

  if (status === 400 && body && body.error === 'consent_required') {
    consentVersion = body.consent.version;              // show the consent and retry
    throw new Error('consent_required');
  }
  if (status === 413) throw new Error('Message too long');
  if (status === 429) throw new Error('Too many requests, wait a minute');
  if (!ok) throw new Error('Could not send');

  token = body.session_token;
  localStorage.setItem(TOKEN_KEY, token);
  if (body.queued) addMessage('system', 'Handed to an operator, who will reply later');
  else if (body.response) addMessage('ai', body.response);
}

// 3. Subscribing to operator replies: SSE with a fallback poll
function listen() {
  if (!token) return;
  const es = new EventSource(API_BASE + '/api/widget/events?st=' + encodeURIComponent(token));
  es.addEventListener('message', (e) => {
    const m = JSON.parse(e.data);
    addMessage(m.sender, m.content, m.id);
  });
  es.addEventListener('closed', () => es.close());
  es.onerror = () => { es.close(); setTimeout(poll, 2500); };
}

async function poll() {
  if (!token) return;
  const { ok, body } = await api('/api/widget/poll?st=' + encodeURIComponent(token));
  if (ok) body.new_messages.forEach((m) => addMessage(m.sender, m.content, m.id));
  setTimeout(poll, 2500);
}

// 4. Displaying without duplicates
function addMessage(sender, content, id) {
  if (id) {
    if (shown.has(id)) return;
    shown.add(id);
  }
  // the AI reply from POST and the same reply from SSE: compare sender+content with the last message
  const last = document.querySelector('#log > :last-child');
  if (last && last.dataset.sender === sender && last.textContent === content) return;
  const div = document.createElement('div');
  div.dataset.sender = sender;
  div.textContent = content;                            // not innerHTML: the text is untrusted
  document.querySelector('#log').appendChild(div);
}

Key points:

#Example: a React component

The working file is examples/custom-chat-react.jsx.

import { useEffect, useRef, useState } from 'react';

const API_BASE = 'https://formula-cream.pro';
const CHANNEL_ID = '00000000-0000-4000-8000-000000000000';

export function ChatBox() {
  const [messages, setMessages] = useState([]);
  const [text, setText] = useState('');
  const [busy, setBusy] = useState(false);
  const token = useRef(localStorage.getItem('my_chat_token') || '');
  const consent = useRef('');

  const add = (m) =>
    setMessages((list) => {
      if (m.id && list.some((x) => x.id === m.id)) return list;
      const last = list[list.length - 1];
      if (last && last.sender === m.sender && last.content === m.content) return list;
      return [...list, m];
    });

  useEffect(() => {
    let es;
    (async () => {
      const init = await fetch(`${API_BASE}/api/widget/init`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ channel_id: CHANNEL_ID })
      }).then((r) => r.json());
      if (init.consent) consent.current = init.consent.version;
      add({ sender: 'ai', content: init.greeting });

      if (token.current) {
        es = new EventSource(`${API_BASE}/api/widget/events?st=${encodeURIComponent(token.current)}`);
        es.addEventListener('message', (e) => add(JSON.parse(e.data)));
      }
    })();
    return () => es && es.close();
  }, []);

  async function submit(e) {
    e.preventDefault();
    if (!text.trim() || busy) return;
    setBusy(true);
    add({ sender: 'user', content: text });
    const res = await fetch(`${API_BASE}/api/widget/messages`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        channel_id: CHANNEL_ID,
        content: text,
        session_token: token.current || undefined,
        consent_version: consent.current || undefined
      })
    });
    const body = await res.json().catch(() => ({}));
    setBusy(false);
    if (!res.ok) return add({ sender: 'system', content: `Error ${res.status}` });
    token.current = body.session_token;
    localStorage.setItem('my_chat_token', token.current);
    if (body.response) add({ sender: 'ai', content: body.response });
    setText('');
  }

  return (
    <form onSubmit={submit}>
      <ul>{messages.map((m, i) => <li key={m.id || i}>{m.content}</li>)}</ul>
      <input value={text} onChange={(e) => setText(e.target.value)} maxLength={4000} />
      <button disabled={busy}>Send</button>
    </form>
  );
}

#Known limitations

The wording is neutral: from the code as of 11 Oct 2026.

No.LimitationConsequences and workarounds
noneCORS: messages, history, poll, events, and upload have no headers. CORS (*) and OPTIONS exist only for initA request from another origin will not go through (for application/json the browser first sends a preflight OPTIONS request, which this route does not serve), and the response will not be available to page code. What works: (a) a page on the cell's own domain; (b) your server-side proxy (browser to your server to the cell), forwarding the visitor's real IP and with buffering disabled for SSE; (c) a mobile app (there is no browser CORS). CORS permission on the platform side is Planned
1Pre-chat form: sender_email and sender_phone are sent by the frame, but the server does not read themsender_name works. Collect contacts in the message text or with your own form
2An upload works only in a started conversation: before the first exchange of messages the response is 409 conversation_not_ready; the accept attribute of the field in the frame is broader than the server's listUpload after the first message; take the list of types from init (files.accept)
13The widget frame does not pass after to poll (the server supports the filter); language in messages does not affect the reply; the data-consent attribute in widget.js is not readIn your own client, pass after or deduplicate by id. The reply language is set by the channel settings

Additionally, not from the list:

#Notes

#Open questions