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

#ИИ-продажник в своём канале

Статус: Планируется. Канал api и серверные маршруты диалогов описаны в проекте публичного API и ещё не работают. Руководство показывает целевую схему; имена полей тел запросов и ответов предварительные (серверный API диалогов). Не стройте на нём боевую интеграцию до выхода маршрутов — следите за журналом изменений.

Назначение: подключить к ИИ-продажнику Sapport собственный канал общения — свой мессенджер, мобильное приложение, чат-бот или систему обмена сообщениями. Клиент пишет у вас, ваш сервер передаёт реплику в Sapport, ИИ отвечает, ответ возвращается клиенту через вас.

#Содержание

  1. Когда использовать
  2. Схема
  3. Что понадобится
  4. Шаг 1. Ключ, области и вебхук
  5. Шаг 2. Внешний идентификатор клиента
  6. Шаг 3. Входящее сообщение и ответ
  7. Шаг 4. Доставка ответа клиенту
  8. Шаг 5. Передача оператору и возврат ИИ
  9. Оплата из кошелька и пределы
  10. Полный пример: Node.js и Express
  11. Полный пример: Python и FastAPI
  12. Проверка перед запуском
  13. Заметки
  14. Открытые вопросы

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

Не подходит, если нужен только чат на сайте: используйте виджет. Если вам нужен собственный интерфейс чата в браузере без своего сервера — клиентский Chat API.

#Схема

Клиент ──► ваш канал ──► ваш сервер
                              │  POST /conversations/{id}/messages
                              │  (Idempotency-Key, ?wait=15)
                              ▼
                          Sapport: ИИ-продажник
                              │
          ┌───────────────────┴──────────────────┐
          │ 200: ответ готов за ?wait            │ 202: operation «в работе»
          ▼                                      ▼
   вы отправляете ответ                  вебхук message.created
   клиенту сразу                         ► вы запрашиваете сообщение
                                          и отправляете клиенту

Ответ ИИ выполняется только асинхронно: запрос возвращает 202 и конверт operation, а ?wait=15 позволяет дождаться того же задания. Все ответы, в том числе ответы оператора из кабинета Sapport, приходят вам событием message.created. Считайте вебхук основным способом доставки, а ответ в пределах wait — удобным сокращением.

#Что понадобится

ЧтоЗачем
Тариф с признаком api_accessбез него feature_not_in_plan
Ключ интеграции с областями conversations:read, conversations:write, ai_seller:invokeчтение диалога, приём сообщения, вызов ИИ
Общедоступный адрес вашего сервера по HTTPSприём вебхуков (требования к адресу)
Положительный баланс кошелькаответ ИИ — платная операция
Агент ИИ-продажника, назначенный каналу apiвыбирается в кабинете при создании интеграции; иначе ai_reply.reason = no_agent
Для RU: список разрешённых IP ключа, страна размещения вашей системы и основание передачиобязательны для областей с ПДн; при стране вне РФ области с ПДн не выдаются, ошибка 403 pii_transfer_not_allowed (Ячейки и данные)

#Шаг 1. Ключ, области и вебхук

  1. В кабинете создайте интеграцию и ключ (Аутентификация). Выдайте только нужные области. Для тестирования используйте тестовый ключ: он не списывает деньги (тестовые ключи).
  2. Создайте подписку на события message.created и conversation.handoff_requested (при желании — operation.completed) с адресом вашего сервера (вебхуки). Секрет подписи показывается один раз — сохраните его в своём хранилище секретов.
  3. Канал api создаётся в кабинете вместе с интеграцией: одна интеграция — один канал api. Агента ИИ-продажника для канала выберите там же. Идентификатор канала возвращает GET /me (channel_id).

Ключ хранится только на сервере: в переменной окружения или хранилище секретов. Не помещайте его в мобильное приложение и в страницу.

#Шаг 2. Внешний идентификатор клиента

Для каждого клиента вашего канала выберите стабильный внешний идентификатор: идентификатор пользователя в вашей системе, а не телефон и не почту. Платформа по нему находит или создаёт контакт и диалог.

curl -sS -X POST "$SAPPORT_BASE_URL/conversations" \
  -H "Authorization: Bearer $SAPPORT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"external_customer_id":"client-4815","name":"Анна"}'

Сохраните у себя соответствие client-4815 → cnv_EXAMPLE01, чтобы не создавать диалог повторно на каждое сообщение. Если соответствие потеряно, повторный вызов с тем же внешним идентификатором должен вернуть тот же диалог (проект: диалог ищется или создаётся по внешнему идентификатору).

Передавайте только данные, которые вам разрешено передавать, и только по основанию. Диалоги содержат персональные данные клиентов; для ячейки RU при подключении указываются страна размещения вашей системы (ПДн передаются только получателям в РФ) и основание передачи, а выдачи ПДн попадают в журнал, видимый арендатору (Ячейки и данные).

#Шаг 3. Входящее сообщение и ответ

На каждую реплику клиента:

  1. Сформируйте Idempotency-Key. Сделайте его производным от вашей реплики (например, идентификатор сообщения в вашем канале), чтобы повтор после сбоя использовал тот же ключ.
  2. Вызовите POST /conversations/{id}/messages?wait=15.
  3. Разберите результат:
РезультатЧто делать
200, ai_reply.status = repliedотправьте текст reply.content клиенту
200, ai_reply.status = skippedсм. причины отсутствия ответа
202, ai_reply.status = queued, в теле operationждите вебхук message.created либо опросите GET /operations/{id}; повтор запроса с тем же ключом возвращает ту же операцию
402пополните кошелёк; сообщение клиента без ответа ИИ можно принять отдельно (только conversations:write)
429, 503 (service_unavailable, upstream_unavailable)подождите Retry-After и повторите с тем же ключом
прочие 4xxисправьте запрос (каталог ошибок)

Не отправляйте клиенту собственное сообщение дважды: ответ может прийти и синхронно, и вебхуком. Запоминайте идентификаторы уже доставленных сообщений.

#Шаг 4. Доставка ответа клиенту

Подписка на message.created вызывает ваш адрес на каждое новое сообщение диалога: клиента, ИИ и оператора.

  1. Проверьте подпись на сыром теле (алгоритм проверки).
  2. Проверьте, что Sapport-Event-Id ещё не обрабатывался.
  3. Нагрузка message.created «тонкая» всегда: идентификаторы без текста сообщения. Запросите сообщение GET /conversations/{conversation_id}/messages и выберите нужное.
  4. Для сообщения клиента в событии есть ai_reply: {status, reason?}. Пропустите сообщения клиента (вы их сами и отправили) и доставьте ответы ИИ и оператора вашему клиенту.
  5. Ответьте 2xx быстро, остальную работу поставьте в очередь.

Флаг подписки include_pii добавляет ПДн в события conversation.*, но не текст сообщений: message.created остаётся «тонким» (Вебхуки).

#Шаг 5. Передача оператору и возврат ИИ

СитуацияЧто происходитЧто делает ваш сервер
ИИ запросил оператора или исчерпан суточный предел (daily_limit)приходит conversation.handoff_requested; ИИ перестаёт отвечатьпоказать диалог вашему оператору, сообщить клиенту
Оператор берёт диалог у васPOST /conversations/{id}/takeoverдалее вызывайте POST …/operator-messages
Оператор пишет клиентуPOST /conversations/{id}/operator-messagesдоставьте клиенту сообщение, придёт message.created
Оператор возвращает диалог ИИPOST /conversations/{id}/releaseследующие реплики клиента снова получают ответ ИИ
Диалог завершёнPOST /conversations/{id}/close

Пока диалог ведёт оператор, на реплики клиента ИИ не отвечает, а ответ POST …/messages содержит ai_reply: {status: "skipped", reason: "operator_active"}. Сообщение при этом принимается и видно оператору.

#Оплата из кошелька и пределы

#Полный пример: Node.js и Express

Пример сводит вместе приём реплик вашего канала и вебхук Sapport. Хранилище соответствий и список обработанных событий — в памяти, для боя замените на базу. Функции deliverToCustomer и onCustomerMessage подключаются к вашему каналу.

import crypto from 'node:crypto';
import express from 'express';

const BASE = process.env.SAPPORT_BASE_URL;        // https://formula-cream.pro/api/public/v1
const KEY = process.env.SAPPORT_API_KEY;
const WEBHOOK_SECRET = process.env.SAPPORT_WEBHOOK_SECRET;
const TOLERANCE_SEC = 300;

const conversations = new Map();   // внешний id клиента -> id диалога (замените на БД)
const delivered = new Set();       // id сообщений, уже отправленных клиенту
const seenEvents = new Set();      // Sapport-Event-Id

async function api(path, { method = 'GET', key, body } = {}) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${KEY}`,
      'Content-Type': 'application/json',
      ...(key ? { 'Idempotency-Key': key } : {})
    },
    body: body ? JSON.stringify(body) : undefined
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    const err = new Error(`${data.code ?? res.status}: ${data.detail ?? ''}`);
    err.status = res.status;
    err.code = data.code;
    throw err;
  }
  return { status: res.status, data };
}

async function conversationFor(customerId, name) {
  if (conversations.has(customerId)) return conversations.get(customerId);
  const { data } = await api('/conversations', {
    method: 'POST',
    key: `conv-${customerId}`,
    body: { external_customer_id: customerId, name }
  });
  conversations.set(customerId, data.id);
  return data.id;
}

// Вызывается вашим каналом на каждую реплику клиента.
// ourMessageId — идентификатор реплики в вашем канале.
export async function onCustomerMessage(customerId, name, ourMessageId, text) {
  const conversationId = await conversationFor(customerId, name);
  // Ключ производный от реплики: повтор после сбоя безопасен
  const { status, data } = await api(`/conversations/${conversationId}/messages?wait=15`, {
    method: 'POST',
    key: `msg-${ourMessageId}`,
    body: { content: text }
  });
  if (status === 200 && data.ai_reply?.status === 'replied' && !delivered.has(data.reply.id)) {
    delivered.add(data.reply.id);
    await deliverToCustomer(customerId, data.reply.content);
  }
  // 202 (ai_reply.status = 'queued', data.operation) — ответ придёт вебхуком message.created
  // ai_reply.status = 'skipped' — ИИ не отвечает, причина в data.ai_reply.reason
}

// Подключите к своему каналу
async function deliverToCustomer(customerId, text) {
  console.log('→ клиенту', customerId, text);
}

const app = express();
app.post('/sapport', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
  const header = req.get('Sapport-Signature') || '';
  const parts = header.split(',').map((p) => p.trim());
  const t = Number(parts.find((p) => p.startsWith('t='))?.slice(2));
  const sigs = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
  if (!t || !sigs.length || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SEC) return res.sendStatus(400);

  const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(`${t}.`).update(req.body).digest();
  const ok = sigs.some((hex) => {
    const got = Buffer.from(hex, 'hex');
    return got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
  if (!ok) return res.sendStatus(400);

  const eventId = req.get('Sapport-Event-Id');
  if (seenEvents.has(eventId)) return res.sendStatus(200);
  seenEvents.add(eventId);

  res.sendStatus(200);                       // отвечаем быстро
  const event = JSON.parse(req.body.toString('utf8'));
  handle(event).catch((e) => console.error('handle', event.id, e.message));
});

async function handle(event) {
  if (event.type !== 'message.created') return;
  const { conversation_id } = event.data;   // data: идентификаторы, без текста
  const { data } = await api(`/conversations/${conversation_id}/messages?limit=10`);
  const customerId = [...conversations].find(([, id]) => id === conversation_id)?.[0];
  if (!customerId) return;
  for (const m of data.data) {
    if (m.sender === 'customer' || delivered.has(m.id)) continue;   // значения sender условные
    delivered.add(m.id);
    await deliverToCustomer(customerId, m.content);
  }
}

app.listen(3000);

#Полный пример: Python и FastAPI

import hashlib
import hmac
import os
import time

import httpx
from fastapi import FastAPI, HTTPException, Request

BASE = os.environ["SAPPORT_BASE_URL"]            # https://formula-cream.pro/api/public/v1
KEY = os.environ["SAPPORT_API_KEY"]
WEBHOOK_SECRET = os.environ["SAPPORT_WEBHOOK_SECRET"].encode()
TOLERANCE = 300

conversations: dict[str, str] = {}               # внешний id клиента -> id диалога (замените на БД)
delivered: set[str] = set()
seen_events: set[str] = set()

app = FastAPI()
client = httpx.AsyncClient(
    base_url=BASE, headers={"Authorization": f"Bearer {KEY}"}, timeout=30
)


async def api(method: str, path: str, key: str | None = None, **kw):
    headers = {"Idempotency-Key": key} if key else {}
    res = await client.request(method, path, headers=headers, **kw)
    data = res.json() if res.content else {}
    if res.status_code >= 400:
        raise RuntimeError(f'{data.get("code", res.status_code)}: {data.get("detail", "")}')
    return res.status_code, data


async def conversation_for(customer_id: str, name: str | None) -> str:
    if customer_id not in conversations:
        _, data = await api(
            "POST", "/conversations", key=f"conv-{customer_id}",
            json={"external_customer_id": customer_id, "name": name},
        )
        conversations[customer_id] = data["id"]
    return conversations[customer_id]


async def deliver_to_customer(customer_id: str, text: str):
    print("-> клиенту", customer_id, text)         # подключите к своему каналу


async def on_customer_message(customer_id: str, name: str | None, our_message_id: str, text: str):
    """Вызывается вашим каналом на каждую реплику клиента."""
    conversation_id = await conversation_for(customer_id, name)
    status, data = await api(
        "POST", f"/conversations/{conversation_id}/messages",
        key=f"msg-{our_message_id}", params={"wait": 15}, json={"content": text},
    )
    ai_reply = (data or {}).get("ai_reply", {})
    reply = (data or {}).get("reply")
    if status == 200 and ai_reply.get("status") == "replied" and reply["id"] not in delivered:
        delivered.add(reply["id"])
        await deliver_to_customer(customer_id, reply["content"])
    # 202 (queued, data["operation"]) — ждите вебхук message.created
    # skipped — ИИ не отвечает, причина в ai_reply["reason"]


def verify(raw: bytes, header: str) -> bool:
    parts = [p.strip() for p in header.split(",")]
    t = next((p[2:] for p in parts if p.startswith("t=")), None)
    sigs = [p[3:] for p in parts if p.startswith("v1=")]
    if not t or not sigs or abs(time.time() - int(t)) > TOLERANCE:
        return False
    expected = hmac.new(WEBHOOK_SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)


@app.post("/sapport")
async def sapport_webhook(request: Request):
    raw = await request.body()                   # сырое тело
    if not verify(raw, request.headers.get("Sapport-Signature", "")):
        raise HTTPException(400, "bad signature")
    event_id = request.headers.get("Sapport-Event-Id", "")
    if event_id in seen_events:
        return {"ok": True}
    seen_events.add(event_id)

    event = await request.json()
    if event.get("type") == "message.created":
        conversation_id = event["data"]["conversation_id"]
        customer_id = next((c for c, i in conversations.items() if i == conversation_id), None)
        if customer_id:
            _, data = await api("GET", f"/conversations/{conversation_id}/messages", params={"limit": 10})
            for m in data["data"]:
                if m["sender"] == "customer" or m["id"] in delivered:   # значения sender условные
                    continue
                delivered.add(m["id"])
                await deliver_to_customer(customer_id, m["content"])
    return {"ok": True}

Запуск: uvicorn app:app --port 3000. Для приёма в боевом режиме поставьте перед сервером HTTPS-прокси.

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

  1. Тестовым ключом отправьте реплику и убедитесь, что ответ пришёл и деньги не списаны.
  2. Отправьте реплику дважды с одним Idempotency-Key — второй раз ответ должен совпасть, дубля в диалоге быть не должно.
  3. Отправьте тот же ключ с другим текстом — ожидайте 409 idempotency_conflict.
  4. Включите перехват (takeover) и убедитесь, что ai_reply равен {status: "skipped", reason: "operator_active"}.
  5. Остановите приёмник вебхуков, затем запустите: пропущенные доставки повторяются по расписанию из 8 повторов, самый поздний — через 24 часа (доставка и повторы); недоставленное видно в журнале доставок, его можно переотправить.
  6. Проверьте обработку 402, 429, 503 и потерю соединения во время ?wait=15 (после обрыва узнайте результат по GET /operations/{id}).
  7. Убедитесь, что ключ и секрет вебхука не попадают в журналы и репозиторий.

#Заметки

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