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

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

Статус: Доступно для схемы «заведение диалога — сообщение клиента — ответ ИИ — вебхук» и карточек. Планируется: шаг 5 — ответ оператора из вашей системы, перехват, возврат ИИ и закрытие (серверный API диалогов).

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

#Содержание

  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)
                              ▼
                          Платформа: ИИ-продажник
                              │
          ┌───────────────────┴──────────────────┐
          │ 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. Ключ, области и вебхук

  1. В кабинете создайте интеграцию и ключ (Аутентификация). Выдайте только нужные области. Для тестирования используйте тестовый ключ: он не списывает деньги (тестовые ключи).
  2. Создайте подписку на события message.created и conversation.handoff_requested (при желании — operation.completed) с адресом вашего сервера (вебхуки). Секрета у подписки нет: доставки подписаны платформой, вы проверяете подпись открытым ключом из набора JWKS (подпись).
  3. Подключите канал чата: Настройки → 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. Входящее сообщение и ответ

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

  1. Сформируйте Idempotency-Key. Сделайте его производным от вашей реплики (например, идентификатор сообщения в вашем канале), чтобы повтор после сбоя использовал тот же ключ.
  2. Вызовите POST /conversations/{id}/messages?wait=15.
  3. Разберите результат:
РезультатЧто делать
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 вызывает ваш адрес на каждое новое сообщение диалога: клиента, ИИ и оператора.

  1. Проверьте подпись на сыром теле (алгоритм проверки).
  2. Проверьте, что Webhook-Event-Id ещё не обрабатывался.
  3. Нагрузка message.created «тонкая» всегда: идентификаторы без текста сообщения, вложений и карточек. Запросите сообщение GET /conversations/{conversation_id}/messages с нужными fields= (content, attachments, cards) и выберите нужное. Без fields текст и вложения не придут: это персональные поля (Минимизация и выбор полей).
  4. Для сообщения клиента в событии есть ai_reply: {status, reason?}. Пропустите сообщения клиента (вы их сами и отправили) и доставьте ответы ИИ и оператора вашему клиенту.
  5. Ответьте 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"}. Сообщение при этом принимается и видно оператору.

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

#Полный пример: 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-прокси.

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

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

#Заметки

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