Support Communication
Документация для разработчиков

Интеграции с Support Communication

Подключите готовый Web SDK, используйте публичный JSON API или передавайте обращения через Open Channel. Ниже описан полный рабочий цикл: ключи и среды, сообщения, файлы, ответы операторов, оценки и исходящие события.

API v1Спецификация обновлена 2 сентября 2026 г.Версии и история изменений
Базовый URLhttps://supportcom.ru/api/v1
Web SDK

Готовый виджет, управление через window.sw_api и обработчики событий.

Клиентский API

Клиенты, сообщения, получение ответов, вложения и оценки.

Open Channel

Двунаправленный протокол для собственных приложений и каналов.

01 · Начало работы

Ключ, среда, права и структура ответа

Создайте публичный ключ для SDK-подключения, сохраните его сразу и передавайте в Bearer-заголовке. Open Channel использует отдельный токен в URL.

Три шага до первого запроса

  1. 1

    Откройте «Настройки → Центр интеграций → Чат на сайте или в приложении». Создайте SDK-подключение с очередью и публичным ключом. Полный ключ сохраните при выдаче; для ротации используйте «API и webhooks».

  2. 2

    Для тестов используйте environment=stage и ключ sk_test_…; для боевого трафика — production и sk_live_….

  3. 3

    Передавайте environment в строке запроса: без параметра используется production. Поля environment и tenantId внутри traits не меняют контекст — сервер отмечает их как отклонённые.

Публичный ключ — не токен оператора

В браузер разрешено передавать только ключ SDK с минимальными правами. Не публикуйте токены оператора или администратора сервиса и не сохраняйте секреты в логах.

HTTP-заголовокAuthorization: Bearer sk_live_<public_api_key>
clients:identifyИдентификация, статус присутствия и проактивные приглашения.
conversations:writeСообщения, polling, файлы, client-info, agents/status, оценки и начало комментария CSAT.
Структура ответаПроверяйте HTTP и status === "ok". В ошибке читайте error.code; data и traceId бывают не у всех ответов.

02 · Виджет на сайте

Подключите Web SDK

Добавьте опубликованный widget.js и инициализируйте виджет на страницах чата. Стабильный externalId связывает обращения одного клиента.

HTML
<script>
  function startSupportWidget() {
    const script = document.createElement("script");
    script.src = "https://supportcom.ru/widget.js";
    script.async = true;
    script.onload = function () {
      window.SupportWidget.init({
        apiBase: "https://supportcom.ru/api/v1",
        publicKey: "sk_test_<sandbox_public_api_key>",
        externalId: "customer_42",
        environment: "stage"
      });
    };
    script.onerror = function () {
      console.error("Support Communication widget could not be loaded");
    };
    document.head.append(script);
  }
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", startSupportWidget, { once: true });
  } else {
    startSupportWidget();
  }
</script>

Пример ждёт готовности DOM и загрузки скрипта. Не вызывайте SupportWidget.init в обычном inline-скрипте сразу после script defer: внешний скрипт в этот момент ещё может не выполниться.

Сессия и приглашения

Виджет поддерживает живую сессию и получает проактивные приглашения до первого сообщения.

Управление виджетом

window.sw_api управляет окном, контактами, дополнительными данными, UTM и счётчиком непрочитанных.

CSAT

После закрытия диалога виджет показывает оценку 1–5 и необязательный комментарий.

Основные методы интерфейса страницы: open, close, chatMode, setContactInfo, setCustomData, setClientAttributes, setUserToken, sendOfflineMessage и clearHistory.

03 · Клиенты и сообщения

Идентифицируйте, отправьте и получите ответ

Один копируемый пример: реализованный sdkRequest, затем identify → messages → poll. Замените тестовый ключ; остальные шаги используют один SDK-клиент и стабильный externalId.

1. Полный quickstart

JavaScript · browser module / Node.js 22+
function createSdkClient({ apiBase, publicKey, environment, externalId }) {
  let conversationId = null;
  let visitorSessionToken = null;
  let lastMessageId = null;
  let conversationEpoch = 0;
  const messagesById = new Map();
  const storageOrigin = new URL(apiBase).origin;

  async function sdkRequest(method, path, body, query = {}) {
    const url = new URL(apiBase.replace(/\/$/, "") + path);
    for (const [name, value] of Object.entries(query)) {
      if (value !== undefined && value !== null) url.searchParams.set(name, value);
    }
    url.searchParams.set("environment", environment);
    let response;
    let envelope;
    const controller = new AbortController();
    // Server-side bot generation may use the full 30-second response window.
    const timeout = setTimeout(() => controller.abort(), 35_000);
    try {
      response = await fetch(url, {
        method,
        signal: controller.signal,
        credentials: "omit",
        headers: {
          authorization: "Bearer " + publicKey,
          ...(body !== undefined ? { "content-type": "application/json" } : {})
        },
        ...(body !== undefined ? { body: JSON.stringify(body) } : {})
      });
      try { envelope = await response.json(); }
      catch (error) {
        if (controller.signal.aborted) throw error;
        envelope = null;
      }
    } catch {
      throw Object.assign(new Error("Network request failed"), {
        code: controller.signal.aborted ? "client_request_timeout" : "client_network_error",
        httpStatus: 0
      });
    } finally {
      clearTimeout(timeout);
    }
    if (!response.ok || envelope?.status !== "ok") {
      throw Object.assign(new Error(envelope?.error?.message || "API request failed"), {
        code: envelope?.error?.code || "client_response_invalid",
        httpStatus: response.status,
        apiStatus: envelope?.status,
        traceId: envelope?.traceId,
        retryAfter: response.headers.get("retry-after")
      });
    }
    return envelope.data;
  }

  function rememberConversation(data) {
    if (data.conversationId && data.conversationId !== conversationId) {
      conversationEpoch += 1;
      conversationId = data.conversationId;
      visitorSessionToken = null;
      lastMessageId = null;
      messagesById.clear();
    }
    if (data.visitorSessionToken) visitorSessionToken = data.visitorSessionToken;
  }

  return {
    request: sdkRequest,
    resolveStorageUrl: (url) => new URL(url, storageOrigin + "/").href,
    rememberConversation,
    getState: () => ({ conversationId, visitorSessionToken, lastMessageId }),
    getMessages: () => [...messagesById.values()],
    async identify(traits = {}) {
      const data = await sdkRequest("POST", "/public/sdk/identify", { externalId, traits });
      rememberConversation(data); // identify does not issue a visitor token.
      return data;
    },
    async sendMessage(text, { attachments, pageUrl, offlineMessage } = {}) {
      const data = await sdkRequest("POST", "/public/sdk/messages", {
        externalId,
        ...(conversationId ? { conversationId } : {}),
        text,
        ...(attachments ? { attachments } : {}),
        ...(pageUrl ? { pageUrl } : {}),
        ...(offlineMessage === true ? { offlineMessage: true } : {})
      });
      rememberConversation(data);
      return data;
    },
    async poll({ replayHistory = false } = {}) {
      if (!conversationId || !visitorSessionToken) {
        throw Object.assign(new Error("Send a user message before polling"), {
          code: "client_visitor_session_required", httpStatus: 0
        });
      }
      const requestedConversationId = conversationId;
      const requestedEpoch = conversationEpoch;
      const isStale = () => requestedEpoch !== conversationEpoch
        || requestedConversationId !== conversationId;
      let data;
      try {
        data = await sdkRequest("GET",
          "/public/sdk/conversations/" + encodeURIComponent(requestedConversationId) + "/messages",
          undefined, {
            visitorSessionToken,
            ...(!replayHistory && lastMessageId ? { since: lastMessageId } : {})
          });
      } catch (error) {
        if (isStale()) return null;
        throw error;
      }
      // sendMessage/accepted may have changed the conversation while this poll was in flight.
      if (isStale()) return null;
      rememberConversation(data);
      for (const message of data.messages) messagesById.set(message.id, message);
      if (data.messages.length) lastMessageId = data.messages.at(-1).id;
      return data;
    }
  };
}

// Browser: put this entire block in <script type="module">.
// Node.js 22+: save it as quickstart.mjs and run node quickstart.mjs.
// Replace the key with the one issued to your SDK connection.
const sdk = createSdkClient({
  apiBase: "https://supportcom.ru/api/v1",
  publicKey: "sk_test_<sandbox_public_api_key>",
  environment: "stage",
  externalId: "customer_42"
});

await sdk.identify({ plan: "business", locale: "ru-RU" });
await sdk.sendMessage("Подскажите статус заказа №1024");
const firstPoll = await sdk.poll();
console.info("Operator replies:", firstPoll?.messages.length ?? 0);
// Render sdk.getMessages() keyed by message.id; do not log the visitor token.

2. Продолжение: polling и безопасный backoff

JavaScript · продолжение quickstart
// Continue in the same module after the quickstart. No overlapping polls.
async function pollWithBackoff() {
  for (let attempt = 0; attempt < 3; attempt += 1) {
    try {
      return await sdk.poll();
    } catch (error) {
      const retryable = ["client_network_error", "client_request_timeout"].includes(error.code)
        || error.httpStatus === 429 || error.httpStatus >= 500;
      if (!retryable || attempt === 2) throw error;
      const header = error.retryAfter;
      const seconds = header ? Number(header) : NaN;
      const retryAt = header && !Number.isFinite(seconds) ? Date.parse(header) : NaN;
      const retryAfterMs = Number.isFinite(seconds) ? seconds * 1000
        : Number.isFinite(retryAt) ? Math.max(0, retryAt - Date.now()) : 0;
      const backoffMs = 1000 * 2 ** attempt + Math.random() * 250;
      await new Promise(resolve => setTimeout(resolve, Math.max(retryAfterMs, backoffMs)));
    }
  }
}

await pollWithBackoff();
// null means a late response for an old conversation was ignored; render no old data.
// Schedule the next poll only after this finishes (the widget uses ~3 s).
// On visitor_session_token_expired stop; identify is NOT a token-refresh API.
// A deliberate new user message issues a token and may start a new appeal.
// For a pending/expired download URL, re-read history and merge by message.id:
// await sdk.poll({ replayHistory: true });
Храните conversationId и visitorSessionToken вместе; не логируйте токен. Обновляйте токен из каждого успешного messages / poll. Используйте since и обновляйте сообщения по message.id.
Токен посетителя живёт 15 минут

identify возвращает conversationId, но не visitor token. Его выдают messages, успешный polling и принятие приглашения; каждый такой ответ задаёт новый срок. При visitor_session_token_expired остановите polling. Отдельного refresh/resume-маршрута нет: следующий осознанный вопрос клиента через messages выдаст токен, но может создать новое обращение. Не отправляйте фиктивное сообщение и не повторяйте последнее ради восстановления.

Polling отдаёт ответы оператора, не полную историю посетителя; неизвестный since возвращает всю доступную операторскую историю. При смене conversationId сбрасывайте курсор. Helper возвращает null для запоздавшего ответа старого диалога и не меняет текущие токен и историю. Повторяйте опрос последовательно, например раз в 3 секунды; в публичном SDK нет WebSocket/SSE-подписки.

04 · Полный цикл SDK

Сессии, файлы, карточка клиента и оценки

Все 14 SDK-операций перечислены ниже. Примеры этого раздела продолжают quickstart в том же модуле: объект sdk уже содержит ключ, среду, диалог и токен.

Клиент и диалог

POST/public/sdk/identify

Связать внешний идентификатор с клиентом

POST/public/sdk/messages

Отправить текст или вложения

GET/public/sdk/conversations/:id/messages

Получить ответы и CSAT-состояние

POST/public/sdk/client-info

Обновить контакты и дополнительные данные

GET/public/sdk/agents/status

Проверить доступность операторов

Сессия виджета

POST/public/sdk/presence/heartbeat

Продлить присутствие посетителя

POST/public/sdk/presence/disconnect

Завершить присутствие

GET/public/sdk/invitations

Получить проактивные приглашения

POST/public/sdk/invitations/:exposureId/:action

Зафиксировать показ, принятие или отказ

Файлы и качество

POST/public/sdk/uploads

Получить параметры загрузки файла

POST/public/sdk/uploads/:fileId/finalize

Подтвердить загрузку

GET/public/sdk/uploads/:fileId/status

Дождаться безопасной готовности файла

POST/public/sdk/conversations/:id/ratings

Передать CSAT/CSI 1–5

POST/public/sdk/conversations/:id/csat-feedback/decline

Начать ввод отзыва после оценки (legacy-имя)

Дескриптор → signed transfer → finalize → сообщение

JavaScript · продолжение quickstart
// Continue after the quickstart; pass the File selected by your user.
async function waitForCleanUpload(fileId) {
  const deadlineAt = Date.now() + 30_000;
  let delayMs = 400;
  while (Date.now() < deadlineAt) {
    const status = await sdk.request("GET",
      "/public/sdk/uploads/" + encodeURIComponent(fileId) + "/status");
    if (status.ready) return status;
    if (!status.retryable) throw new Error("File failed antivirus scanning");
    await new Promise(resolve => setTimeout(resolve,
      Math.min(delayMs, Math.max(1, deadlineAt - Date.now()))));
    delayMs = Math.min(2000, Math.ceil(delayMs * 1.6));
  }
  throw new Error("File scan is still pending; retry sending later");
}

async function sendAttachment(file, text) {
  const descriptor = await sdk.request("POST", "/public/sdk/uploads", {
    fileName: file.name,
    mimeType: file.type || "application/octet-stream",
    sizeBytes: file.size
  });
  const transfer = await fetch(sdk.resolveStorageUrl(descriptor.signedUpload.url), {
    method: descriptor.signedUpload.method,
    headers: descriptor.signedUpload.headers || {},
    credentials: "omit",
    body: file
  }); // No SDK Authorization header on the storage request.
  if (!transfer.ok) throw new Error("File transfer failed: HTTP " + transfer.status);

  const finalized = await sdk.request("POST",
    "/public/sdk/uploads/" + encodeURIComponent(descriptor.fileId) + "/finalize", {});
  if (finalized.storageState !== "uploaded") throw new Error("Upload is not finalized");
  const ready = await waitForCleanUpload(descriptor.fileId);
  return sdk.sendMessage(text, { attachments: [{
    fileId: ready.fileId,
    fileName: ready.fileName,
    mimeType: ready.mimeType,
    sizeBytes: ready.sizeBytes
  }] });
}

const exampleFile = new File(["SDK attachment example\n"], "example.txt", { type: "text/plain" });
await sendAttachment(exampleFile, "Прикладываю документ");
// Only ready operator attachments appear in poll messages[].attachments[].download.
// sdk_attachment_scan_pending rejects the entire message before persistence;
// poll the status route and retry only while retryable=true.

Используйте метод и заголовки signedUpload, не добавляя Bearer-ключ к запросу хранилища. URL может быть относительным (/s3/…): sdk.resolveStorageUrl разрешает его от origin API, не сайта посетителя, и сохраняет подписанные параметры. После finalize опрашивайте GET /public/sdk/uploads/:fileId/status с ограниченным backoff. Передавайте вложение в messages только при ready=true; retryable=false означает блокировку или ошибку проверки.

Сообщение с ожидающим проверку файлом отклоняется целиком с sdk_attachment_scan_pending до сохранения и запуска бота. Поэтому безопасно дождаться статуса и повторить исходный запрос; не создавайте отдельный текстовый дубль. Виджет ждёт до 30 секунд и при таймауте оставляет текст и выбранный файл в композере, чтобы при явном повторе продолжить проверку того же fileId без новой загрузки.

В ответах оператора attachments[].download.url и expiresAt появляются только для готовых проверенных файлов. Чтобы обновить старое вложение или истёкший URL, выполните sdk.poll({ replayHistory: true }): это запрос без since, результат нужно объединить по message.id, а не дописать дубликат.

Присутствие и приглашения

JavaScript · продолжение quickstart
// Continue after the quickstart. Keep one sessionId for this page/session.
const sessionId = crypto.randomUUID();
const presence = {
  sessionId,
  externalId: "customer_42",
  pageUrl: "https://shop.example/orders",
  pagePath: "/orders"
};
async function heartbeat() {
  return sdk.request("POST", "/public/sdk/presence/heartbeat", presence);
}
async function pollInvitations() {
  return sdk.request("GET", "/public/sdk/invitations", undefined, { sessionId });
}
async function acknowledgeInvitation(exposureId, action, failureCode) {
  if (!["shown", "accepted", "dismissed", "failed"].includes(action)) {
    throw new Error("Unknown invitation action");
  }
  let conversationId;
  if (action === "accepted") {
    // Use our identified conversation, never a guessed/foreign conversation id.
    if (!sdk.getState().conversationId) await sdk.identify();
    conversationId = sdk.getState().conversationId;
    if (!conversationId) throw new Error("Identify a conversation before accepting");
  }
  const data = await sdk.request("POST",
    "/public/sdk/invitations/" + encodeURIComponent(exposureId) + "/" + action,
    { sessionId, ...(conversationId ? { conversationId } : {}),
      ...(failureCode ? { failureCode } : {}) });
  if (action === "accepted") sdk.rememberConversation(data);
  return data;
}
async function disconnect() {
  return sdk.request("POST", "/public/sdk/presence/disconnect", { sessionId });
}

await heartbeat();
const pendingInvitations = await pollInvitations();
// Render each exposureId once, then acknowledge "shown" only after visible rendering.
// Buttons call "accepted" or "dismissed"; rendering failure calls "failed" + failureCode.
// Repeat heartbeat ~15 s (TTL 90 s) and invitation poll ~5 s while the page is active.
// Stop both loops before disconnect(); unload delivery is best-effort.

Сначала heartbeat, затем invitations с тем же sessionId. Сессия живёт 90 секунд; виджет обновляет её примерно раз в 15 секунд. Полученное приглашение ещё не показано: подтвердите shown после отрисовки, затем одно из accepted, dismissed, failed. Терминальное действие нельзя заменить другим. accepted может вернуть новый диалог и visitor token — сохраните оба. При выходе остановите циклы и отправьте disconnect; доставка при закрытии вкладки не гарантируется.

Для accepted пример передаёт собственный conversationId из identify/messages, чтобы следующий вопрос с прежним externalId попал в тот же диалог. Если принять приглашение без этого поля, runtime создаёт диалог со служебным externalId: polling работает, но следующий messages с прежним идентификатором посетителя может вернуть sdk_conversation_tenant_mismatch. Не подменяйте идентификатор догадкой и не используйте чужой conversationId.

Оценка и добровольный комментарий

JavaScript · продолжение quickstart
// Continue after the quickstart; wire these functions to explicit user actions.
async function submitRating(score, idempotencyKey) {
  const { conversationId, visitorSessionToken } = sdk.getState();
  return sdk.request("POST",
    "/public/sdk/conversations/" + encodeURIComponent(conversationId) + "/ratings",
    { scale: "CSAT", score, idempotencyKey, visitorSessionToken });
}

async function chooseFeedbackComment() {
  const { conversationId, visitorSessionToken } = sdk.getState();
  return sdk.request("POST",
    "/public/sdk/conversations/" + encodeURIComponent(conversationId) + "/csat-feedback/decline",
    { visitorSessionToken });
  // Legacy name: declined=true means offered -> awaiting, NOT "skip comment".
}

async function sendFeedbackComment(text) {
  const current = await sdk.poll(); // also refreshes the visitor token
  if (current?.csatSurvey?.state !== "feedback") {
    throw new Error("Feedback window is not active; ask before starting a new appeal");
  }
  const sent = await sdk.sendMessage(text);
  // A concurrent state change can still close the feedback window.
  return { recordedAsFeedback: sent.recordedAsFeedback === true, message: sent };
}

// 1. When csatSurvey.state === "rating", create and keep one key for this rating:
// const ratingKey = crypto.randomUUID();
// const rating = await submitRating(5, ratingKey);
// 2. Only if rating.feedback.offered and the user chooses "Оставить отзыв":
// await chooseFeedbackComment();
// 3. On the next explicit submit: await sendFeedbackComment("Спасибо за помощь");
// To ask a new question instead: do NOT call chooseFeedbackComment; sendMessage normally.

После закрытия диалога polling показывает csatSurvey.state=rating. Передайте в ratings scale=CSAT или CSI, целую оценку 1–5 и стабильный idempotencyKey для этой оценки. Ответ feedback.offered=true приглашает оставить отзыв, но ещё не переводит следующий текст в комментарий.

Историческое имя decline не означает «пропустить»

Кнопка «Оставить отзыв» вызывает /csat-feedback/decline: в текущем runtime она меняет offered → awaiting, а declined=true подтверждает именно этот переход. При csatSurvey.state=feedback следующее сообщение сохраняется как отзыв (recordedAsFeedback=true), обращение остаётся закрытым. Окно — 30 минут с момента предложения комментария. Без этого вызова обычный вопрос после оценки создаёт новое обращение. После окончания окна не обещайте пользователю сохранение текста как отзыва.

05 · Кастомный канал

Передавайте обращения из своего приложения

Open Channel — симметричный протокол { sender, recipient, message } для мобильных приложений, десктопных клиентов и собственных интерфейсов.

В «Настройки → Центр интеграций → Внешнее приложение» задайте очередь и «Адрес для ответов операторов». Этот callback URL называется outboundUrl (не apiUrl); позже его можно изменить в настройках подключения. Сохраните выданный channel token на своём сервере. Он не заменяет SDK public key и не предназначен для публикации в браузере.

cURL
curl -X POST "https://supportcom.ru/api/v1/open-channel/<channel_token>" \
  -H "Content-Type: application/json; charset=utf-8" \
  -d '{
    "sender": { "id": "customer_42", "name": "Анна" },
    "message": {
      "type": "text",
      "id": "order_1024_question_1",
      "date": 1787788800,
      "text": "Нужна помощь с заказом"
    }
  }'
Приём событийPOST /open-channel/:channelToken
Активность каналаGET /open-channel/:channelToken/status → 0 | 1
Повтор входящего сообщенияСохраняйте тот же message.id и payload. Для нового сообщения нужен новый id; это не универсальная идемпотентность всех типов событий.

Успех входящего запроса — JSON { "result": "ok" }, а не SDK-envelope. Ошибки бывают plain text: например invalid_client (401), invalid_request (400). /status возвращает текст 0/1 об активности канала в текущей выборке, а не статус конкретного клиента или доступность оператора. Типы text, медиа, location, rate, seen, keyboard, typein, start, stop описаны в OpenAPI.

06 · Исходящие события

Принимайте ответы операторов и события

Исходящие callback-запросы описаны отдельно: OpenAPI 3.1 Webhooks. В спецификации используются webhooks, а не вымышленные маршруты клиентского API.

Ответы Open Channel

Сервер отправляет на ваш outboundUrl JSON { sender?, recipient, message }. recipient.id — внешний идентификатор клиента. Сохраните сообщение до подтверждения; учитывайте повтор по message.id, когда он есть.

Event Webhooks

chat_accepted, chat_updated, chat_finished, client_updated, client_attribute_updated, offline_message доставляются на URL подписки. У client_attribute_updated отдельная форма с client_id и attributes, без общего chat-envelope.

Текущий пробел настройки: отдельного UI управления Event Webhook subscriptions нет. URL и список событий должен подготовить администратор рабочего пространства или поддержка. Раздел «API и webhooks → Webhook endpoints» не подключает эти шесть событий. Не используйте внутренние административные маршруты как публичный API.

Событие принятого диалога

JSON
{
  "event_name": "chat_accepted",
  "widget_id": "sdk_connection_1",
  "user_token": "customer_42",
  "chat_id": 537183224,
  "agent": {
    "email": "",
    "id": "operator_7",
    "name": "Мария"
  },
  "assigned_agent": null,
  "organization": null,
  "status": null,
  "tags": [
    {
      "id": "vip",
      "title": "vip"
    }
  ],
  "visitor": {
    "chats_count": 1,
    "description": "",
    "email": "customer@example.com",
    "name": "Анна",
    "number": "122557678",
    "phone": "+79990000000",
    "social": {}
  },
  "session": {
    "geoip": {},
    "ip_addr": "",
    "user_agent": "",
    "utm": "",
    "utm_json": {}
  },
  "page": {
    "url": "https://shop.example/orders/1024"
  },
  "analytics": {}
}

Минимальный приёмник с сохранением до ACK

Node.js 22+
// Node.js 22+, save as receiver.mjs. Terminate HTTPS at your reverse proxy.
// Set WEBHOOK_PATH to /callbacks/<long-random-secret>.
// Open Channel: set this HTTPS callback URL in the connection UI.
// Event subscriptions: arrange the URL/events with your workspace administrator/support.
import { createServer } from "node:http";
import { open } from "node:fs/promises";

const callbackPath = process.env.WEBHOOK_PATH;
if (!/^\/callbacks\/[A-Za-z0-9_-]{32,}$/.test(callbackPath || "")) {
  throw new Error("Set a long random WEBHOOK_PATH; do not log it");
}
const eventNames = new Set([
  "chat_accepted", "chat_updated", "chat_finished",
  "client_updated", "client_attribute_updated", "offline_message"
]);
let writeQueue = Promise.resolve();
function persist(payload) {
  // Minimal durable inbox, not a complete business-event consumer.
  writeQueue = writeQueue.catch(() => {}).then(async () => {
    const file = await open("webhook-inbox.ndjson", "a", 0o600);
    try {
      await file.appendFile(JSON.stringify({ receivedAt: new Date().toISOString(), payload }) + "\n");
      await file.sync();
    } finally { await file.close(); }
  });
  return writeQueue;
}

const server = createServer(async (request, response) => {
  if (request.url !== callbackPath) { response.writeHead(404).end(); return; }
  if (request.method !== "POST") { response.writeHead(405).end(); return; }
  const chunks = [];
  let bytes = 0;
  try {
    for await (const chunk of request) {
      bytes += chunk.length;
      if (bytes > 1024 * 1024) { response.writeHead(413).end(); return; }
      chunks.push(chunk);
    }
    let payload;
    try { payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
    catch { response.writeHead(400).end(); return; }
    const event = payload && eventNames.has(payload.event_name);
    const message = payload?.recipient?.id && typeof payload?.message?.type === "string";
    if (!event && !message) { response.writeHead(400).end(); return; }
    // Validate all consumed fields against the webhook OpenAPI contract.
    // No HMAC/Authorization/delivery-id is sent by these public callback flows.
    // Treat all input as untrusted; the secret URL is not a signed identity.
    await persist(payload);
    response.writeHead(204).end(); // Any 2xx acknowledges delivery; body is optional.
  } catch {
    response.writeHead(503).end(); // Persist failed; this response is retryable.
  }
});
server.listen(8080, "127.0.0.1");
Любой 2xx — ACK; JSON-тело необязательно. Сначала надёжно сохраните, затем отвечайте. Не ставьте redirect: 3xx не считается успехом.

Доставка: максимум 3 попытки всего, таймаут 10 секунд. Сеть, таймаут, 3xx, 5xx и ошибки 408, 425, 429 допускают повтор; остальные 4xx завершают доставку. Задержки между попытками — 3/6 секунд для Open Channel и 5/10 для Event Webhooks плюс ожидание очереди. Быстро сохраняйте в надёжный inbox, а бизнес-обработку выполняйте отдельно. Пример локального NDJSON-inbox не реализует схему бизнес-валидации, retention, мониторинг или обработчик очереди.

У этих двух callback-контуров сейчас нет подписи

Отправляется только Content-Type: application/json; charset=utf-8: нет HMAC, Authorization, idempotency-key или delivery-id. Используйте HTTPS и непредсказуемый URL, не пишите его в логи; URL не доказывает подлинность отправителя. Проверяйте форму данных и не выдавайте права по callback. Для Event Webhooks нет event id: применяйте идемпотентные обновления бизнес-состояния; краткоживущий hash payload может убрать повтор, но также склеить два одинаковых реальных события. Гарантии exactly-once нет.

Только для chat_accepted и chat_updated ответ { "result": "ok", "contact_info": { "name": "Анна" } } может дополнить карточку. Доступны также custom_data, crm_link, enable_assign, page; точные поля — в webhook-контракте. Обычный ACK без такого JSON карточку не меняет.

07 · Надёжная интеграция

Ошибки, безопасность и повторы

SDK сообщает бизнес-результат в поле status. Open Channel и webhook-доставка дополнительно используют HTTP-коды для решения о повторе.

status !== "ok"Бизнес-отказ возможен и при HTTP 200. Название статуса зависит от маршрута.
error.codeВыбирайте действие по коду: даже invalid может означать проблему ключа, а не тела запроса.
HTTP 429 / 503Rate limiter отвечает status=error; envelope может не содержать data и traceId. Для 429 учитывайте Retry-After.
error.codeДействие клиента
public_api_key_required / public_api_key_invalidПроверьте Bearer-ключ SDK; отозванный ключ замените в настройках.
public_api_key_environment_mismatch / public_api_scope_deniedСопоставьте environment со средой ключа и выдайте нужный scope.
sdk_connection_inactive / sdk_routing_queue_unresolvedПроверьте активное SDK-подключение, привязку ключа и очередь.
sdk_presence_session_id_required / sdk_presence_session_not_liveИспользуйте тот же sessionId и сначала выполните heartbeat.
conversation_not_found / sdk_conversation_tenant_mismatchПроверьте пару externalId/conversationId и рабочее пространство ключа.
visitor_session_token_required / visitor_session_token_malformed / visitor_session_token_invalid / visitor_session_token_scope_mismatchНе меняйте непрозрачный токен; передавайте его для того же диалога и tenant.
visitor_session_token_expiredОстановите polling. Refresh-маршрута нет; следующий осознанный запрос messages выдаст токен.
message_content_required / sdk_attachment_limit_exceeded / quality_rating_invalid / idempotency_key_invalidНужен текст или до 10 файлов; для оценки — CSAT/CSI, целое 1–5 и ключ до 200 символов.
quality_rating_operator_unresolvedВ активном диалоге ещё не определён оператор; не повторяйте оценку циклом.
file_name_required / file_not_foundПроверьте имя файла и fileId из дескриптора этого рабочего пространства.
sdk_attachment_scan_pending / sdk_attachment_validation_unavailableНе отправляйте сообщение: опрашивайте status с ограниченным backoff и повторите тот же запрос только после ready=true.
sdk_attachment_not_found / sdk_attachment_upload_incomplete / sdk_attachment_scan_rejectedПроверьте tenant и finalize; заблокированный антивирусом файл удалите из сообщения.
attachment_quota_exceeded / attachment_upload_state_limit_exceededДостигнута квота или лимит незавершённых загрузок; обратитесь к администратору.
object_metadata_missing / object_size_mismatch / object_checksum_mismatchПроверьте завершение signed transfer, размер и checksum до отправки вложения.
proactive_exposure_action_invalid / proactive_exposure_id_required / proactive_exposure_not_foundПроверьте action, exposureId, живую сессию и допустимый переход.
rate_limit_exceeded / rate_limit_unavailableHTTP 429: соблюдайте Retry-After; HTTP 503: ограниченный backoff только для безопасного повтора.
Не делайте слепые повторы

Пример backoff повторяет только чтение сообщений, не POST. У messages, создания upload и finalize нет публичной гарантии идемпотентности: потерянный ответ не означает, что операция не выполнена. Не посылайте их автоматически заново. ratings требует ключ в JSON-теле и дедуплицирует запись оценки, но повтор может снова изменить CSAT-состояние; не переигрывайте оценку после начала комментария. Логируйте только код ошибки, HTTP-статус и доступный traceId — без ключей, visitor token и signed URL.

08 · Жизненный цикл API

Версии, изменения и устаревание

Текущая стабильная версия — v1. Несовместимые изменения выпускаются под новым префиксом версии. Об устаревании действующего маршрута документация сообщает заранее вместе с рекомендуемой заменой и датой отключения.

27 августа 2026Уточнены схемы ответов, токены, файлы, CSAT, ошибки и публичные callback-контракты; добавлен исполнимый quickstart. Выполнение запросов в OpenAPI-интерфейсе отключено.
Проверка интеграцииПеред обновлением сравните машинную спецификацию и повторите сценарии в тестовой среде.
Нужны точные схемы?

Откройте интерактивную спецификацию

В OpenAPI доступны схемы тел запросов, параметры строки запроса и пути, модели ответов и актуальные публичные маршруты текущей версии.

Открыть OpenAPI