#ИИ-продажник в своём канале
Статус: Планируется. Канал
apiи серверные маршруты диалогов описаны в проекте публичного API и ещё не работают. Руководство показывает целевую схему; имена полей тел запросов и ответов предварительные (серверный API диалогов). Не стройте на нём боевую интеграцию до выхода маршрутов — следите за журналом изменений.
Назначение: подключить к ИИ-продажнику Sapport собственный канал общения — свой мессенджер, мобильное приложение, чат-бот или систему обмена сообщениями. Клиент пишет у вас, ваш сервер передаёт реплику в Sapport, ИИ отвечает, ответ возвращается клиенту через вас.
#Содержание
- Когда использовать
- Схема
- Что понадобится
- Шаг 1. Ключ, области и вебхук
- Шаг 2. Внешний идентификатор клиента
- Шаг 3. Входящее сообщение и ответ
- Шаг 4. Доставка ответа клиенту
- Шаг 5. Передача оператору и возврат ИИ
- Оплата из кошелька и пределы
- Полный пример: Node.js и Express
- Полный пример: Python и FastAPI
- Проверка перед запуском
- Заметки
- Открытые вопросы
#Когда использовать
- У вас собственный канал, которого нет среди встроенных: корпоративный мессенджер, приложение, SMS-шлюз, интеграция с партнёрской платформой.
- Вам нужно решать самим, где хранить переписку на своей стороне, как показывать ответы и как идентифицировать клиентов.
Не подходит, если нужен только чат на сайте: используйте виджет. Если вам нужен собственный интерфейс чата в браузере без своего сервера — клиентский 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. Ключ, области и вебхук
- В кабинете создайте интеграцию и ключ (Аутентификация). Выдайте только нужные области. Для тестирования используйте тестовый ключ: он не списывает деньги (тестовые ключи).
- Создайте подписку на события
message.createdиconversation.handoff_requested(при желании —operation.completed) с адресом вашего сервера (вебхуки). Секрет подписи показывается один раз — сохраните его в своём хранилище секретов. - Канал
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. Входящее сообщение и ответ
На каждую реплику клиента:
- Сформируйте
Idempotency-Key. Сделайте его производным от вашей реплики (например, идентификатор сообщения в вашем канале), чтобы повтор после сбоя использовал тот же ключ. - Вызовите
POST /conversations/{id}/messages?wait=15. - Разберите результат:
| Результат | Что делать |
|---|---|
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 вызывает ваш адрес на каждое новое сообщение диалога: клиента, ИИ и оператора.
- Проверьте подпись на сыром теле (алгоритм проверки).
- Проверьте, что
Sapport-Event-Idещё не обрабатывался. - Нагрузка
message.created«тонкая» всегда: идентификаторы без текста сообщения. Запросите сообщениеGET /conversations/{conversation_id}/messagesи выберите нужное. - Для сообщения клиента в событии есть
ai_reply: {status, reason?}. Пропустите сообщения клиента (вы их сами и отправили) и доставьте ответы ИИ и оператора вашему клиенту. - Ответьте
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"}. Сообщение при этом принимается и видно оператору.
#Оплата из кошелька и пределы
- Что платно. Ответ ИИ (
ai_seller:invoke). Приём сообщения клиента, ответы оператора, перехват, возврат и закрытие бесплатны. - Откуда деньги. С кошелька арендатора. Конверт
operationзавершённой операции содержитusage: {cost: {amount, currency}, model}в валюте кошелька (стоимость в ответе). - Повтор не списывает второй раз. Тот же
Idempotency-Keyвозвращает ту же операцию. - Защита от расходов. Резерв перед выполнением, предел трат на арендатора и на интеграцию, не более 5 одновременных платных операций на арендатора, максимум стоимости одной операции (по умолчанию эквивалент 100 ₽), предохранитель кошелька (платные операции). Значения задаются в кабинете, действующие видны в
GET /me. - Суточный предел ответов ИИ. Исчерпан — сообщение сохраняется, ответ ИИ не запрашивается и не оплачивается, диалог получает
handoff_pending, аai_replyравен{status: "skipped", reason: "daily_limit"}. - Пределы частоты (стартовые). Чтение 600/мин, запись 120/мин, платные 30/мин. Это нижняя граница пропускной способности: при ~30 репликах клиентов в минуту на ключ ограничивайте поток очередью у себя и используйте несколько интеграций только по согласованию с арендатором.
- Ошибки денег.
insufficient_funds(402) иspend_limit_reached(402): сообщения клиентов принимайте, ответ клиенту отложите или передайте оператору.
#Полный пример: 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-прокси.
#Проверка перед запуском
- Тестовым ключом отправьте реплику и убедитесь, что ответ пришёл и деньги не списаны.
- Отправьте реплику дважды с одним
Idempotency-Key— второй раз ответ должен совпасть, дубля в диалоге быть не должно. - Отправьте тот же ключ с другим текстом — ожидайте
409 idempotency_conflict. - Включите перехват (
takeover) и убедитесь, чтоai_replyравен{status: "skipped", reason: "operator_active"}. - Остановите приёмник вебхуков, затем запустите: пропущенные доставки повторяются по расписанию из 8 повторов, самый поздний — через 24 часа (доставка и повторы); недоставленное видно в журнале доставок, его можно переотправить.
- Проверьте обработку
402,429,503и потерю соединения во время?wait=15(после обрыва узнайте результат поGET /operations/{id}). - Убедитесь, что ключ и секрет вебхука не попадают в журналы и репозиторий.
#Заметки
- Один канал — одна роль. Канал
apiне получает общий промпт площадки: ИИ отвечает по настройкам вашего агента и базе знаний вашего арендатора. - Порядок событий не гарантирован. Опирайтесь на идентификаторы сообщений и время создания.
- Вебхук важнее опроса. Опрос
GET …/messagesгодится для сверки, но тратит пределы чтения. - Разные ячейки — разные ключи и адреса. Ключ RU не работает на EN и ID, как и обратное (Аутентификация).
- Тексты клиента — ПДн. Не пишите их в журналы без необходимости.
#Открытые вопросы
- Окончательные имена полей ответа (
reply,message, значенияsender) — только в OpenAPI. Техническое. - Параметры фильтра сообщений по времени и размеру страницы для быстрой сверки после вебхука. Техническое.