К содержанию
sapportlydocs
Панель

Входящие и живой канал

Гайд интегратора: принять входящее, принять ответ оператора, записать исходящее, слушать AI. Можно без вебхуков — только WebSocket.

Это гайд для сервера, 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:connectHTTPS URL, secret, быстрый 2xxИ то и другое + дедуп по message_id
Черновики ИИonAi, стрим partialНет. Только финальный автоответ, если он включёнЧерновики с сокета, сообщения с любого
Пропуски при офлайнеСокет интегратора не реплеит историю. После reconnect читайте RESTПлатформа ретраит POST (5xx / 429)REST — источник правды

Полный пример (только сокет)

Это минимальный интегратор на custom:shop: входящие уже кладёт ваш другой процесс через приём входящих; здесь вы слушаете ленту и доставляете исходящее человеку.

inbox.tsTypeScript
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.

TypeScript
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 считает это тем же исходящим).

TypeScript
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.

TypeScript
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.

TypeScript
await client.ingest.send({
  channel: "custom:shop",
  body: followUp.text,
  idempotency_key: `shop:${order.id}:${followUp.id}`,
  identity: { external_id: customer.id },
});

Протокол сокета

Низкоуровневый справочник: Живой канал API. Здесь — то, без чего интеграция ломается.

  1. POST /v1/ws/ticket (право ws:connect) отдаёт одноразовый билет ≈ 60 секунд. API-ключ в query класть нельзя.
  2. Коннект: wss://ws.sapportly.pro/ws?ticket=…. После redeem сессия интегратора живёт 3600 с. По истечении сервер закрывает сокет кодом 4401. SapportlyInbox сам берёт новый ticket и подключается снова.
  3. Кадры — wire v2: { v: 2, type, event_id, payload }. Это не внутренний NATS-конверт с metadata / kind.
  4. Сокет только на приём. Текстовые кадры от клиента игнорируются. Сервер шлёт WebSocket ping каждые 25 с. Поток клиентских ping (> ~60/мин) закрывает соединение кодом 1008. Принудительный сброс — 4403.
  5. Пропущенные кадры интегратору не досылаются. Очередь offline — только для посетителей виджета. После reconnect читайте историю REST.
message.deliveredJSON
{
  "v": 2,
  "type": "message.delivered",
  "event_id": "uuid",
  "payload": {
    "type": "message.delivered",
    "message_id": "uuid",
    "role": "visitor",
    "channel": "custom:shop",
    "body": "Где заказ?",
    "content_encoding": "plain"
  }
}
ai.draftJSON
{
  "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-memory MessageDeduper не переживает рестарт и не шарится между процессами.

Догнать историю после reconnect

Страница сообщений внутри хронологическая, листается назад. Курсор —первый элемент страницы. SDK: iterateMessages. Диалоги — новые сверху, курсор с последнего. Подробности: пагинация.

TypeScript
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.

TypeScript
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=.

Дальше

Страница полезна?

/docs/sdk/inbox