#ИИ-продажник в своём канале
Статус: Доступно для схемы «заведение диалога — сообщение клиента — ответ ИИ — вебхук» и карточек. Планируется: шаг 5 — ответ оператора из вашей системы, перехват, возврат ИИ и закрытие (серверный API диалогов).
Назначение: подключить к ИИ-продажнику Формула ИИ собственный канал общения — свой мессенджер, мобильное приложение, чат-бот или систему обмена сообщениями. Клиент пишет у вас, ваш сервер передаёт реплику в Формула ИИ, ИИ отвечает, ответ возвращается клиенту через вас.
#Содержание
- Когда использовать
- Схема
- Что понадобится
- Шаг 1. Ключ, области и вебхук
- Шаг 2. Внешний идентификатор клиента
- Шаг 3. Входящее сообщение и ответ
- Шаг 4. Доставка ответа клиенту
- Шаг 5. Передача оператору и возврат ИИ
- Оплата из кошелька и пределы
- Полный пример: Node.js и Express
- Полный пример: Python и FastAPI
- Проверка перед запуском
- Заметки
- Открытые вопросы
#Когда использовать
- У вас собственный канал, которого нет среди встроенных: корпоративный мессенджер, приложение, SMS-шлюз, интеграция с партнёрской платформой.
- Вам нужно решать самим, где хранить переписку на своей стороне, как показывать ответы и как идентифицировать клиентов.
Не подходит, если нужен только чат на сайте: используйте виджет. Если вам нужен собственный интерфейс чата в браузере без своего сервера — клиентский Chat API.
#Схема
Клиент ──► ваш канал ──► ваш сервер
│ POST /conversations/{id}/messages
│ (Idempotency-Key, ?wait=15)
▼
Платформа: ИИ-продажник
│
┌───────────────────┴──────────────────┐
│ 200: ответ готов за ?wait │ 202: operation «в работе»
▼ ▼
вы отправляете ответ вебхук message.created
клиенту сразу ► вы запрашиваете сообщение
и отправляете клиенту
Ответ ИИ выполняется только асинхронно: запрос возвращает 202 и конверт operation, а ?wait=15 позволяет дождаться того же задания. Все ответы, в том числе ответы оператора из кабинета Формула ИИ, приходят вам событием message.created. Считайте вебхук основным способом доставки, а ответ в пределах wait — удобным сокращением.
#Что понадобится
| Что | Зачем |
|---|---|
Тариф с признаком api_access | без него feature_not_in_plan |
Ключ интеграции с областями conversations:read, conversations:write, ai_seller:invoke | чтение диалога, приём сообщения, вызов ИИ |
| Общедоступный адрес вашего сервера по HTTPS | приём вебхуков (требования к адресу) |
| Положительный баланс кошелька | ответ ИИ — платная операция |
Агент ИИ-продажника, назначенный каналу api | выбирается в кабинете при подключении канала; иначе ai_reply.reason = no_agent |
| Подключённый канал чата | Настройки → API → «Подключить канал чата»; без него 409 channel_not_configured |
| Для RU: список разрешённых IP ключа, страна размещения вашей системы и основание передачи | обязательны для областей с ПДн; при стране вне РФ области с ПДн не выдаются, ошибка 403 pii_transfer_not_allowed (Ячейки и данные) |
#Шаг 1. Ключ, области и вебхук
- В кабинете создайте интеграцию и ключ (Аутентификация). Выдайте только нужные области. Для тестирования используйте тестовый ключ: он не списывает деньги (тестовые ключи).
- Создайте подписку на события
message.createdиconversation.handoff_requested(при желании —operation.completed) с адресом вашего сервера (вебхуки). Секрета у подписки нет: доставки подписаны платформой, вы проверяете подпись открытым ключом из набора JWKS (подпись). - Подключите канал чата: Настройки → API → «Подключить канал чата». Одна интеграция — один канал
api; агента ИИ-продажника выберите там же. Идентификатор канала возвращаетGET /me(channel_id). Пока канал не подключён, запись диалогов отвечает409 channel_not_configured.
Ключ хранится только на сервере: в переменной окружения или хранилище секретов. Не помещайте его в мобильное приложение и в страницу.
#Шаг 2. Внешний идентификатор клиента
Для каждого клиента вашего канала выберите стабильный внешний идентификатор: идентификатор пользователя в вашей системе, а не телефон и не почту. Платформа по нему находит или создаёт контакт и диалог.
curl -sS -X POST "$API_BASE_URL/conversations" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"external_customer_id":"client-4815","name":"Анна","language":"ru"}'
Idempotency-Key обязателен. Если у клиента уже есть активный диалог, ответ — он же (200); иначе создаётся новый (201) и приходит событие conversation.created. Сохраните у себя соответствие client-4815 → id диалога, чтобы не вызывать маршрут на каждое сообщение: потерянное соответствие восстанавливается тем же вызовом. Внешний идентификатор изолирован по интеграции: тот же external_customer_id у другой интеграции — другой клиент. Имя, почта и телефон пишутся только в пустые поля контакта.
Передавайте только данные, которые вам разрешено передавать, и только по основанию. Диалоги содержат персональные данные клиентов; для ячейки RU при подключении указываются страна размещения вашей системы (ПДн передаются только получателям в РФ) и основание передачи, а выдачи ПДн попадают в журнал, видимый арендатору (Ячейки и данные).
#Шаг 3. Входящее сообщение и ответ
На каждую реплику клиента:
- Сформируйте
Idempotency-Key. Сделайте его производным от вашей реплики (например, идентификатор сообщения в вашем канале), чтобы повтор после сбоя использовал тот же ключ. - Вызовите
POST /conversations/{id}/messages?wait=15. - Разберите результат:
| Результат | Что делать |
|---|---|
200, ai_reply.status = replied | в ответе только reply.id, без текста: прочитайте сообщение GET /conversations/{id}/messages?fields=sender,content и отправьте текст клиенту |
201 или 200, ai_reply.status = skipped | см. причины отсутствия ответа; 201 с not_requested — вы не просили ответ ИИ (ai_reply: none или нет области ai_seller:invoke) |
200, ai_reply.status = queued_for_review | ответ ИИ стал черновиком, клиенту уйдёт после одобрения оператором |
202, ai_reply.status = queued, в теле operation | ждите вебхук message.created либо опросите GET /operations/{id}; повтор запроса с тем же ключом возвращает ту же операцию |
402 (wallet_suspended, spend_limit_reached) | пополните кошелёк или поднимите предел; сообщение клиента без ответа ИИ можно принять отдельно: ai_reply: none |
409 channel_not_configured | подключите канал чата в кабинете |
429 (rate_limited, concurrency_limit), 503 (service_unavailable, upstream_unavailable) | подождите Retry-After и повторите с тем же ключом |
прочие 4xx | исправьте запрос (каталог ошибок) |
Не отправляйте клиенту собственное сообщение дважды: ответ может прийти и синхронно, и вебхуком. Запоминайте идентификаторы уже доставленных сообщений.
#Шаг 4. Доставка ответа клиенту
Подписка на message.created вызывает ваш адрес на каждое новое сообщение диалога: клиента, ИИ и оператора.
- Проверьте подпись на сыром теле (алгоритм проверки).
- Проверьте, что
Webhook-Event-Idещё не обрабатывался. - Нагрузка
message.created«тонкая» всегда: идентификаторы без текста сообщения, вложений и карточек. Запросите сообщениеGET /conversations/{conversation_id}/messagesс нужнымиfields=(content,attachments,cards) и выберите нужное. Безfieldsтекст и вложения не придут: это персональные поля (Минимизация и выбор полей). - Для сообщения клиента в событии есть
ai_reply: {status, reason?}. Пропустите сообщения клиента (вы их сами и отправили) и доставьте ответы ИИ и оператора вашему клиенту. - Ответьте
2xxбыстро, остальную работу поставьте в очередь.
Сообщение может нести карточки товаров (cards) и вложения (attachments; ссылки временные). Карточку из вашей системы отправляет POST /conversations/{id}/cards (карточки).
Флаг подписки include_pii добавляет ПДн в события conversation.*, но не текст сообщений: message.created остаётся «тонким» (Вебхуки).
#Шаг 5. Передача оператору и возврат ИИ
Статус: частично Планируется. Событие
conversation.handoff_requestedи причинаoperator_activeработают. Маршрутыtakeover,operator-messages,releaseиcloseещё не работают.
| Ситуация | Что происходит | Что делает ваш сервер |
|---|---|---|
ИИ запросил оператора или исчерпан суточный предел (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 одновременных платных операций на интеграцию и не более 5 на арендатора (сверх —
429 concurrency_limit), максимум стоимости одной операции (по умолчанию эквивалент 100 ₽), предохранитель кошелька (платные операции). Значения задаются в кабинете, действующие видны вGET /me. - Суточный предел ответов ИИ. Исчерпан — сообщение сохраняется, ответ ИИ не запрашивается и не оплачивается, диалог получает
handoff_pending, аai_replyравен{status: "skipped", reason: "daily_limit"}. - Пределы частоты (стартовые). Чтение 600/мин, запись 120/мин, платные 30/мин. Это нижняя граница пропускной способности: при ~30 репликах клиентов в минуту на ключ ограничивайте поток очередью у себя и используйте несколько интеграций только по согласованию с арендатором.
- Ошибки денег.
spend_limit_reachedиwallet_suspended(402): запрос отклоняется до записи. Нехватка средств на самом ответе ИИ даётai_reply.reason=insufficient_funds: сообщение сохранено, ответ клиенту отложите или передайте оператору.
#Полный пример: Node.js и Express
Пример сводит вместе приём реплик вашего канала и вебхук Формула ИИ. Хранилище соответствий и список обработанных событий — в памяти, для боя замените на базу. Функции deliverToCustomer и onCustomerMessage подключаются к вашему каналу. Подпись вебхука проверяется библиотекой jose по открытым ключам платформы (алгоритм).
import express from 'express';
import { base64url, compactVerify, createRemoteJWKSet } from 'jose';
const BASE = process.env.API_BASE_URL; // https://formula-cream.pro/api/public/v1
const KEY = process.env.API_KEY;
const JWKS = createRemoteJWKSet(new URL(`${BASE}/.well-known/jwks.json`));
const TOLERANCE_SEC = 300;
const conversations = new Map(); // внешний id клиента -> id диалога (замените на БД)
const delivered = new Set(); // id сообщений, уже отправленных клиенту
const seenEvents = new Set(); // Webhook-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);
// 201 — новый диалог, 200 — найден существующий; в обоих случаях data.id
const { data } = await api('/conversations', {
method: 'POST',
key: `conv-${customerId}`,
body: { external_customer_id: customerId, name, language: 'ru' }
});
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, ai_reply: 'auto' }
});
if (status === 200 && data.ai_reply?.status === 'replied' && !delivered.has(data.reply.id)) {
// Текста ответа здесь нет: читаем сообщение отдельным запросом.
await deliverMessage(customerId, conversationId, data.reply.id);
}
// 202 (ai_reply.status = 'queued', data.operation) — ответ придёт вебхуком message.created
// ai_reply.status = 'skipped' — ИИ не отвечает, причина в data.ai_reply.reason
}
// Текст читаем только с fields=content: выдача попадает в журнал выдач.
async function deliverMessage(customerId, conversationId, messageId) {
const { data } = await api(`/conversations/${conversationId}/messages?limit=20&fields=sender,content`);
const m = data.data.find((x) => x.id === messageId);
if (!m || delivered.has(m.id)) return;
delivered.add(m.id);
await deliverToCustomer(customerId, m.content);
}
// Подключите к своему каналу
async function deliverToCustomer(customerId, text) {
console.log('→ клиенту', customerId, text);
}
const app = express();
app.post('/webhooks', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
const eventId = req.get('Webhook-Event-Id') ?? '';
const timestamp = req.get('Webhook-Timestamp') ?? '';
const m = /^([\w-]+)\.\.([\w-]+)$/.exec(req.get('Webhook-Signature') ?? '');
if (!eventId || !m || !/^\d{1,12}$/.test(timestamp)
|| Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SEC) return res.sendStatus(400);
// Нагрузка отделена от подписи: собираем её сами и вставляем между точками.
const payload = Buffer.concat([Buffer.from(`${eventId}.${timestamp}.`), req.body]);
try {
await compactVerify(`${m[1]}.${base64url.encode(payload)}.${m[2]}`, JWKS, { algorithms: ['EdDSA'] });
} catch {
return res.sendStatus(400);
}
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, sender } = event.data; // data: идентификаторы, без текста
if (sender === 'customer') return; // свои же реплики не возвращаем
const customerId = [...conversations].find(([, id]) => id === conversation_id)?.[0];
if (!customerId) return;
const { data } = await api(`/conversations/${conversation_id}/messages?limit=10&fields=sender,content`);
for (const m of data.data) {
if (m.sender === 'customer' || delivered.has(m.id) || !m.content) continue;
delivered.add(m.id);
await deliverToCustomer(customerId, m.content);
}
}
app.listen(3000);
#Полный пример: Python и FastAPI
Подпись проверяется библиотекой PyJWT (класс PyJWS, нагрузка не JSON) по открытым ключам платформы.
import base64
import json
import os
import time
import httpx
import jwt
import requests
from fastapi import FastAPI, HTTPException, Request
from jwt import PyJWK, PyJWS
BASE = os.environ["API_BASE_URL"] # https://formula-cream.pro/api/public/v1
KEY = os.environ["API_KEY"]
JWKS_URL = BASE + "/.well-known/jwks.json"
TOLERANCE = 300
conversations: dict[str, str] = {} # внешний id клиента -> id диалога (замените на БД)
delivered: set[str] = set()
seen_events: set[str] = set()
_jwks = {"keys": [], "at": 0.0}
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:
# 201 — новый диалог, 200 — найден существующий
_, data = await api(
"POST", "/conversations", key=f"conv-{customer_id}",
json={"external_customer_id": customer_id, "name": name, "language": "ru"},
)
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 deliver_new_messages(customer_id: str, conversation_id: str):
# Текст читаем только с fields=content: выдача попадает в журнал выдач.
_, data = await api(
"GET", f"/conversations/{conversation_id}/messages",
params={"limit": 10, "fields": "sender,content"},
)
for m in data["data"]:
if m["sender"] == "customer" or m["id"] in delivered or not m.get("content"):
continue
delivered.add(m["id"])
await deliver_to_customer(customer_id, m["content"])
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": "auto"},
)
ai_reply = (data or {}).get("ai_reply", {})
if status == 200 and ai_reply.get("status") == "replied":
await deliver_new_messages(customer_id, conversation_id) # в ответе только reply.id
# 202 (queued, data["operation"]) — ждите вебхук message.created
# skipped — ИИ не отвечает, причина в ai_reply["reason"]
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def key_for(kid: str):
for attempt in (0, 1):
if attempt == 1 or time.time() - _jwks["at"] > 300:
_jwks.update(keys=requests.get(JWKS_URL, timeout=5).json()["keys"], at=time.time())
for jwk in _jwks["keys"]:
if jwk.get("kid") == kid:
return PyJWK.from_dict(jwk).key
return None
def verify(event_id: str, timestamp: str, signature: str, body: bytes) -> bool:
parts = signature.split(".")
if not event_id or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE:
return False
if len(parts) != 3 or parts[1] != "":
return False
try:
header = json.loads(base64.urlsafe_b64decode(parts[0] + "=" * (-len(parts[0]) % 4)))
key = key_for(header.get("kid", ""))
if key is None:
return False
payload = f"{event_id}.{timestamp}.".encode() + body
PyJWS().decode(f"{parts[0]}.{b64url(payload)}.{parts[2]}", key, algorithms=["EdDSA"])
return True
except (jwt.PyJWTError, ValueError):
return False
@app.post("/webhooks")
async def handle_webhook(request: Request):
raw = await request.body() # сырое тело
h = request.headers
event_id = h.get("Webhook-Event-Id", "")
if not verify(event_id, h.get("Webhook-Timestamp", ""), h.get("Webhook-Signature", ""), raw):
raise HTTPException(400, "bad signature")
if event_id in seen_events:
return {"ok": True}
seen_events.add(event_id)
event = json.loads(raw)
if event.get("type") == "message.created" and event["data"].get("sender") != "customer":
conversation_id = event["data"]["conversation_id"]
customer_id = next((c for c, i in conversations.items() if i == conversation_id), None)
if customer_id:
await deliver_new_messages(customer_id, conversation_id)
return {"ok": True}
Запуск: uvicorn app:app --port 3000. Для приёма в боевом режиме поставьте перед сервером HTTPS-прокси.
#Проверка перед запуском
- Тестовым ключом отправьте реплику и убедитесь, что ответ пришёл и деньги не списаны.
- Отправьте реплику дважды с одним
Idempotency-Key— второй раз ответ должен совпасть, дубля в диалоге быть не должно. - Отправьте тот же ключ с другим текстом — ожидайте
409 idempotency_conflict. - Отправьте реплику с
ai_reply: noneи убедитесь, чтоai_replyравен{status: "skipped", reason: "not_requested"}, а деньги не списаны. Когда будет доступен перехват, проверьте иoperator_active. - Остановите приёмник вебхуков, затем запустите: пропущенные доставки повторяются по расписанию из 8 повторов, самый поздний — через 24 часа (доставка и повторы); недоставленное видно в журнале доставок, его можно переотправить.
- Проверьте обработку
402,409 channel_not_configured,429(в том числеconcurrency_limit),503и потерю соединения во время?wait=15(после обрыва узнайте результат поGET /operations/{id}). - Убедитесь, что ключ доступа не попадает в журналы и репозиторий, а тексты клиентов не пишутся в журналы.
#Заметки
- Один канал — одна роль. Канал
apiне получает общий промпт площадки: ИИ отвечает по настройкам вашего агента и базе знаний вашего арендатора. - Порядок событий не гарантирован. Опирайтесь на идентификаторы сообщений и время создания.
- Вебхук важнее опроса. Опрос
GET …/messagesгодится для сверки, но тратит пределы чтения. - Тексты и вложения не в событиях. События тонкие, а
fields=по умолчанию не отдаёт персональные поля: запрашивайте только то, что нужно. - Разные ячейки — разные ключи и адреса. Ключ RU не работает на EN и ID, как и обратное (Аутентификация).
- Тексты клиента — ПДн. Не пишите их в журналы без необходимости.
#Открытые вопросы
- Шаг 5 (ответ оператора, перехват, возврат ИИ, закрытие) — после выхода маршрутов. Техническое.
- Отправка вложений клиента через серверный API. Техническое.