#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 11 Oct 2026. 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
- 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 (Planned).
#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
│
│ 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 (multipart) file → url
#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} |
{
"channel_id": "00000000-0000-4000-8000-000000000000",
"name": "Формула крема",
"greeting": "Здравствуйте! Чем можем помочь?",
"color": "#6366f1",
"avatar_url": null,
"pre_chat_form": null,
"locale": "ru",
"consent": {
"text": "Я согласен с условиями оферты",
"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 (Planned).
#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": "Я согласен с условиями оферты",
"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" }
]
}
| Field | Type | Description |
|---|---|---|
messages[].id | string | the message ID in the database |
messages[].sender | string | user is the visitor; ai; operator |
messages[].content | string | the text |
messages[].created_at | string | creation time |
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",
"operator_avatar": null
}
],
"operator": { "name": "Anna", "avatar": null },
"typing": false,
"typing_name": ""
}
| Field | Type | Description |
|---|---|---|
new_messages[] | array | AI and operator messages |
new_messages[].sender | string | operator or ai |
new_messages[].operator_name, operator_avatar | string, optional | the operator's name and avatar |
operator | object or null | the assigned operator |
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 | {id, sender, content, created_at} (sender: operator or ai) | a message not from the visitor appeared in the conversation (internal ones are skipped) |
conversation:update | {status, assigned_operator_id} | the conversation status or the assigned operator changed |
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. It works with a limitation: an uploaded file does not create a message (limitations).
Upload a file and get a public address.
#Request
POST /api/widget/upload, Content-Type: multipart/form-data
| Form field | Type | Required | Constraints | Example |
|---|---|---|---|---|
file | file | yes | at most 10 MB | scheme.pdf |
channel_id | string | yes | 00000000-0000-4000-8000-000000000000 | |
session_token | string | yes | the token of an active session | w1.… |
Allowed types: JPEG, PNG, GIF, and WebP images; PDF; DOC, DOCX; XLS, XLSX; TXT, CSV, MD. The extension, MIME type, and file signature are checked; dangerous extensions are blocked.
curl -X POST https://formula-cream.pro/api/widget/upload \
-F '[email protected]' \
-F 'channel_id=00000000-0000-4000-8000-000000000000' \
-F 'session_token=w1.…'
#Response 200
{
"url": "https://formula-cream.pro/storage/…/scheme.pdf",
"filename": "scheme.pdf",
"size": 84213,
"content_type": "application/pdf"
}
The url address is public: anyone who knows it can open the file.
#Errors
| Status | Body | When |
|---|---|---|
400 | {"error":"file and channel_id required"} | no file or channel |
401 | {"error":"Session not available"} | no token or an invalid one (without explanation) |
400 / 413 | {"error":"<reason>"} | the file failed the type, size, or signature check: the reason text comes from the file check |
404 | {"error":"Session not available"} | the channel or conversation is not found |
413 | {"error":"File too large (max 10MB)"} | the file is larger than 10 MB |
429 | {"error":"Too many requests"} | more than 5 uploads per minute per IP, or 30 per minute per channel |
500 | {"error":"Upload failed"} | upload failure |
The per-IP rate check runs before the body is parsed.
For the operator to see the file, send a message with the url address in the text (POST /messages, content: "File: https://…").
#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 | 10 MB | file | none |
| 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 |
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 or file too large |
429 | messages, upload | rate limit |
500 | all | server failure |
503 | messages | session signing is not configured |
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 does not create a message; the operator does not see the file. Before the first message (no token), the response is a bare 401; the accept attribute of the field in the frame is broader than the server's list | Upload the file, then send a message with the url. Upload only after the first message |
| 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, use the server API once it is released.
#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.
- The file
urlis public: do not upload anything secret. - 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.