Это гайд для сервера, CRM и своей админки. Виджет на сайте сюда не входит — у него свой сокет и Site ID. Здесь API-ключ, канал-источник вроде custom:shop и SapportlyInbox.
Карта направлений
| Что нужно | HTTP / SDK | Как узнать, что случилось |
|---|---|---|
| Пользователь написал вам | Вы кладёте это сами: client.ingest.send (POST /v1/ingest/messages). Виджет приём входящих не использует. | WS onVisitor (role: visitor) и/или вебхук message.received |
| Оператор ответил в панели | Панель сама пишет в ленту. Вам HTTP не нужен. | WS onAgent с echo: false и/или вебхук agent.reply. Доставку человеку на custom: делаете вы. |
| Ваш бот / оператор отвечает из вашей системы | inbox.reply или conversations.reply (POST /v1/conversations/{channel}/messages) | Свой кадр вернётся в onAgent с echo: true — во внешний канал его повторно слать не нужно |
| ИИ предлагает / отвечает | Черновик — только WS onAi. Автоответ ИИ — обычное исходящее role: agent в ленте (как оператор). | ai.draft на вебхук нет. Автоответ приходит как onAgent / agent.reply |
WebSocket или вебхуки
Оба канала несут один и тот же message_id. Можно включить только одно. WebSocket проще для живой системы: не нужен публичный HTTPS endpoint и HMAC. Вебхуки проще для serverless, где держать сокет неудобно.
| Только WS | Только вебхуки | Оба | |
|---|---|---|---|
| Что поднять | Процесс с inbox.connect(), право ws:connect | HTTPS URL, secret, быстрый 2xx | И то и другое + дедуп по message_id |
| Черновики ИИ | onAi, стрим partial | Нет. Только финальный автоответ, если он включён | Черновики с сокета, сообщения с любого |
| Пропуски при офлайне | Сокет интегратора не реплеит историю. После reconnect читайте REST | Платформа ретраит POST (5xx / 429) | REST — источник правды |
Полный пример (только сокет)
Это минимальный интегратор на custom:shop: входящие уже кладёт ваш другой процесс через приём входящих; здесь вы слушаете ленту и доставляете исходящее человеку.
import { SapportlyClient, sourceChannel } from "@sapportly/sdk";
import { SapportlyInbox } from "@sapportly/sdk/realtime";
const CHANNEL = "custom:shop";
const client = new SapportlyClient({
apiKey: process.env.SAPPORTLY_API_KEY ?? process.env.SUPPORTLY_API_KEY!,
});
const inbox = new SapportlyInbox(client, { channels: [CHANNEL] });
inbox.onVisitor(async (msg) => {
await persistLocally({ id: msg.messageId, channel: msg.channel, from: "user" });
});
inbox.onAgent(async (msg) => {
if (msg.echo) return;
await deliverToCustomer(msg.externalId, msg.body);
});
inbox.onAi((draft) => {
if (draft.partial) return; // стрим «печатает…» в вашей админке
showSuggestedReply(draft.draft_body ?? "");
});
inbox.onStateChange((state) => {
if (state === "open") void catchUp();
});
await inbox.connect();
async function catchUp() {
// После reconnect сокет историю не досылает. REST по каждому треду.
// Не вызывайте отсюда deliverToCustomer.
for await (const c of client.conversations.iterate({ limit: 50, maxPages: 3 })) {
if (sourceChannel(c.channel) !== CHANNEL) continue;
for await (const m of client.conversations.iterateMessages(c.channel, { limit: 50, maxPages: 1 })) {
await persistLocally({ id: m.id, channel: c.channel, body: m.body, at: m.created_at });
}
}
}
async function replyAsAgent(threadChannel: string, externalId: string, text: string) {
await deliverToCustomer(externalId, text);
await inbox.reply(threadChannel, text);
}1. Как принять сообщения пользователей
Пользователь пишет в ваш продукт. Sapportly сам это не видит, пока вы не положите входящее через приём — кроме виджета, который ходит в POST /v1/widget/messages.
await client.ingest.send({
channel: "custom:shop",
body: comment.text,
idempotency_key: `shop:${order.id}:${comment.id}`,
identity: { email: customer.email, external_id: customer.id },
});Ответ шлюза 202 с message_id: null значит «принято в очередь». Повтор с тем же ключом даёт 200 и уже известный id. После сохранения придёт WS message.delivered / onVisitor. Это эхо вашего приёма входящих, а не второе входящее: если вы уже обработали HTTP 202, кадр можно использовать только как подтверждение.
2. Как принять сообщения операторов
Оператор жмёт «Ответить» в панели. Платформа пишет строку в ленту того же канала и пушит message.delivered с role: "agent" (иногда assistant — SDK считает это тем же исходящим).
inbox.onAgent(async (msg) => {
if (msg.echo) return;
await deliverToCustomer(msg.externalId, msg.body);
});На виджете кадр уходит посетителю сам. На custom: / email / CRM доставку человеку делаете вы. Если канал с tenant-шифрованием (enc_v1_standard), в body будет ciphertext: публичный SDK ключ тенанта не отдаёт.
3. Как отправить ответы операторов (и ИИ)
Это не приём входящих. Ключ — тред из msg.channel / accepted.channel, не slug источника. Нужно право conversations:write.
await inbox.reply(msg.channel, "Трек 123, доставка завтра.");
// или без SapportlyInbox:
await client.conversations.reply(threadChannel, { body: "Трек 123, доставка завтра." });Три исходящих, которые путают
| Что | Где видно | Попадает в историю? |
|---|---|---|
Черновик ИИ (ai.draft) | Только WS onAi. Частичные кадры: partial: true | Нет. Это подсказка, не сообщение |
| Автоответ ИИ (режим Auto) | onAgent, вебхук agent.reply | Да, как исходящее агента |
inbox.reply / панель | onAgent. Свой ответ — echo: true | Да |
Если вы показываете черновик у себя и сами решаете, слать ли его клиенту: доставьте текст, затем inbox.reply. Не кладите черновик повторно как приём входящих.
4. Как отправить ответы пользователей
Со стороны интегратора «ответ пользователя» — это снова приём входящих в тот же канал. Sapportly не различает «первое обращение» и «следующую реплику клиента» на уровне метода: оба раза ingest.send с новым idempotency_key.
await client.ingest.send({
channel: "custom:shop",
body: followUp.text,
idempotency_key: `shop:${order.id}:${followUp.id}`,
identity: { external_id: customer.id },
});Протокол сокета
Низкоуровневый справочник: Живой канал API. Здесь — то, без чего интеграция ломается.
POST /v1/ws/ticket(правоws:connect) отдаёт одноразовый билет ≈ 60 секунд. API-ключ в query класть нельзя.- Коннект:
wss://ws.sapportly.pro/ws?ticket=…. После redeem сессия интегратора живёт 3600 с. По истечении сервер закрывает сокет кодом 4401. SapportlyInbox сам берёт новый ticket и подключается снова. - Кадры — wire v2:
{ v: 2, type, event_id, payload }. Это не внутренний NATS-конверт сmetadata/kind. - Сокет только на приём. Текстовые кадры от клиента игнорируются. Сервер шлёт WebSocket ping каждые 25 с. Поток клиентских ping (> ~60/мин) закрывает соединение кодом 1008. Принудительный сброс — 4403.
- Пропущенные кадры интегратору не досылаются. Очередь offline — только для посетителей виджета. После reconnect читайте историю REST.
{
"v": 2,
"type": "message.delivered",
"event_id": "uuid",
"payload": {
"type": "message.delivered",
"message_id": "uuid",
"role": "visitor",
"channel": "custom:shop",
"body": "Где заказ?",
"content_encoding": "plain"
}
}{
"v": 2,
"type": "ai.draft",
"event_id": "uuid",
"payload": {
"type": "ai.draft",
"message_id": "uuid-входящего",
"channel": "custom:shop",
"draft_body": "Проверяю трек…",
"partial": true
}
}Фильтр каналов, echo, дедуп
channels: ["custom:shop"]ловит и источник, и тредыcustom:shop:{uuid}. Кадры без канала проходят.echo: true— этоmessage_id, который вернул вашinbox.replyв этом процессе. Ответ из панели или из другого воркера echo не несёт: его нужно доставить человеку. Набор id ограничен; если эхо так и не пришло, старые ключи вытесняются.- Дедуп вебхук+WS включён по умолчанию (
dedup: falseвыключает). На флоте воркеров нужен общий store: in-memoryMessageDeduperне переживает рестарт и не шарится между процессами.
Догнать историю после reconnect
Страница сообщений внутри хронологическая, листается назад. Курсор —первый элемент страницы. SDK: iterateMessages. Диалоги — новые сверху, курсор с последнего. Подробности: пагинация.
const seen = new Set<string>();
for await (const c of client.conversations.iterate({ limit: 50 })) {
if (sourceChannel(c.channel) !== "custom:shop") continue;
for await (const m of client.conversations.iterateMessages(c.channel, {
limit: 100,
maxPages: 5,
})) {
if (seen.has(m.id)) continue;
seen.add(m.id);
await persistLocally({ id: m.id, channel: c.channel, body: m.body, at: m.created_at });
}
}Только вебхуки, без сокета
Подпись: HMAC-SHA256 строки {unix}.{rawBody}, заголовки X-Sapportly-Timestamp и X-Sapportly-Signature, окно 300 с. Нужен сырой body. Не используйте @sapportly/api/webhooks.
import { verifyWebhook } from "@sapportly/sdk/webhooks";
export async function POST(req: Request) {
const raw = await req.text();
await verifyWebhook(
process.env.SAPPORTLY_WEBHOOK_SECRET ?? process.env.SUPPORTLY_WEBHOOK_SECRET!,
raw,
{
timestamp: req.headers.get("X-Sapportly-Timestamp"),
signature: req.headers.get("X-Sapportly-Signature"),
},
);
const event = JSON.parse(raw) as {
type: string;
message_id?: string;
data?: { payload?: { channel?: string; body?: string; role?: string } };
};
// ack сразу, работу — в очередь
void handle(event);
return new Response(null, { status: 204 });
}Тело: type, tenant_id, timestamp, data (внутренний конверт; канал и текст — в data.payload), при наличии сообщения — верхний message_id и объект delivery. Для входящих слушайте message.received, для исходящих агента — agent.reply. Типы событий: Вебхуки API.
Типичные ошибки
- Класть исходящее через приём входящих — в ленте оно станет «от посетителя», AI снова сработает.
- Считать
onVisitorединственным способом узнать о клиенте, если приём входящих делаете вы же: получите дубль. Либо обрабатывайте HTTP, либо кадр, не оба без дедупа. - Слать человеку эхо своего
reply— клиент увидит сообщение дважды.echoработает только в процессе, который вызвалinbox.reply. - Ждать реплей сокета как у виджета. Интегратор сам ходит в
conversations.messages. - Парсить NATS
kind/metadataна клиенте. На сокете этого нет. - Класть API-ключ в браузер или в
?ticket=.