#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). Onlyinitsends 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
- When to use it
- Access and general rules
- How it works
- Session token
- POST /api/widget/init
- POST /api/widget/messages
- Consent to the terms
- GET /api/widget/history
- GET /api/widget/poll
- GET /api/widget/events (SSE)
- POST /api/widget/upload
- Conversation states
- Card in a message
- Limits
- Error summary
- Example: a chat in plain JavaScript
- Example: a React component
- Known limitations
- Notes
- Open questions
#When to use it
- You need a chat interface with your own design, or inside your app.
- The widget does not suit you in look or placement.
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 URL | https://<cell domain>: RU https://formula-cream.pro, EN https://wfacademy.org, ID https://wfacademy.id |
| Authentication | no key. The channel is identified by the public channel_id, and the visitor by a signed session token |
| Format | JSON, UTF-8, Content-Type: application/json (except upload) |
| Condition | the channel is of type web_chat and active; the tenant is active |
| CORS | init only (Access-Control-Allow-Origin: *). The other routes do not return responses to a foreign origin (limitations) |
| Channel ID in the examples | 00000000-0000-4000-8000-000000000000 |
| Money | the AI reply is charged to the tenant's wallet (Overview) |
| Versioning | none. 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.
| Property | Value |
|---|---|
| Format | w1.<base64url payload>.<HMAC signature>; at most 1024 characters |
| Payload | conversation 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 |
| Lifetime | 30 days from issue |
| What it grants | access to its own conversation: history, poll, events, upload, and continuing through messages |
| Where to store it | the 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 it | in the body (session_token) for messages and upload; in the query (st) for history, poll, events |
| Invalid token | messages 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
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
channel_id | string (UUID) | yes | a web_chat channel ID | 00000000-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
| Field | Type | Description |
|---|---|---|
channel_id | string | channel ID |
name | string | channel name |
greeting | string | greeting. If it is not set, a greeting in the channel's or market's language is substituted |
color | string | accent color, #rrggbb; #6366f1 by default |
avatar_url | string or null | avatar URL |
pre_chat_form | object or null | settings of the form shown before the chat: the fields name, email, phone and a required flag |
locale | string or null | channel language |
consent | object, optional | present only if the tenant set a consent: {text, url, version} |
assistant | object | the 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 |
queue | object | the header in the queued state (the conversation is waiting for an operator): {title, subtitle, icon_url} |
powered_by | object or null | the label under the window: {text, url}; null means the tenant hid the label |
files | object | the 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
| Status | Body | When |
|---|---|---|
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.
initdoes 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
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
channel_id | string (UUID) | yes | 00000000-0000-4000-8000-000000000000 | |
content | string | yes | at most 4000 characters | How much does the course cost? |
session_token | string | no | the token from the previous response. Without it: a new visitor and a new conversation | w1.eyJ….Zm9v |
sender_name | string | no | the visitor's name | Anna |
language | string | no | accepted; according to the project's list of discrepancies it does not affect the reply | ru |
page_url | string | no | up to 300 characters; the page address | https://shop.example.com/courses/hydrolat-basics |
page_title | string | no | up to 200 characters; up to 150 go into the prompt | Hydrolat Basics |
referrer | string | no | up to 300 characters | https://yandex.ru/ |
first_touch | object | no | the first touch, up to 1200 characters as JSON (fields) | {"source":"yandex","medium":"cpc"} |
consent_version | string | no | the version of the consent to the terms; required if the tenant set a consent | 3a7bd3e2360a3d29 |
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
| Field | Type | Description |
|---|---|---|
session_token | string | the session token; save the new one |
response | string or null | the AI reply. null if the AI did not reply (an operator is handling the conversation, and so on) |
queued | boolean, optional | true 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
responsefield when there is no AI reply (an empty string ornull) 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
| Status | Body | When | What to do |
|---|---|---|---|
400 | {"error":"Missing required fields: channel_id, content"} | channel_id or content is missing | Fix the request |
400 | {"error":"consent_required","consent":{"text","url","version"}} | the tenant requires consent, and the version was not passed or is out of date | Show 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 disabled | Check channel_id |
413 | {"error":"message_too_long","max_length":4000} | the text is longer than 4000 characters | Shorten it; put the text back in the input field |
429 | {"error":"Too many requests"} | the rate limit is exceeded | Wait and retry |
500 | {"error":"Internal server error"} | server failure | Retry after a pause |
503 | {"error":"widget_not_configured"} | session signing is not configured on the server | Contact 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
- The visitor's identity is determined only by the session token. Without it, or with an invalid one, it is a new visitor.
- The conversation metadata records the site host, the path (up to 200 characters),
page_key, the query (up to 200 characters), andsource: "embedded_widget"(details). - The model receives the address as host and path, without the query.
- Attribution is recorded only for tenants; its failure does not cancel the reply.
- If an operator is handling the conversation, the AI does not reply.
- Daily AI limit: the channel's
ai_daily_limit(200 by default, 10,000 at most). After the limit,queued: true.
#Consent to the terms
If the tenant set a consent_text, the server does not accept messages without consent.
Procedure:
initreturnsconsent: {text, url, version}. If the key is absent, no consent is required.- Show the
textand (if present) theurllink with a checkbox. Do not send a message until the checkbox is ticked. - Pass
consent_version=consent.versionin everyPOST /messages. Passing it in the first one is enough: the consent is recorded in the conversation. - If the tenant changes the text or the link, the version changes. The server will respond
400 consent_requiredwith the currentconsent: 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>
| Parameter | Type | Required | Description |
|---|---|---|---|
st | string | yes | the 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" }
}
| Field | Type | Description |
|---|---|---|
messages[].id | string | the message ID in the database |
messages[].sender | string | user is the visitor; ai; operator; system |
messages[].content | string | the text. For a "file only" or "cards only" message it may be empty: the attachments and cards show it |
messages[].created_at | string | creation time |
messages[].operator | object, 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[].attachments | array, optional | the 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[].cards | array, optional | product cards (format) |
state | string | who is handling the conversation: ai, queued (waiting for an operator), operator (states) |
operator | object or null | the 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
| Status | Body | When |
|---|---|---|
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>]
| Parameter | Type | Required | Description |
|---|---|---|---|
st | string | yes | the session token |
after | string (ISO 8601 time) | no | filters 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": ""
}
| Field | Type | Description |
|---|---|---|
new_messages[] | array | AI and operator messages in the same form as in history (operator, attachments, cards) |
new_messages[].sender | string | operator or ai |
state | string | ai, queued, or operator (states) |
operator | object or null | the public profile of the conversation's operator: {name, avatar_url} |
typing | boolean | whether the operator is typing. The flag stays active for 5 seconds after the last keystroke |
typing_name | string | the 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
| Event | Data | When |
|---|---|---|
message | the 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:
- Access is rechecked every 60 seconds; on a denial,
closedarrives. - The AI reply to your message may, according to the code, arrive twice: in the body of
POST /messages(theresponsefield) and as amessageevent with a database ID (the reply is written to the database, and the stream delivers any message not from the visitor). Not verified on live data. Protect yourself: comparesenderandcontentwith recent messages and keep theids you have shown. - The visitor's own messages do not appear in the stream.
EventSourceautomatic reconnection works; after a drop, readhistoryso you do not lose messages.- Errors before the stream starts:
401,404,500with the plain-text bodySession not available.
#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 field | Type | Required | Constraints | Example |
|---|---|---|---|---|
file | file | yes | at most files.max_mb from init (10 by default, 10 MB at most) | scheme.pdf |
channel_id | string | yes | the channel the token was issued for | 00000000-0000-4000-8000-000000000000 |
session_token | string | yes, if there is no st in the address | the token of an active session | w1.… |
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
| What | Limit |
|---|---|
| Files in one message | up to 5 |
| Conversation | 50 files and 200 MB |
| Channel | a daily quota on files and volume |
| Tenant | a daily quota on volume |
#Errors
| Status | Body | When |
|---|---|---|
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.
state | What it means | Widget header |
|---|---|---|
ai | the AI is replying | assistant.name and assistant.subtitle, icon assistant.avatar_url |
queued | the 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 |
operator | an operator is handling the conversation | the 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.
| Field | Type | Description |
|---|---|---|
code | string | the card code, up to 40 characters: lowercase Latin letters, digits, _, - |
name | string | the name, up to 120 characters |
description | string or null | the description, up to 500 characters |
image_url | string or null | the picture: https or a /… path on the tenant's own site |
price_text | string or null | the price as text ("9,900 RUB") |
badge | string or null | the label ("Bestseller"), up to 30 characters |
page_url | string or null | the card page: https or a /… path |
buttons | array | up 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
| What | Limit | Scope | Counter storage |
|---|---|---|---|
| Message length | 4000 characters | message | none |
| Message rate | 20 per minute | channel + IP | process memory |
| Message rate | 240 per minute | channel | process memory |
| Daily AI limit | 200 by default, 10,000 at most (ai_daily_limit) | tenant channel | database |
| File size | files.max_mb from init, at most 10 MB | file | none |
| Files per message | 5 | message | none |
| Files per conversation | 50 files and 200 MB | conversation | database and counter |
| File uploads | 5 per minute | IP | process memory |
| File uploads | 30 per minute | channel | process memory |
| History | 500 messages | history request | none |
| Token lifetime | 30 days | token | none |
| Token size | 1024 characters | token | none |
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
| Code | Routes | Meaning |
|---|---|---|
400 | init, messages, upload | invalid request; consent_required |
403 | upload | uploads_disabled: visitor files are turned off |
409 | upload | conversation_not_ready: the conversation has not started |
411 | upload | no Content-Length |
415 | upload | file_type_not_allowed |
401 | history, poll, events, upload | missing or invalid token |
404 | all | the channel, conversation, or session is not found or is inactive |
413 | messages, upload | message too long, file too large, quota_exceeded, too_many_files |
429 | messages, upload | rate limit; for upload, also the daily quotas (channel_daily_quota, tenant_daily_quota) |
500 | all | server failure |
503 | messages, upload | messages: 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 configurableAPI_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:
- Render text with
textContent, notinnerHTML: replies come from the model and the operator. - Save the token after every response.
- After a page reload, restore the window from
history, then connect the stream.
#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. | Limitation | Consequences and workarounds |
|---|---|---|
| none | CORS: messages, history, poll, events, and upload have no headers. CORS (*) and OPTIONS exist only for init | A 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 |
| 1 | Pre-chat form: sender_email and sender_phone are sent by the frame, but the server does not read them | sender_name works. Collect contacts in the message text or with your own form |
| 2 | An 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 list | Upload after the first message; take the list of types from init (files.accept) |
| 13 | The 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 read | In your own client, pass after or deduplicate by id. The reply language is set by the channel settings |
Additionally, not from the list:
- Possible duplication of the AI reply (the
POST /messagesbody and SSE). Deduplicate. - No idempotency on
POST /messages: a retry after a network failure may create a second message. - No versioning. The contract may change; for a durable integration from your server, use the server API.
#Notes
- If you build the interface on the cell's domain (for example, your own page inside the cell), CORS is not required.
- For SSE behind your proxy, turn off buffering (
proxy_buffering off) and keepX-Accel-Buffering: no. - Hosting proxies often cut SSE connections after 30 to 60 seconds: rely on the fallback poll.
- File links are signed and live for about an hour: do not store them as permanent addresses, re-read
history. - Changelog: 90-changelog.
#Open questions
- Whether CORS headers will be added to all client routes, or a versioned contract (
/api/public/v1/chat/*) will be released: the project describes only the "Client Chat API" section, and there are no dates. - The shape of the
responsefield when the AI did not reply (nullor an empty string), and the reasons for the absence of a reply in the client API, are not fixed in the code. Technical. - The structure of
pre_chat_form(field names, required flags) is described here from the frame's behavior; the exact schema is not published in the project. - SSE (Realtime) operation on each of the three cells was not verified as part of this documentation.