◆ Sapport 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 11 Oct 2026. 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. Limits
  13. Error summary
  14. Example: a chat in plain JavaScript
  15. Example: a React component
  16. Known limitations
  17. Notes
  18. 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 (Planned).

#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
      │
      │  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.

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}
{
  "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

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 (Planned).

#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": "Я согласен с условиями оферты",
    "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" }
  ]
}
FieldTypeDescription
messages[].idstringthe message ID in the database
messages[].senderstringuser is the visitor; ai; operator
messages[].contentstringthe text
messages[].created_atstringcreation time

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",
      "operator_avatar": null
    }
  ],
  "operator": { "name": "Anna", "avatar": null },
  "typing": false,
  "typing_name": ""
}
FieldTypeDescription
new_messages[]arrayAI and operator messages
new_messages[].senderstringoperator or ai
new_messages[].operator_name, operator_avatarstring, optionalthe operator's name and avatar
operatorobject or nullthe assigned operator
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
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:

#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 fieldTypeRequiredConstraintsExample
filefileyesat most 10 MBscheme.pdf
channel_idstringyes00000000-0000-4000-8000-000000000000
session_tokenstringyesthe token of an active sessionw1.…

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

StatusBodyWhen
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

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 size10 MBfilenone
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
401history, poll, events, uploadmissing or invalid token
404allthe channel, conversation, or session is not found or is inactive
413messages, uploadmessage too long or file too large
429messages, uploadrate limit
500allserver failure
503messagessession 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 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 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 listUpload the file, then send a message with the url. Upload only after the first message
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