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

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

Статус: Доступно (чтение) / Планируется (запись). Три маршрута чтения — GET /conversations, GET /conversations/{id} и GET /conversations/{id}/messages — работают. Маршруты записи (POST …) описаны в проекте и ещё не работают: вызовы вернут 404. Для записи имена полей тел запросов и ответов, кроме ai_reply и конверта operation, помечены как предварительные и подлежат сверке с OpenAPI при выпуске.

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

#Содержание

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

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

#Доступ

Базовый адрес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; агент выбирается там же. Идентификатор канала возвращает GET /me (channel_id)

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

ОбластьЧто даётПлатнаяСодержит ПДн
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 (асинхронные операции)
Платные операцииответ ИИ списывается с кошелька арендатора; возможны insufficient_funds (402), spend_limit_reached (402); стоимость — в usage.cost (платные операции)
Пределы частотычтение 600/мин, запись 120/мин, платные 30/мин; не более 5 одновременных платных операций на арендатора (стартовые значения, действующие — в GET /me)
Идентификаторынепрозрачные строки; в примерах cnv_EXAMPLE01, msg_EXAMPLE01, evt_EXAMPLE01

#Объекты

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

#Диалог

ПолеТипОписание
idстрока (uuid)идентификатор диалога
channelстрока или nullтип канала: api, telegram, website …
contact_idстрока или nullидентификатор контакта (клиента) в Sapport
external_customer_idстрока или nullидентификатор клиента в вашей системе; отдаётся только у канала 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; по нему идёт обход. Счётчик непрочитанного его не меняет

#Сообщение

ПолеТипОписание
idстрока (uuid)идентификатор сообщения
conversation_idстрокадиалог
sendercustomer, ai, operator, systemотправитель: клиент, ИИ, оператор, служебное
content_typeстрокавид содержимого: text, image, file …
contentстрока или nullтекст; null — текста нет (только вложение) или сообщение удалено
attachmentsмассиввложения (сейчас не больше одного)
deletedлогическоеtrue — сообщение удалено (приходит только при include_deleted=true, без текста и вложений)
created_atстрокавремя сообщения, ISO 8601 UTC

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

Персональные данные и журнал выдач. Каждая выдача записывается в журнал выдач (см. ячейки и данные). На ячейке 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…
curl -sS "$SAPPORT_BASE_URL/conversations?channel=api&mode=operator&limit=50" \
  -H "Authorization: Bearer $SAPPORT_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
}

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

#GET /conversations/{id}

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

Один диалог. Если диалога нет или он принадлежит другому арендатору — 404 not_found.

curl -sS "$SAPPORT_BASE_URL/conversations/7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01" \
  -H "Authorization: Bearer $SAPPORT_API_KEY"

#GET /conversations/{id}/messages

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

Сообщения диалога постранично: limit (по умолчанию 25, максимум 100), cursor, include_deleted. Порядок возрастающий по (created_at, id) — от старых к новым.

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

{
  "data": [
    { "id": "1a2b3c4d-0001-4000-8000-000000000001", "conversation_id": "7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01", "sender": "customer", "content_type": "text", "content": "Сколько стоит курс?", "attachments": [], "deleted": false, "created_at": "2026-10-11T08:15:30Z" },
    { "id": "1a2b3c4d-0002-4000-8000-000000000002", "conversation_id": "7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01", "sender": "ai", "content_type": "text", "content": "Курс стоит 9 900 ₽.", "attachments": [], "deleted": false, "created_at": "2026-10-11T08:15:33Z" }
  ],
  "next_cursor": null
}

#POST /conversations

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

Завести диалог из вашего канала (канал api). Один клиент вашей системы — один внешний идентификатор; повторный вызов с тем же внешним идентификатором должен возвращать тот же диалог (платформа ищет или создаёт диалог по внешнему идентификатору).

ПолеТипОбязательныйОграниченияПример
external_customer_idстрокадастабильный идентификатор клиента в вашем каналеclient-4815
nameстроканетимя клиентаАнна
emailстроканет[email protected]
phoneстроканет+79990001122
pageобъектнетконтекст: адрес, заголовок, type, id{"type":"course","id":"hydrolat-basics"}

Имена полей предварительные.

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

Ответ — объект диалога (201; повтор с тем же ключом — тот же диалог).

#POST /conversations/{id}/messages

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

Передать сообщение клиента. Если диалог ведёт ИИ, платформа запускает ИИ-продажника.

#Запрос

ПолеТипОбязательныйОграниченияПример
contentстрокадатекст реплики; предел длины уточняется (для виджета — 4000 знаков)Сколько стоит курс?
attachmentsмассивнетвложения (форма не зафиксирована)—
Параметр адресаТипОписание
waitцелое от 0 до 20секунды ожидания ответа ИИ. Не дождались — вернётся 202 с тем же заданием
ЗаголовокОбязательныйОписание
Idempotency-Keyдауникальная строка на каждую новую реплику клиента; повтор с тем же ключом не создаёт дубль и не списывает деньги второй раз
curl -sS -X POST "$SAPPORT_BASE_URL/conversations/cnv_EXAMPLE01/messages?wait=15" \
  -H "Authorization: Bearer $SAPPORT_API_KEY" \
  -H "Idempotency-Key: 8d4b2a40-1c63-4d4e-8f0a-6c2d3e1b7a90" \
  -H "Content-Type: application/json" \
  -d '{"content":"Сколько стоит курс?"}'

#Ответы

Ответ ИИ выполняется только асинхронно: запрос не держит соединение дольше wait. Формы ответа предварительные, кроме ai_reply и operation.

Принято, ответ ИИ готовится (202). ai_reply.status — queued, в operation — конверт асинхронной операции вида conversation.ai_reply:

{
  "message": {
    "id": "msg_EXAMPLE01",
    "conversation_id": "cnv_EXAMPLE01",
    "sender": "customer",
    "content": "Сколько стоит курс?",
    "created_at": "2026-10-11T08:15:30Z"
  },
  "ai_reply": { "status": "queued" },
  "operation": {
    "id": "op_EXAMPLE01",
    "type": "conversation.ai_reply",
    "status": "queued",
    "created_at": "2026-10-11T08:15:30Z",
    "resource": { "type": "message", "id": "msg_EXAMPLE01" }
  }
}

Ответ готов в пределах wait (200). ai_reply.status — replied, операция завершена, в ней стоимость:

{
  "message": {
    "id": "msg_EXAMPLE01",
    "conversation_id": "cnv_EXAMPLE01",
    "sender": "customer",
    "content": "Сколько стоит курс?",
    "created_at": "2026-10-11T08:15:30Z"
  },
  "ai_reply": { "status": "replied" },
  "reply": {
    "id": "msg_EXAMPLE02",
    "content": "Курс «Основы гидролатов» стоит 9 900 ₽. Хотите, расскажу, что входит?"
  },
  "operation": {
    "id": "op_EXAMPLE01",
    "type": "conversation.ai_reply",
    "status": "succeeded",
    "created_at": "2026-10-11T08:15:30Z",
    "completed_at": "2026-10-11T08:15:33Z",
    "resource": { "type": "message", "id": "msg_EXAMPLE01" },
    "usage": { "cost": { "amount": "0.42", "currency": "RUB" }, "model": "model-EXAMPLE" }
  }
}

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

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

Повтор того же запроса с тем же Idempotency-Key возвращает ту же операцию, новая не запускается.

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

{
  "message": {
    "id": "msg_EXAMPLE01",
    "conversation_id": "cnv_EXAMPLE01",
    "sender": "customer",
    "content": "Сколько стоит курс?",
    "created_at": "2026-10-11T08:15:30Z"
  },
  "ai_reply": { "status": "skipped", "reason": "operator_active" }
}

Объект ai_reply приходит и в событии message.created для сообщения клиента. См. следующий раздел.

#Ошибки

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

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

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

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

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

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

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

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

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

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

ПолеТипОбязательныйОграниченияПример
contentстрокадаЗдравствуйте! Меня зовут Анна.
operator_idстроканетидентификатор сотрудника (предварительно)usr_EXAMPLE01
curl -sS -X POST "$SAPPORT_BASE_URL/conversations/cnv_EXAMPLE01/operator-messages" \
  -H "Authorization: Bearer $SAPPORT_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 "$SAPPORT_BASE_URL/conversations/cnv_EXAMPLE01/takeover" \
  -H "Authorization: Bearer $SAPPORT_API_KEY" \
  -H "Idempotency-Key: c2d6f3a9-4b0e-4a8b-8b7f-2d9a6e1f0c55"

#События

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

СобытиеКогдаВ data
conversation.createdсоздан диалогconversation_id
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

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

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

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

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

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

#curl

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

#JavaScript (Node.js 18+)

import { randomUUID } from 'node:crypto';

const BASE = process.env.SAPPORT_BASE_URL;   // https://formula-cream.pro/api/public/v1
const KEY = process.env.SAPPORT_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 })
  });

  const body = await res.json();
  if (!res.ok) {
    // application/problem+json: ветвимся по code
    const err = new Error(`${body.code}: ${body.detail}`);
    err.code = body.code;
    err.requestId = body.request_id;
    throw err;
  }
  // 202 — ответ ещё готовится (body.operation): ждите вебхук message.created
  // или опрашивайте GET /operations/{id}
  return { status: res.status, body, idempotencyKey };
}

#Python (httpx)

import os
import uuid

import httpx

BASE = os.environ["SAPPORT_BASE_URL"]  # https://formula-cream.pro/api/public/v1
KEY = os.environ["SAPPORT_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},
        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('SAPPORT_BASE_URL'); // https://formula-cream.pro/api/public/v1
    $key  = getenv('SAPPORT_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], 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];
}

#Заметки

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