Формула ИИ для разработчиков Справочник API Каталог ошибок OpenAPI
Разделы

#Клиентский Chat API

Статус: Доступно. Маршруты /api/widget/* работают сегодня и используются самим виджетом. Они не оформлены как версионируемый контракт (нет префикса версии, нет OpenAPI): состав полей описан по коду на 12.10.2026 (виджет v2: шапка по состояниям, файлы посетителя, карточки). Заголовки CORS есть только у init; про остальные маршруты читайте раздел «Известные ограничения» до начала работы.

Назначение: построить собственный интерфейс чата («headless»): те же вызовы, что делает кадр виджета, но с вашим дизайном.

#Содержание

  1. Когда использовать
  2. Доступ и общие правила
  3. Схема работы
  4. Токен сессии
  5. POST /api/widget/init
  6. POST /api/widget/messages
  7. Согласие с офертой
  8. GET /api/widget/history
  9. GET /api/widget/poll
  10. GET /api/widget/events (SSE)
  11. POST /api/widget/upload
  12. Состояния диалога
  13. Карточка в сообщении
  14. Пределы
  15. Сводка ошибок
  16. Пример: чат на чистом JavaScript
  17. Пример: компонент React
  18. Известные ограничения
  19. Заметки
  20. Открытые вопросы

#Когда использовать

Если достаточно стандартного окна, используйте виджет. Если ответ нужен на вашем сервере или в вашем мессенджере — серверный API (Доступно).

#Доступ и общие правила

Базовый адресhttps://<домен ячейки> — RU https://formula-cream.pro, EN https://wfacademy.org, ID https://wfacademy.id
Аутентификацияключа нет. Канал определяется открытым channel_id, личность посетителя — подписанным токеном сессии
ФорматJSON, UTF-8, Content-Type: application/json (кроме upload)
Условиеканал типа web_chat, активный; арендатор активен
CORSтолько init (Access-Control-Allow-Origin: *). Остальные маршруты ответов для чужого origin не отдают (ограничения)
Идентификатор канала в примерах00000000-0000-4000-8000-000000000000
Деньгиответ ИИ списывается с кошелька арендатора (Обзор)
Версионированиенет. Состав полей может расшириться: игнорируйте неизвестные поля

Соглашения серверного API (Соглашения: problem+json, каталог кодов, Idempotency-Key, курсоры, operation) на эти маршруты не распространяются: ответы клиентского API — простые объекты {"error": "…"}, а не application/problem+json. Тот же случай исчерпанного суточного предела серверный API сообщает полем ai_reply: {status: "skipped", reason: "daily_limit"}.

#Схема работы

  Ваш интерфейс                                     Ячейка
      │  POST /init   {channel_id} ───────────────►  название, цвет, приветствие,
      │ ◄─────────────────────────────────────────  форма, согласие, шапка (ассистент,
      │                                              очередь), подпись, правила файлов
      │
      │  POST /messages {channel_id, content,       первое сообщение: сессии ещё нет
      │                  [session_token]} ────────►
      │ ◄── {session_token, response, queued?} ───  сохраните session_token
      │
      │  GET /events?st=<token>  (SSE) ◄──────────  ответы оператора, набор текста
      │  либо GET /poll?st=<token> каждые 2,5 с
      │  GET /history?st=<token>  при открытии      история разговора
      │  POST /upload?st=<token> (multipart)        файл → сообщение с вложением

#Токен сессии

Токен выдаётся в ответе на первое сообщение (POST /messages) и обновляется в каждом ответе. Сохраните последний.

СвойствоЗначение
Форматw1.<полезная нагрузка base64url>.<подпись HMAC>; не более 1024 знаков
Полезная нагрузкаидентификаторы разговора и канала, версия, срок; подписана, но не зашифрована — не помещайте в токен ничего, что вы не готовы показать пользователю
Срок30 суток от выдачи
Что даётдоступ к своему разговору: history, poll, events, upload и продолжение через messages
Где хранитьу кадра виджета — sessionStorage (sapport_session_v2_<channel_id>), то есть одна вкладка. Выбор за вами: localStorage сохранит разговор между вкладками
Передачав теле (session_token) для messages и upload; в query (st) для history, poll, events
Негодный токенmessages не считает его ошибкой: заводится новый разговор и новый токен. history, poll, events, upload отвечают 401

Токен — это ключ к разговору. Не пишите его в журналы и адреса, которые видят третьи лица. Токен в query (st) попадает в журнал ваших прокси и сервера.

Идентификатор разговора в ответах не возвращается: он нужен только внутри токена.

#POST /api/widget/init

Статус: Доступно.

Получить настройки канала для отрисовки интерфейса. Единственный маршрут с CORS.

#Запрос

POST /api/widget/init

ПолеТипОбязательныйОграниченияПример
channel_idстрока (UUID)даидентификатор канала web_chat00000000-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"}'

#Ответ 200

ПолеТипОписание
channel_idстрокаидентификатор канала
nameстроканазвание канала
greetingстрокаприветствие. Если не задано, подставляется приветствие на языке канала или рынка
colorстрокацвет акцента, #rrggbb; по умолчанию #6366f1
avatar_urlстрока или nullURL аватара
pre_chat_formобъект или nullнастройка формы перед чатом: поля name, email, phone и признак обязательности
localeстрока или nullязык канала
consentобъект, необязательноесть только если арендатор задал согласие: {text, url, version}
assistantобъектшапка в состоянии ai: {name, subtitle, avatar_url}. Имя по умолчанию — название канала; avatar_url — null, если иконка не задана
queueобъектшапка в состоянии queued (диалог ждёт оператора): {title, subtitle, icon_url}
powered_byобъект или nullподпись под окном: {text, url}; null — подпись скрыта арендатором
filesобъектправила файлов посетителя: {visitor_upload, max_mb, accept} — разрешена ли загрузка, предел размера в МБ, список разрешённых MIME-типов

Настройки assistant, queue, powered_by и files арендатор задаёт в кабинете или через API арендатора; подробности — Встраивание, брендирование. Пути своего сайта в адресах иконок возвращаются абсолютным адресом ячейки. Поля name и avatar_url верхнего уровня сохранены для прежних клиентов: avatar_url равен assistant.avatar_url.

{
  "channel_id": "00000000-0000-4000-8000-000000000000",
  "name": "Формула крема",
  "greeting": "Здравствуйте! Чем можем помочь?",
  "color": "#6366f1",
  "avatar_url": null,
  "pre_chat_form": null,
  "locale": "ru",
  "assistant": { "name": "Помощник школы", "subtitle": "Отвечаем сразу", "avatar_url": null },
  "queue": { "title": "Ожидание оператора", "subtitle": "Оператор скоро подключится", "icon_url": null },
  "powered_by": { "text": "Работает на Формула ИИ", "url": null },
  "files": { "visitor_upload": true, "max_mb": 10, "accept": ["image/jpeg", "image/png", "application/pdf"] },
  "consent": {
    "text": "Я согласен с условиями оферты",
    "url": "https://formula-cream.pro/offer",
    "version": "3a7bd3e2360a3d29"
  }
}

Приветствие greeting — локальное: вам нужно показать его самим. Оно не приходит из истории и не хранится как сообщение.

#Ошибки

СтатусТелоКогда
400{"error":"Missing channel_id"}нет channel_id
404{"error":"Channel not found or inactive"}канал не найден, выключен, не web_chat, либо арендатор неактивен
500{"error":"Internal server error"}сбой сервера

OPTIONS возвращает 204 с заголовками CORS.

init не проверяет, разрешён ли ваш домен для окна чата (frame-ancestors). Это касается только встроенного кадра; собственному интерфейсу не мешает.

#POST /api/widget/messages

Статус: Доступно.

Отправить сообщение посетителя и получить ответ ИИ. Платная операция.

#Запрос

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

ПолеТипОбязательныйОграниченияПример
channel_idстрока (UUID)да00000000-0000-4000-8000-000000000000
contentстрокадане более 4000 знаковСколько стоит курс?
session_tokenстроканеттокен предыдущего ответа. Без него — новый посетитель и новый разговорw1.eyJ….Zm9v
sender_nameстроканетимя посетителяАнна
languageстроканетпринимается; по перечню расхождений проекта на ответ не влияетru
page_urlстроканетдо 300 знаков; адрес страницыhttps://shop.example.com/courses/hydrolat-basics
page_titleстроканетдо 200 знаков; в промпт идёт до 150Основы гидролатов
referrerстроканетдо 300 знаковhttps://yandex.ru/
first_touchобъектнетпервое касание, до 1200 знаков в JSON (поля){"source":"yandex","medium":"cpc"}
consent_versionстроканетверсия согласия с офертой; обязателен, если арендатор задал согласие3a7bd3e2360a3d29

Поля sender_email и sender_phone, которые отправляет кадр виджета, сервер не читает — не рассчитывайте на них (ограничения).

curl -X POST https://formula-cream.pro/api/widget/messages \
  -H 'Content-Type: application/json' \
  -d '{
    "channel_id": "00000000-0000-4000-8000-000000000000",
    "content": "Сколько стоит курс?",
    "sender_name": "Анна",
    "page_url": "https://shop.example.com/courses/hydrolat-basics",
    "page_title": "Основы гидролатов",
    "consent_version": "3a7bd3e2360a3d29"
  }'

#Ответ 200

ПолеТипОписание
session_tokenстрокатокен сессии; сохраните новый
responseстрока или nullответ ИИ. null, если ИИ не отвечал (диалог ведёт оператор и т. п.)
queuedboolean, необязательноtrue, если суточный предел ИИ исчерпан (daily_limit): сообщение сохранено, диалог получил состояние handoff_pending и передан оператору, ответа ИИ не будет
{
  "session_token": "w1.eyJjIjoiMDAwMDAwMDAifQ.c2lnbmF0dXJl",
  "response": "Курс «Основы гидролатов» стоит 9 900 ₽. Хотите, расскажу, что входит?"
}
{
  "session_token": "w1.eyJjIjoiMDAwMDAwMDAifQ.c2lnbmF0dXJl",
  "response": null,
  "queued": true
}

Форма поля response при отсутствии ответа ИИ (пустая строка или null) в коде не зафиксирована: проверяйте оба варианта. Точные причины отсутствия ответа в клиентском API не раскрываются; они есть только в серверном API.

#Ошибки

СтатусТелоКогдаЧто делать
400{"error":"Missing required fields: channel_id, content"}нет channel_id или contentИсправить запрос
400{"error":"consent_required","consent":{"text","url","version"}}арендатор требует согласия, а версия не передана или устарелаПоказать текст, получить согласие, повторить с consent_version (ниже)
404{"error":"Channel not found or inactive"}канал не найден или выключенПроверить channel_id
413{"error":"message_too_long","max_length":4000}текст длиннее 4000 знаковСократить; вернуть текст в поле ввода
429{"error":"Too many requests"}превышен предел частотыПодождать и повторить
500{"error":"Internal server error"}сбой сервераПовторить с паузой
503{"error":"widget_not_configured"}на сервере не настроена подпись сессийОбратиться в поддержку ячейки

Повтор после 429, 500, 503 может привести к повторной записи сообщения: идемпотентности у этого маршрута нет. Если сомневаетесь, прочитайте history и повторите отправку, только если сообщения там нет.

#Что происходит на сервере

#Согласие с офертой

Если арендатор задал consent_text, сервер не принимает сообщения без согласия.

Порядок:

  1. init возвращает consent: {text, url, version}. Если ключа нет — согласие не требуется.
  2. Покажите text и (если есть) ссылку url с флажком. Не отправляйте сообщение, пока флажок не отмечен.
  3. Передавайте consent_version = consent.version в каждом POST /messages. Достаточно в первом: согласие записывается в разговор.
  4. Если арендатор изменил текст или ссылку, версия меняется. Сервер ответит 400 consent_required с актуальным consent: покажите его снова и повторите отправку с новой версией.

Версия — первые 16 шестнадцатеричных знаков sha256(text + "\n" + url). Не вычисляйте её сами: берите готовую из init или из ответа 400.

Запись согласия: kind: "offer", version, accepted_at, text_sha256, url, ip (исходный IP посетителя — ограничение).

{
  "error": "consent_required",
  "consent": {
    "text": "Я согласен с условиями оферты",
    "url": "https://formula-cream.pro/offer",
    "version": "3a7bd3e2360a3d29"
  }
}

#GET /api/widget/history

Статус: Доступно.

История разговора для восстановления окна при перезагрузке страницы.

#Запрос

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

ПараметрТипОбязательныйОписание
stстрокадатокен сессии

#Ответ 200

{
  "messages": [
    { "id": "11111111-1111-4111-8111-111111111111", "sender": "user", "content": "Сколько стоит курс?", "created_at": "2026-10-11T08:15:30Z" },
    { "id": "22222222-2222-4222-8222-222222222222", "sender": "ai", "content": "Курс стоит 9 900 ₽.", "created_at": "2026-10-11T08:15:33Z" },
    {
      "id": "44444444-4444-4444-8444-444444444444", "sender": "operator", "content": "Вот схема и карточка курса.",
      "created_at": "2026-10-11T08:17:10Z",
      "operator": { "name": "Анна", "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": "Основы гидролатов", "description": "Курс для начинающих", "image_url": null,
          "price_text": "9 900 ₽", "badge": "Хит", "page_url": "/courses/hydrolat-basics",
          "buttons": [ { "label": "Записаться", "url": "https://formula-cream.pro/courses/hydrolat-basics" } ]
        }
      ]
    }
  ],
  "state": "operator",
  "operator": { "name": "Анна", "avatar_url": "https://formula-cream.pro/storage/operators/anna.jpg" }
}
ПолеТипОписание
messages[].idстрокаидентификатор сообщения в базе
messages[].senderстрокаuser — посетитель, ai, operator, system
messages[].contentстрокатекст. У сообщения «только файл» или «только карточки» может быть пустым: их показывают вложения и карточки
messages[].created_atстрокавремя создания
messages[].operatorобъект, необязательно{name, avatar_url}: подпись сообщения оператора. Есть только у сообщений operator; ответ ИИ никогда не подписывается как человек
messages[].attachmentsмассив, необязательновложения сообщения, до 5: {name, type, size, url, expires_at}. url — подписанная ссылка на приватный файл, действует около часа (expires_at); после истечения заново прочитайте history
messages[].cardsмассив, необязательнокарточки товаров и продуктов (формат)
stateстрокакто ведёт диалог: ai, queued (ждёт оператора), operator (состояния)
operatorобъект или nullпубличный профиль оператора диалога: {name, avatar_url}; avatar_url может быть null

Возвращается не более 500 сообщений в порядке возрастания времени. Внутренние заметки сотрудников (is_internal) не включаются.

#Ошибки

СтатусТелоКогда
401{"error":"Session not available"}нет токена, негодный токен
404{"error":"Session not available"}канал или разговор не найден либо неактивен
500{"error":"Internal server error"}сбой сервера или базы

#GET /api/widget/poll

Статус: Доступно.

Запасной способ получать новые сообщения, если поток событий (SSE) недоступен. Кадр виджета опрашивает каждые 2,5 секунды. Без after каждый ответ содержит все сообщения ИИ и оператора разговора, поэтому на стороне клиента ведите учёт показанных id или передавайте after со временем последнего сообщения.

#Запрос

GET /api/widget/poll?st=<session_token>[&after=<время>]

ПараметрТипОбязательныйОписание
stстрокадатокен сессии
afterстрока (время ISO 8601)нетотфильтровывает сообщения с created_at не позже этого значения. Кадр виджета параметр не передаёт (ограничения)

#Ответ 200

{
  "new_messages": [
    {
      "id": "33333333-3333-4333-8333-333333333333",
      "sender": "operator",
      "content": "Здравствуйте! Меня зовут Анна, расскажу подробнее.",
      "created_at": "2026-10-11T08:17:02Z",
      "operator": { "name": "Анна", "avatar_url": null }
    }
  ],
  "state": "operator",
  "operator": { "name": "Анна", "avatar_url": null },
  "typing": false,
  "typing_name": ""
}
ПолеТипОписание
new_messages[]массивсообщения ИИ и оператора в том же виде, что в history (operator, attachments, cards)
new_messages[].senderстрокаoperator или ai
stateстрокаai, queued или operator (состояния)
operatorобъект или nullпубличный профиль оператора диалога: {name, avatar_url}
typingbooleanпечатает ли оператор. Признак активен 5 секунд после последнего нажатия
typing_nameстрокаимя печатающего

Ошибки — как у history.

#GET /api/widget/events (SSE)

Статус: Доступно.

Поток серверных событий (Server-Sent Events): ответы оператора и ИИ приходят без опроса.

#Запрос

GET /api/widget/events?st=<session_token> — через EventSource.

Заголовки ответа: Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, X-Accel-Buffering: no. Первая строка потока — : connected.

#События

СобытиеДанныеКогда
messageто же, что элемент history: {id, sender, content, created_at, operator?, attachments?, cards?} (sender: operator или ai)в разговоре появилось сообщение не от посетителя (внутренние пропускаются)
conversation:update{status, state, operator}изменился статус разговора, режим ИИ или назначенный оператор. state — ai, queued или operator; operator — {name, avatar_url} или null. Внутренний идентификатор оператора посетителю не отдаётся
typing{active, name}оператор печатает (active=true в течение 5 с)
status{status}состояние подписки (например, SUBSCRIBED)
heartbeat{ts}каждые 25 секунд
closed{reason: "session_unavailable"}доступ к разговору закрыт (канал выключен, арендатор отключён, сессия недействительна). Поток закрывается
: connected

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

event: message
data: {"id":"33333333-3333-4333-8333-333333333333","sender":"operator","content":"Здравствуйте!","created_at":"2026-10-11T08:17:02Z"}

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

Свойства:

#POST /api/widget/upload

Статус: Доступно. Файл посетителя сохраняется в приватное хранилище диалога и создаёт сообщение посетителя с вложением; оператор видит его в «Диалогах».

Загрузить файл от имени посетителя. Загрузку можно отключить в настройках канала (files.visitor_upload, см. init).

#Запрос

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

Токен сессии принимается в адресе (st, так шлёт кадр) или в поле формы session_token. Заголовок Content-Length обязателен: без него ответ 411. Размер проверяется до разбора тела.

Поле формыТипОбязательныйОграниченияПример
fileфайлдане больше files.max_mb из init (по умолчанию 10, максимум 10 МБ)scheme.pdf
channel_idстрокадаканал, к которому выдан токен00000000-0000-4000-8000-000000000000
session_tokenстрокада, если нет st в адресетокен действующей сессииw1.…

Допустимые типы: изображения JPEG, PNG, GIF, WebP; PDF; DOC, DOCX; XLS, XLSX; TXT, CSV, MD. Архивы не принимаются. Проверяются расширение, MIME-тип и сигнатура файла; опасные расширения блокируются. Актуальный список MIME-типов канал отдаёт в init (files.accept).

Загружать можно, когда диалог уже начался: есть ответ ИИ или оператора, либо посетитель написал два сообщения. Иначе ответ 409 conversation_not_ready.

curl -X POST 'https://formula-cream.pro/api/widget/upload?st=w1.…' \
  -F 'file=@scheme.pdf' \
  -F 'channel_id=00000000-0000-4000-8000-000000000000'

#Ответ 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 — подписанная ссылка на приватный файл; действует около часа. Файл не публичен: чтобы получить свежую ссылку, прочитайте history. Сообщение уже создано, отдельно отправлять POST /messages с адресом не нужно. В модель ИИ файл не передаётся.

#Квоты

ЧтоПредел
Файлов в одном сообщениидо 5
Диалог50 файлов и 200 МБ
Каналсуточная квота на файлы и объём
Арендаторсуточная квота на объём

#Ошибки

СтатусТелоКогда
400{"error":"file and channel_id required"}нет файла или канала
401{"error":"Session not available"}нет токена, он негоден или выдан для другого канала
403{"error":"uploads_disabled"}арендатор отключил файлы посетителя
404{"error":"Session not available"}канал или разговор не найден
409{"error":"conversation_not_ready"}диалог ещё не начат: загрузка до первого обмена сообщениями
411{"error":"length_required"}нет заголовка Content-Length
413{"error":"file_too_large","max_mb":10}файл больше предела канала; max_mb — действующий предел
413{"error":"quota_exceeded"} или {"error":"too_many_files"}исчерпана квота диалога или слишком много файлов
415{"error":"file_type_not_allowed"}тип файла не разрешён
429{"error":"Too many requests"}более 5 загрузок в минуту с IP или 30 в минуту на канал
429{"error":"channel_daily_quota"} или {"error":"tenant_daily_quota"}исчерпана суточная квота канала или арендатора
503{"error":"rate_limit_unavailable"}счётчик квот временно недоступен; повторите позже
500{"error":"Upload failed"}сбой загрузки

Проверка частоты по IP выполняется до разбора тела.

#Состояния диалога

Каждое сообщение, history и poll несут состояние диалога, а SSE сообщает о его смене событием conversation:update. Виджет по нему меняет шапку.

stateЧто значитШапка виджета
aiотвечает ИИassistant.name и assistant.subtitle, иконка assistant.avatar_url
queuedдиалог передан оператору и ждёт его (или исчерпан суточный предел ответов ИИ)queue.title, queue.subtitle, иконка queue.icon_url
operatorдиалог ведёт операторфото и имя оператора (operator), подзаголовок «На связи»

Свой интерфейс стройте так же: заголовок и подзаголовок берите из init, пока state равен ai или queued, и из operator, когда state равен operator.

#Карточка в сообщении

Карточки товаров и продуктов приходят в поле cards сообщения (до 10 в одном сообщении). Их создаёт арендатор в кабинете или через API, а в диалог отправляет оператор, ИИ-продажник или серверный вызов.

ПолеТипОписание
codeстрокакод карточки, до 40 знаков: строчные латинские буквы, цифры, _, -
nameстроканазвание, до 120 знаков
descriptionстрока или nullописание, до 500 знаков
image_urlстрока или nullкартинка: https или путь /… своего сайта
price_textстрока или nullцена как текст («9 900 ₽»)
badgeстрока или nullплашка («Хит»), до 30 знаков
page_urlстрока или nullстраница карточки: https или путь /…
buttonsмассивдо 3 кнопок {label ≤ 30, url}; url — только https или путь /…

Показывайте тексты как текст, не как HTML. Адреса с другими схемами (javascript:, data:) не выводите и по ним не переходите.

#Пределы

ЧтоПределОбластьХранение счётчика
Длина сообщения4000 знаковсообщение—
Частота сообщений20 в минутуканал + IPпамять процесса
Частота сообщений240 в минутуканалпамять процесса
Суточный предел ИИ200 по умолчанию, максимум 10 000 (ai_daily_limit)канал арендаторабаза
Размер файлаfiles.max_mb из init, не больше 10 МБфайл—
Файлов в сообщении5сообщение—
Файлы в диалоге50 файлов и 200 МБдиалогбаза и счётчик
Загрузка файлов5 в минутуIPпамять процесса
Загрузка файлов30 в минутуканалпамять процесса
История500 сообщенийзапрос history—
Срок токена30 сутоктокен—
Размер токена1024 знакатокен—

Счётчики в памяти процесса не общие для нескольких процессов и сбрасываются при перезапуске. Заголовков Retry-After и остатка предела у клиентских маршрутов нет: при 429 ждите минуту.

#Сводка ошибок

КодМаршрутыСмысл
400init, messages, uploadневерный запрос; consent_required
403uploaduploads_disabled: файлы посетителя отключены
409uploadconversation_not_ready: диалог ещё не начат
411uploadнет Content-Length
415uploadfile_type_not_allowed
401history, poll, events, uploadнет или негодный токен
404всеканал, разговор или сессия не найдены или неактивны
413messages, uploadслишком длинное сообщение, большой файл, quota_exceeded, too_many_files
429messages, uploadпредел частоты; для upload — и суточные квоты (channel_daily_quota, tenant_daily_quota)
500всесбой сервера
503messages, uploadmessages: подпись сессий не настроена; upload: rate_limit_unavailable

Тело ошибки — {"error": "<код или текст>"}; для events — текст. Ветвите логику по статусу и по строке error там, где она описана выше; тексты Session not available и Too many requests не предназначены для разбора.

#Пример: чат на чистом JavaScript

Рабочий файл — examples/custom-chat-vanilla.html, с настраиваемым API_BASE (см. CORS). Ниже — основные фрагменты.

const API_BASE = 'https://formula-cream.pro';          // ваша ячейка
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();                                // id уже показанных сообщений

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

// 1. Настройки канала и согласие
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('Канал недоступен');
  addMessage('ai', body.greeting);                      // приветствие показываем сами
  if (body.consent) consentVersion = body.consent.version;
  return body;
}

// 2. Отправка сообщения
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;              // показать согласие и повторить
    throw new Error('consent_required');
  }
  if (status === 413) throw new Error('Сообщение слишком длинное');
  if (status === 429) throw new Error('Слишком часто, подождите минуту');
  if (!ok) throw new Error('Не удалось отправить');

  token = body.session_token;
  localStorage.setItem(TOKEN_KEY, token);
  if (body.queued) addMessage('system', 'Передано оператору, он ответит позже');
  else if (body.response) addMessage('ai', body.response);
}

// 3. Подписка на ответы оператора: SSE с запасным опросом
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. Показ без дублей
function addMessage(sender, content, id) {
  if (id) {
    if (shown.has(id)) return;
    shown.add(id);
  }
  // ответ ИИ из POST и тот же ответ из SSE: сравнить по sender+content с последним сообщением
  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;                            // не innerHTML: текст чужой
  document.querySelector('#log').appendChild(div);
}

Важное:

#Пример: компонент React

Рабочий файл — 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: `Ошибка ${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}>Отправить</button>
    </form>
  );
}

#Известные ограничения

Формулировки нейтральны: по коду на 11.10.2026.

№ОграничениеПоследствия и обходы
—CORS: заголовков нет у messages, history, poll, events, upload. CORS (*) и OPTIONS есть только у initЗапрос с другого origin не пройдёт (для application/json браузер сначала отправит предварительный запрос OPTIONS, который этот маршрут не обслужит), а ответ не будет доступен коду страницы. Работает: (а) страница на домене самой ячейки; (б) ваш серверный прокси (запросы браузер→ваш сервер→ячейка), с пробросом реального IP посетителя и отключённой буферизацией для SSE; (в) мобильное приложение (CORS браузера нет). Разрешение CORS на стороне платформы — Планируется
1Форма перед чатом: sender_email и sender_phone отправляются кадром, но сервер их не читаетПоле sender_name работает. Собирайте контакты текстом сообщения или своей формой
2Загрузка файла работает только в начатом диалоге: до первого обмена сообщениями — 409 conversation_not_ready; атрибут accept поля в кадре шире списка сервераЗагружайте после первого сообщения; список типов берите из init (files.accept)
13Кадр виджета не передаёт after в poll (сервер фильтр поддерживает); language в messages на ответ не влияет; атрибут data-consent в widget.js не читаетсяВ своём клиенте передавайте after или дедуплицируйте по id. Язык ответа определяется настройками канала

Дополнительно, не из перечня:

#Заметки

#Открытые вопросы