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

#Серверный API диалогов

Статус: Доступно (чтение, заведение диалога, приём сообщения клиента с ответом ИИ, отправка карточек, операции) / Планируется (ответ оператора, перехват, возврат ИИ, закрытие). Маршруты operator-messages, takeover, release и close описаны в проекте и ещё не работают: вызовы вернут 404.

Назначение: управлять диалогами с вашего сервера: заводить диалог из своего мессенджера, передавать реплики клиента ИИ-продажнику, получать его ответ, отвечать от имени оператора, перехватывать диалог и возвращать его ИИ, закрывать.

#Содержание

  1. Когда использовать
  2. Доступ
  3. Общие правила
  4. Объекты
  5. GET /conversations
  6. GET /conversations/{id}
  7. GET /conversations/{id}/messages
  8. Подключение канала
  9. POST /conversations
  10. POST /conversations/{id}/messages
  11. Причины отсутствия ответа ИИ
  12. GET /operations/{id}
  13. POST /conversations/{id}/cards
  14. POST /conversations/{id}/operator-messages
  15. Перехват, возврат ИИ и закрытие
  16. События
  17. Примеры на четырёх языках
  18. Заметки
  19. Открытые вопросы

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

#Доступ

Базовый адресhttps://<домен ячейки>/api/public/v1 — RU https://formula-cream.pro, EN https://wfacademy.org, ID https://wfacademy.id
АутентификацияAuthorization: Bearer <ключ>; ключ действует только в своей ячейке (Аутентификация)
Только серверCORS отсутствует: вызывайте API с вашего сервера, ключ в браузер и в приложение не помещайте
Лицензияпризнак api_access; иначе feature_not_in_plan
Каналapi. Подключается в кабинете: Настройки → API → «Подключить канал чата» (одна интеграция — один канал api, агент выбирается там же). Идентификатор канала возвращает GET /me (channel_id). Пока канал не подключён, маршруты записи отвечают 409 channel_not_configured (Подключение канала)

Области ключа:

ОбластьЧто даётПлатнаяСодержит ПДн
conversations:readчтение диалогов и сообщенийнетда
conversations:writeзаведение диалога, приём сообщения клиента, отправка карточек в диалог; в планах — сообщение оператора, перехват, возврат ИИ, закрытиенетда
ai_seller:invokeответ ИИ-продажника на сообщение клиентадада

Права запроса — пересечение областей ключа, роли интеграции, прав владельца и лицензии (как вычисляются). Для приёма сообщения клиента с ответом ИИ нужны обе области: conversations:write и ai_seller:invoke. Приём без ответа ИИ требует только conversations:write.

В ячейке RU для областей с ПДн (conversations:*) при подключении интеграции обязательны список разрешённых IP, страна размещения принимающей системы и основание передачи. ПДн передаются только получателям в РФ: если страна иная, область conversations:* не выдаётся (403 pii_transfer_not_allowed, Ячейки и данные).

СтатусКодКогда
401invalid_api_key, wrong_cellключ неверен или из другой ячейки
403scope_missing, feature_not_in_plan, ip_not_allowedнет области, тарифа или адрес не разрешён
413payload_too_largeтело запроса больше 256 КБ

#Общие правила

Подробно — Соглашения.

ПравилоЗначение
ФорматJSON, UTF-8, тело до 256 КБ
Ошибкиapplication/problem+json, ветвление по полю code; каталог — Соглашения
Идентификатор запросазаголовок X-Request-Id в каждом ответе, поле request_id в ошибке
Пагинацияlimit от 1 до 100 (по умолчанию 25), cursor (живёт 24 часа); ответ {data, next_cursor}; порядок возрастающий: у диалогов (updated_at, id), у сообщений (created_at, id) (пагинация)
Идемпотентностьзаголовок Idempotency-Key на POST: для заведения диалога, приёма сообщения клиента и отправки карточек — обязателен; срок 24 часа (идемпотентность)
Асинхронностьответ ИИ только асинхронно: 202 и конверт operation; ?wait=N (до 20 с) ждёт то же задание; результат — GET /operations/{id} или событие operation.completed (асинхронные операции)
Платные операцииответ ИИ списывается с кошелька арендатора; возможны spend_limit_reached (402) и wallet_suspended (402, обмен остановлен); стоимость — в operation.usage.cost (платные операции)
Пределы частотычтение 600/мин, запись 120/мин, платные 30/мин (стартовые значения, действующие — в GET /me). Одновременных платных операций (ответов ИИ) не более 5 на интеграцию и не более 5 на арендатора: сверх предела — 429 concurrency_limit
Выбор полейпараметр fields= у чтения: без него приходят все поля, кроме персональных; персональное поле — только если названо явно (Минимизация и выбор полей)
Идентификаторынепрозрачные строки; в примерах cnv_EXAMPLE01, msg_EXAMPLE01, evt_EXAMPLE01

#Объекты

Поля объектов диалога и сообщения — фактические (схемы Conversation и Message в OpenAPI). Идентификаторы — uuid. Набор значений status, channel и content_type может расширяться: не считайте списки закрытыми.

#Диалог

ПолеТипОписание
idстрока (uuid)идентификатор диалога
channelстрока или nullтип канала: api, telegram, website …
contact_idстрока или nullидентификатор контакта (клиента) в Формула ИИ
external_customer_idстрока или nullперсональное поле: приходит, только если названо в fields=. Идентификатор клиента в вашей системе; отдаётся только у канала api, у мессенджеров всегда null (внешний идентификатор мессенджера не раскрывается)
statusстрокасостояние: open, handoff_pending, resolved, closed …
modeai или operatorкто ведёт диалог: ai — отвечает ИИ (режимы auto и supervised), operator — человек (copilot и manual)
languageстрока или nullязык диалога (ISO 639-1)
created_atстрока или nullначало диалога, ISO 8601 UTC
updated_atстрокапоследнее изменение или сообщение, ISO 8601 UTC; по нему идёт обход. Счётчик непрочитанного его не меняет
_encмассив строктолько ячейки EN и ID, когда действует формат JWE: имена полей, значения которых заменены строкой JWE (Формат JWE)

#Сообщение

ПолеТипОписание
idстрока (uuid)идентификатор сообщения
conversation_idстрокадиалог
sendercustomer, ai, operator, systemотправитель: клиент, ИИ, оператор, служебное
content_typeстрокавид содержимого: text, image, file …
contentстрока или nullперсональное поле (только по fields=). Текст; null — текста нет (только вложение) или сообщение удалено
attachmentsмассивперсональное поле (только по fields=). Вложения, до 5 на сообщение: {type, name, size, url, expires_at}; url — временная подписанная ссылка (около часа), не сохраняйте её, запросите сообщение заново
cardsмассивкарточки товаров сообщения (до трёх), копия на момент отправки; поля нет — карточек нет. Персональных данных не содержат. Форма — Карточки
deletedлогическоеtrue — сообщение удалено (приходит только при include_deleted=true, без текста и вложений)
created_atстрокавремя сообщения, ISO 8601 UTC
_encмассив строккак у диалога: только EN и ID при формате JWE

Сообщения содержат персональные данные клиентов. Не записывайте их в журналы и не передавайте третьим лицам без основания.

Персональные данные и журнал выдач. Каждая выдача записывается в журнал выдач (см. ячейки и данные); в журнал попадают только поля, которые вы назвали в fields=. На ячейке RU нужны узкий список разрешённых IP-адресов у ключа и страна получателя RU, иначе 403 pii_transfer_not_allowed приходит до чтения. Если строку журнала записать не удалось — 503, данные не отдаются. Диалоги личных ящиков и чаты кабинета ученика не отдаются; чужой, несуществующий или не-uuid идентификатор даёт 404 not_found.

Ответы чтения несут Cache-Control: private, no-store.

#GET /conversations

Статус: Доступно. Область conversations:read.

Список диалогов с фильтрами и курсором. Порядок возрастающий по (updated_at, id): чтобы синхронизироваться, запоминайте next_cursor и возвращайтесь с ним.

ПараметрТипОбязательныйОграниченияПример
channelстроканетфильтр по каналуapi
statusстроканетфильтр по статусуopen
modeстроканетai или operatoroperator
updated_afterстроканетдиалоги, изменённые строго позже момента; ISO 8601 с поясом2026-10-11T00:00:00Z
include_deletedлогическоенетпринимается для единообразия, у диалогов ничего не добавляет (мягкого удаления нет)false
limitцелоенетот 1 до 100, по умолчанию 2550
cursorстроканетnext_cursor предыдущего ответаeyJ2Ijox…
fieldsстроканетчерез запятую, до 2000 знаков; id приходит всегда. Без параметра — все поля, кроме персональных; персональное поле (external_customer_id) — только если названо (Минимизация и выбор полей)external_customer_id,status
Encryption-Key-Idзаголовокнеттолько EN и ID: какой зарегистрированный ключ использовать для формата JWE (Формат JWE)key-2026-10
curl -sS "$API_BASE_URL/conversations?channel=api&mode=operator&limit=50&fields=external_customer_id" \
  -H "Authorization: Bearer $API_KEY"
{
  "data": [
    {
      "id": "7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01",
      "channel": "api",
      "contact_id": "0d94a7c8-5e2f-4b13-8a6c-91f4e7b02d35",
      "external_customer_id": "client-4815",
      "status": "open",
      "mode": "operator",
      "language": "ru",
      "created_at": "2026-10-11T08:00:00Z",
      "updated_at": "2026-10-11T08:15:30Z"
    }
  ],
  "next_cursor": null
}

В примере поле external_customer_id названо в fields=; без этого параметра в ответе его не будет. Меняя фильтры, начинайте обход заново без cursor: курсор привязан к фильтрам. Недействительный, просроченный или чужой курсор — 422 validation_failed.

#GET /conversations/{id}

Статус: Доступно. Область conversations:read.

Один диалог; принимает fields= и Encryption-Key-Id так же, как список. Если диалога нет или он принадлежит другому арендатору — 404 not_found.

curl -sS "$API_BASE_URL/conversations/7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01?fields=external_customer_id" \
  -H "Authorization: Bearer $API_KEY"

#GET /conversations/{id}/messages

Статус: Доступно. Область conversations:read.

Сообщения диалога постранично: limit (по умолчанию 25, максимум 100), cursor, include_deleted, fields, заголовок Encryption-Key-Id (EN и ID). Без fields текст (content) и вложения (attachments) не приходят: это персональные поля, их нужно назвать явно, например fields=content,attachments. Внутренние заметки операторов не отдаются. Если интеграция на EN или ID работает в режиме required и ключ не зарегистрирован — 409 encryption_key_required. Порядок возрастающий по (created_at, id) — от старых к новым.

С include_deleted=true удалённое сообщение приходит как «надгробие»: deleted: true, content: null, без вложений.

curl -sS "$API_BASE_URL/conversations/7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01/messages?fields=sender,content,attachments,cards,created_at" \
  -H "Authorization: Bearer $API_KEY"
{
  "data": [
    { "id": "1a2b3c4d-0001-4000-8000-000000000001", "sender": "customer", "content": "Сколько стоит курс?", "attachments": [], "created_at": "2026-10-12T08:15:30Z" },
    { "id": "1a2b3c4d-0002-4000-8000-000000000002", "sender": "ai", "content": "Курс стоит 9 900 ₽.", "attachments": [], "created_at": "2026-10-12T08:15:33Z" },
    {
      "id": "1a2b3c4d-0003-4000-8000-000000000003",
      "sender": "customer",
      "content": null,
      "attachments": [
        { "type": "image", "name": "label.jpg", "size": 182044, "url": "https://example.com/signed/EXAMPLE", "expires_at": "2026-10-12T09:20:00Z" }
      ],
      "created_at": "2026-10-12T08:19:00Z"
    }
  ],
  "next_cursor": null
}

#Подключение канала

Все маршруты записи работают в канале api вашей интеграции. Канал подключается один раз в кабинете: Настройки → API → «Подключить канал чата». Там же выбирается агент, который будет отвечать.

Пока канал не подключён, POST /conversations и POST /conversations/{id}/messages возвращают 409 channel_not_configured: подключите канал в кабинете и повторите запрос. Чтение (GET) от подключения не зависит.

#POST /conversations

Статус: Доступно. Область conversations:write. Idempotency-Key обязателен.

Найти или завести диалог клиента вашего канала по внешнему идентификатору.

ПолеТипОбязательныйОграниченияПример
external_customer_idстрокада1–128 знаков; стабильный идентификатор клиента в вашей системе (не телефон и не почта)client-4815
nameстроканетдо 120 знаков; пишется только в пустое поле контактаАнна
emailстроканетдо 254 знаков; только в пустое поле контактаanna@example.com
phoneстроканетдо 32 знаков, международный формат; только в пустое поле контакта+79990001122
languageстроканетISO 639-1, две буквы; язык нового диалога. Язык ответа ИИ ограничен языками ячейкиru

Неизвестные поля дают 422 validation_failed.

curl -sS -X POST "$API_BASE_URL/conversations" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: 6f3c1f3e-5b52-4a52-9e0b-0d8e3a2c9f11" \
  -H "Content-Type: application/json" \
  -d '{"external_customer_id":"client-4815","name":"Анна","language":"ru"}'

Ответ — объект диалога: 201 для нового, 200 для найденного. Ответ содержит персональные данные, поэтому выдача записывается в журнал выдач (на RU действуют ограничения из раздела Доступ).

СтатусКодКогда
409channel_not_configuredканал чата не подключён (Подключение канала)
409idempotency_conflictтот же ключ с другим телом
409operation_in_progressпараллельный повтор того же ключа
402wallet_suspendedобмен остановлен: баланс кошелька отрицателен или кошелёк заблокирован
422validation_failedнет external_customer_id, неизвестные поля, длина
429rate_limitedпредел частоты
503service_unavailableхранилище пределов недоступно; повторите с тем же ключом

#POST /conversations/{id}/messages

Статус: Доступно. Области: conversations:write (приём) и ai_seller:invoke (ответ ИИ). Idempotency-Key обязателен. Платная операция при вызове ИИ.

Передать сообщение клиента. Если запрошен ответ ИИ и у ключа есть область ai_seller:invoke, платформа запускает ИИ-продажника асинхронно. Вложения в этом маршруте не принимаются.

#Запрос

ПолеТипОбязательныйОграниченияПример
contentстрокада1–4000 знаковСколько стоит курс?
ai_replyauto или noneнет, по умолчанию autoauto — запросить ответ ИИ (без области ai_seller:invoke сообщение только сохраняется); none — только сохранить сообщениеauto

Неизвестные поля дают 422 validation_failed.

Параметр адресаТипОписание
waitцелое от 0 до 20секунды ожидания ответа ИИ. Ждёт то же задание, новое не создаётся. Не входит в ключ идемпотентности
ЗаголовокОбязательныйОписание
Idempotency-Keyдауникальная строка на каждую новую реплику клиента; повтор с тем же ключом не создаёт дубль и не списывает деньги второй раз
curl -sS -X POST "$API_BASE_URL/conversations/7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01/messages?wait=15" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: 8d4b2a40-1c63-4d4e-8f0a-6c2d3e1b7a90" \
  -H "Content-Type: application/json" \
  -d '{"content":"Сколько стоит курс?","ai_reply":"auto"}'

#Ответы

Ответ ИИ выполняется только асинхронно: запрос не держит соединение дольше wait. В ответе нет текстов: ни сообщения клиента, ни ответа ИИ — только идентификаторы и итог. Тексты читайте запросом GET /conversations/{id}/messages (там выдача ПДн попадёт в журнал выдач) или получайте событием message.created.

СтатусКогдаai_reply
202ответ ИИ запущен, к концу wait ещё не готов (или wait не задан)status: queued, есть operation
200ответ ИИ готов в пределах waitstatus: replied, есть reply.id и завершённая operation со стоимостью
201ответ ИИ не запрашивался: ai_reply: none или нет области ai_seller:invokestatus: skipped, reason: not_requested; ИИ не вызывается, ничего не списывается
200ИИ не ответит по причине, не связанной с вашим запросомstatus: skipped и причина в reason; операции нет, деньги не списываются
200ответ ИИ стал черновиком, ожидающим оператора (режим с проверкой)status: queued_for_review

Принято, ответ ИИ готовится (202):

{
  "message_id": "1a2b3c4d-0001-4000-8000-000000000001",
  "conversation_id": "7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01",
  "ai_reply": { "status": "queued" },
  "operation": {
    "id": "5e0c9d52-7b1a-4c3d-8e21-0f4a6b7c8d90",
    "type": "conversation.ai_reply",
    "status": "queued",
    "created_at": "2026-10-12T08:15:30Z",
    "resource": { "type": "message", "id": "1a2b3c4d-0001-4000-8000-000000000001" }
  }
}

Ответ готов в пределах wait (200). Текста ответа нет, только reply.id; в операции стоимость в валюте кошелька:

{
  "message_id": "1a2b3c4d-0001-4000-8000-000000000001",
  "conversation_id": "7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01",
  "ai_reply": { "status": "replied" },
  "reply": { "id": "1a2b3c4d-0002-4000-8000-000000000002" },
  "operation": {
    "id": "5e0c9d52-7b1a-4c3d-8e21-0f4a6b7c8d90",
    "type": "conversation.ai_reply",
    "status": "succeeded",
    "created_at": "2026-10-12T08:15:30Z",
    "completed_at": "2026-10-12T08:15:33Z",
    "resource": { "type": "message", "id": "1a2b3c4d-0001-4000-8000-000000000001" },
    "usage": { "cost": { "amount": "0.42", "currency": "RUB" }, "model": "model-EXAMPLE" }
  }
}

ИИ не запрашивался или не отвечал — это успешный ответ, а не ошибка. Операция не создаётся, деньги не списываются:

{
  "message_id": "1a2b3c4d-0001-4000-8000-000000000001",
  "conversation_id": "7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01",
  "ai_reply": { "status": "skipped", "reason": "not_requested" }
}

Не дождались — вернётся 202 с текущим состоянием операции. Дальше выберите способ получения ответа (Асинхронные операции):

  1. подпишитесь на события message.created и operation.completed (вебхуки) — предпочтительный способ;
  2. опросите GET /operations/{id};
  3. прочитайте GET /conversations/{id}/messages (текст ответа запрашивайте через fields=content).

Повтор того же запроса с тем же Idempotency-Key во время выполнения возвращает тот же 202, после завершения — итог; сообщение повторно не записывается и не оплачивается.

#Проверки до записи

Платная часть проверяется до записи сообщения: при отказе ничего не записывается, а Idempotency-Key освобождается — повторите запрос после устранения причины с тем же ключом.

#Ошибки

СтатусКодКогдаЧто делать
422validation_failedнет текста, длиннее 4000 знаков, неизвестные поля; детали в errors[]Исправить запрос
413payload_too_largeтело больше 256 КБУменьшить тело
401invalid_api_key, wrong_cellключПроверить ключ и адрес ячейки
402spend_limit_reachedдостигнут предел трат (операции или суток)Дождаться окна или поднять предел
402wallet_suspendedобмен остановлен: баланс кошелька отрицателен или кошелёк заблокирован; в ответе topup_url и balanceПополнить кошелёк
403scope_missing, pii_transfer_not_allowedнет conversations:write; на RU — не выполнены условия выдачи ПДнПроверить области и настройки интеграции
404not_foundдиалога нет, он чужой или другого каналаПроверить идентификатор
409channel_not_configuredканал чата не подключёнПодключить в кабинете
409invalid_stateдиалог закрытЗавести новый диалог
409idempotency_conflictтот же ключ с другим теломНовый ключ для новой реплики
409operation_in_progressпараллельный повторПодождать Retry-After и повторить
429rate_limitedпредел частотыПодождать Retry-After
429concurrency_limitуже идут 5 ответов ИИ на интеграцию или на арендатораДождаться завершения и повторить с тем же ключом
503upstream_unavailableнедоступна языковая модельПовторить с тем же ключом
503service_unavailableнедоступно хранилище пределов; есть Retry-AfterПовторить с тем же ключом

Общий разбор классов — Соглашения.

#Причины отсутствия ответа ИИ

Платформа принимает сообщение клиента всегда, когда запрос корректен. ИИ может не ответить — и это не сбой. Состояние ответа ИИ возвращается полем ai_reply в теле успешного ответа на сообщение клиента.

ai_reply — объект {status, reason?}:

statusСмысл
repliedИИ ответил: ответ — отдельное сообщение диалога (reply.id)
queuedответ готовится; результат — по operation или событием message.created
queued_for_reviewответ ИИ стал черновиком и ждёт оператора (режим с проверкой): клиенту он не уйдёт, пока оператор не одобрит
skippedИИ не ответит; причина в reason
reason (при skipped)СмыслЧто делать вашему серверу
operator_activeдиалог ведёт оператор; ИИ не отвечаетНичего: оператор ответит. Ответ придёт событием message.created
spamсообщение признано спамомНе пересылать клиенту автоответ; при необходимости — журнал
flow_replyответил сценарий (поток), а не ИИВзять ответ сценария из диалога
ai_unavailable_handoffИИ недоступен; диалог передан операторуСообщить клиенту, что ответит сотрудник
no_agentдля канала не назначен агентНазначить агента в кабинете
daily_limitисчерпан суточный предел ответов ИИ в каналеСообщение сохранено, диалог получил состояние handoff_pending, ответ ИИ не запрашивался и не оплачивался. Сообщить клиенту, что ответит сотрудник
insufficient_fundsна кошельке не хватило средств на ответ ИИСообщение сохранено, ИИ не вызывался. Пополнить кошелёк; клиенту сообщить, что ответит сотрудник
upstream_unavailableязыковая модель не ответилаСообщение сохранено; повторять запрос не нужно — ответит оператор или следующая реплика
quota_exceededисчерпана квота ответов ИИ по тарифуПоднять тариф или дождаться нового периода
not_requestedвы не просили ответ ИИ: ai_reply: none или у ключа нет ai_seller:invokeНичего; при необходимости добавьте область ai_seller:invoke

Список причин может расти: ветвитесь по известным значениям и обрабатывайте неизвестное как «ответа ИИ не будет».

Для канала api суточный предел работает так же, как у виджета: значение берётся из настройки канала ai_daily_limit (по умолчанию 200 в сутки, максимум 10 000); в событии conversation.handoff_requested диалог попадает к оператору (События).

#GET /operations/{id}

Статус: Доступно. Отдельной области не требует: нужна та же область, что и у создавшего операцию запроса (для ответа ИИ-продажника — ai_seller:invoke).

Состояние асинхронной операции, созданной вашей интеграцией (например, conversation.ai_reply из ответа 202). Возвращает конверт {operation}: состояние queued, running, succeeded, failed или canceled, ресурс результата (resource) и стоимость (usage) для платных операций.

curl -sS "$API_BASE_URL/operations/5e0c9d52-7b1a-4c3d-8e21-0f4a6b7c8d90" \
  -H "Authorization: Bearer $API_KEY"

Операция другой интеграции или другого арендатора — 404 not_found. Вместо частого опроса подпишитесь на событие operation.completed (События). Подробности конверта — Соглашения.

#POST /conversations/{id}/cards

Статус: Доступно. Область conversations:write. Idempotency-Key обязателен.

Отправить в диалог одну—три карточки товаров с кнопками: сообщение от имени оператора. Карточки создаются заранее — в кабинете или через API карточек.

ПолеТипОбязательныйОграниченияПример
codesмассив строкдакоды карточек, от 1 до 3["hydrolat-basics"]
commentстрока или nullнеттекст над карточками, до 1000 знаковПодобрал для вас курс
curl -sS -X POST "$API_BASE_URL/conversations/7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01/cards" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: 2f9a1c77-5d3e-4b8a-9c01-7e6d5a4b3c21" \
  -H "Content-Type: application/json" \
  -d '{"codes":["hydrolat-basics"],"comment":"Подобрал для вас курс"}'

Ответ 201:

{
  "message_id": "1a2b3c4d-0003-4000-8000-000000000003",
  "conversation_id": "7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01",
  "created_at": "2026-10-12T08:20:00Z",
  "cards": [
    {
      "code": "hydrolat-basics",
      "name": "Основы гидролатов",
      "description": null,
      "image_url": "https://example.com/img/hydrolat.jpg",
      "price_text": "9 900 ₽",
      "badge": "Хит",
      "page_url": "https://example.com/courses/hydrolat-basics",
      "buttons": [{ "label": "Подробнее", "url": "https://example.com/courses/hydrolat-basics" }]
    }
  ],
  "delivery": "metadata",
  "status": "sent"
}

delivery показывает, как карточки доходят до клиента: metadata — виджет сайта и канал интеграции (вы получаете событие message.created); native — Telegram, VK и Max своим форматом; text — прочие каналы строками «название — цена — ссылка». status: sent, delivered или failed.

Карточка в сообщении — копия на момент отправки, адреса развёрнуты до полных. Неизвестный или неактивный код — 422 validation_failed; диалог другого арендатора, кабинета ученика или личного ящика — 404 not_found. Повтор того же Idempotency-Key возвращает то же сообщение.

#API арендатора: виджет и карточки

Настройки виджета и каталог карточек можно вести из вашей системы. Полные описания — в справочнике API.

МаршрутОбластьЧто делает
GET /channels/{id}/widgetchannels:readпрочитать настройки виджета канала (название и подзаголовок помощника, очередь, подпись, файлы, цвет)
PATCH /channels/{id}/widgetchannels:writeизменить настройки; null восстанавливает значение по умолчанию; изменения пишутся в аудит
GET /cards, GET /cards/{code}cards:readсписок и одна карточка
POST /cards, PATCH /cards/{code}, DELETE /cards/{code}cards:writeсоздать, изменить, удалить карточку
POST /conversations/{id}/cardsconversations:writeотправить карточки в диалог (выше)

Что означают настройки виджета и как выглядят состояния — Встраивание виджета.

#POST /conversations/{id}/operator-messages

Статус: Планируется. Область conversations:write. Idempotency-Key — рекомендуется.

Ответ оператора, введённый в вашей системе. Сообщение пишется в диалог от имени оператора и уходит клиенту событием message.created через ваш вебхук.

ПолеТипОбязательныйОграниченияПример
contentстрокадаЗдравствуйте! Меня зовут Анна.
operator_idстроканетидентификатор сотрудника (предварительно)usr_EXAMPLE01
curl -sS -X POST "$API_BASE_URL/conversations/cnv_EXAMPLE01/operator-messages" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: 0a1f6f7b-8e0d-47c5-9f53-5a1b27c9d3e4" \
  -H "Content-Type: application/json" \
  -d '{"content":"Здравствуйте! Меня зовут Анна."}'

#Перехват, возврат ИИ и закрытие

Статус: Планируется. Область conversations:write.

МаршрутДействиеПосле вызова
POST /conversations/{id}/takeoverОператор берёт диалогmode=operator; ИИ перестаёт отвечать
POST /conversations/{id}/releaseОператор возвращает диалог ИИmode=ai; следующие реплики клиента получают ответ ИИ
POST /conversations/{id}/closeЗакрыть диалогдиалог закрыт

Тела запросов в проекте не описаны. Повтор вызова должен быть безопасным; Idempotency-Key передавайте в любом случае. Действие, недопустимое в текущем состоянии диалога (например, takeover закрытого диалога), возвращает 409 invalid_state.

curl -sS -X POST "$API_BASE_URL/conversations/cnv_EXAMPLE01/takeover" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: c2d6f3a9-4b0e-4a8b-8b7f-2d9a6e1f0c55"

#События

Получайте результат вебхуками вместо опроса (Вебхуки). Конверт события — {id, type, api_version, created_at, livemode, data}.

СобытиеКогдаВ data
conversation.createdсоздан диалог (в том числе вызовом POST /conversations)conversation_id; полная нагрузка с ПДн — только при include_pii: true
message.createdв диалоге появилось сообщение: клиента, ИИ или оператора, а также сообщение с карточкамиconversation_id, message_id, sender; для сообщения клиента — ai_reply: {status, reason?}. Текстов, вложений и карточек в событии нет
conversation.handoff_requestedзапрошена передача оператору (в том числе по daily_limit и ai_unavailable_handoff)conversation_id, lead_id (если у диалога есть лид)
conversation.summary_readyготова сводка диалогаconversation_id
operation.completedзавершился ответ ИИ (succeeded, failed, canceled)конверт operation (идентификаторы, состояние, usage.cost; без текста ответа)

Событие передачи оператору одно — conversation.handoff_requested; имя handoff.requested относится к вебхукам версии 1 и в v2 не используется.

События message.created и operation.completed «тонкие» всегда: текст сообщения, вложения и карточки в них не входят, даже при include_pii: true. Содержимое получайте запросом GET /conversations/{id}/messages с нужными fields= (Минимизация и выбор полей): так выдача попадёт в журнал выдач. Флаг include_pii подписки добавляет ПДн в conversation.created, conversation.handoff_requested и conversation.summary_ready; для него у интеграции нужны conversations:read и leads:read (Вебхуки).

Событие message.created приходит и на сообщения оператора, и на ответы ИИ. Чтобы не отправить клиенту собственное сообщение дважды, сравнивайте message_id с тем, что вы уже обработали.

#Примеры на четырёх языках

Во всех примерах ключ и адрес берутся из переменных окружения; идентификатор диалога — условный.

#curl

curl -sS -X POST "$API_BASE_URL/conversations/cnv_EXAMPLE01/messages?wait=15" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"content":"Сколько стоит курс?","ai_reply":"auto"}'

#JavaScript (Node.js 18+)

import { randomUUID } from 'node:crypto';

const BASE = process.env.API_BASE_URL;   // https://formula-cream.pro/api/public/v1
const KEY = process.env.API_KEY;

export async function sendCustomerMessage(conversationId, text, idempotencyKey = randomUUID()) {
  const res = await fetch(`${BASE}/conversations/${conversationId}/messages?wait=15`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${KEY}`,
      'Idempotency-Key': idempotencyKey,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ content: text, ai_reply: 'auto' })
  });

  const body = await res.json();
  if (!res.ok) {
    // application/problem+json: ветвимся по code (concurrency_limit, spend_limit_reached,
    // channel_not_configured ...)
    const err = new Error(`${body.code}: ${body.detail}`);
    err.code = body.code;
    err.requestId = body.request_id;
    throw err;
  }
  // 200 — body.ai_reply.status === 'replied', body.reply.id: текст читайте через
  //       GET /conversations/{id}/messages?fields=content
  // 202 — ответ ещё готовится (body.operation): ждите вебхук message.created
  //       или опрашивайте GET /operations/{id}
  // 201 — ИИ не запрашивался (ai_reply.reason === 'not_requested')
  return { status: res.status, body, idempotencyKey };
}

#Python (httpx)

import os
import uuid

import httpx

BASE = os.environ["API_BASE_URL"]  # https://formula-cream.pro/api/public/v1
KEY = os.environ["API_KEY"]


def send_customer_message(conversation_id: str, text: str, idempotency_key: str | None = None):
    key = idempotency_key or str(uuid.uuid4())
    res = httpx.post(
        f"{BASE}/conversations/{conversation_id}/messages",
        params={"wait": 15},
        headers={"Authorization": f"Bearer {KEY}", "Idempotency-Key": key},
        json={"content": text, "ai_reply": "auto"},
        timeout=30,
    )
    body = res.json()
    if res.status_code >= 400:
        # application/problem+json
        raise RuntimeError(f'{body.get("code")}: {body.get("detail")} (request {body.get("request_id")})')
    return res.status_code, body, key

#PHP

<?php
function sapport_send_customer_message(string $conversationId, string $text, ?string $idempotencyKey = null): array
{
    $base = getenv('API_BASE_URL'); // https://formula-cream.pro/api/public/v1
    $key  = getenv('API_KEY');
    $idempotencyKey ??= bin2hex(random_bytes(16));

    $ch = curl_init("$base/conversations/$conversationId/messages?wait=15");
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $key",
            "Idempotency-Key: $idempotencyKey",
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS     => json_encode(['content' => $text, 'ai_reply' => 'auto'], JSON_UNESCAPED_UNICODE),
    ]);

    $raw    = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    $body = json_decode($raw, true);
    if ($status >= 400) {
        throw new RuntimeException(($body['code'] ?? 'error') . ': ' . ($body['detail'] ?? ''));
    }
    return [$status, $body, $idempotencyKey];
}

#Заметки

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