Готовый виджет, управление через window.sw_api и обработчики событий.
Интеграции с Support Communication
Подключите готовый Web SDK, используйте публичный JSON API или передавайте обращения через Open Channel. Ниже описан полный рабочий цикл: ключи и среды, сообщения, файлы, ответы операторов, оценки и исходящие события.
https://supportcom.ru/api/v1Клиенты, сообщения, получение ответов, вложения и оценки.
Двунаправленный протокол для собственных приложений и каналов.
01 · Начало работы
Ключ, среда, права и структура ответа
Создайте публичный ключ для SDK-подключения, сохраните его сразу и передавайте в Bearer-заголовке. Open Channel использует отдельный токен в URL.
Три шага до первого запроса
- 1
Откройте «Настройки → Центр интеграций → Чат на сайте или в приложении». Создайте SDK-подключение с очередью и публичным ключом. Полный ключ сохраните при выдаче; для ротации используйте «API и webhooks».
- 2
Для тестов используйте
environment=stageи ключsk_test_…; для боевого трафика —productionиsk_live_…. - 3
Передавайте
environmentв строке запроса: без параметра используетсяproduction. ПоляenvironmentиtenantIdвнутриtraitsне меняют контекст — сервер отмечает их как отклонённые.
В браузер разрешено передавать только ключ SDK с минимальными правами. Не публикуйте токены оператора или администратора сервиса и не сохраняйте секреты в логах.
Authorization: Bearer sk_live_<public_api_key>clients:identifyИдентификация, статус присутствия и проактивные приглашения.conversations:writeСообщения, polling, файлы, client-info, agents/status, оценки и начало комментария CSAT.status === "ok". В ошибке читайте error.code; data и traceId бывают не у всех ответов.02 · Виджет на сайте
Подключите Web SDK
Добавьте опубликованный widget.js и инициализируйте виджет на страницах чата. Стабильный externalId связывает обращения одного клиента.
<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 и счётчиком непрочитанных.
После закрытия диалога виджет показывает оценку 1–5 и необязательный комментарий.
Основные методы интерфейса страницы: open, close, chatMode, setContactInfo, setCustomData, setClientAttributes, setUserToken, sendOfflineMessage и clearHistory.
03 · Клиенты и сообщения
Идентифицируйте, отправьте и получите ответ
Один копируемый пример: реализованный sdkRequest, затем identify → messages → poll. Замените тестовый ключ; остальные шаги используют один SDK-клиент и стабильный externalId.
1. Полный quickstart
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
// 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 });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 уже содержит ключ, среду, диалог и токен.
Клиент и диалог
/public/sdk/identifyСвязать внешний идентификатор с клиентом
/public/sdk/messagesОтправить текст или вложения
/public/sdk/conversations/:id/messagesПолучить ответы и CSAT-состояние
/public/sdk/client-infoОбновить контакты и дополнительные данные
/public/sdk/agents/statusПроверить доступность операторов
Сессия виджета
/public/sdk/presence/heartbeatПродлить присутствие посетителя
/public/sdk/presence/disconnectЗавершить присутствие
/public/sdk/invitationsПолучить проактивные приглашения
/public/sdk/invitations/:exposureId/:actionЗафиксировать показ, принятие или отказ
Файлы и качество
/public/sdk/uploadsПолучить параметры загрузки файла
/public/sdk/uploads/:fileId/finalizeПодтвердить загрузку
/public/sdk/uploads/:fileId/statusДождаться безопасной готовности файла
/public/sdk/conversations/:id/ratingsПередать CSAT/CSI 1–5
/public/sdk/conversations/:id/csat-feedback/declineНачать ввод отзыва после оценки (legacy-имя)
Дескриптор → signed transfer → finalize → сообщение
// 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, а не дописать дубликат.
Присутствие и приглашения
// 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.
Оценка и добровольный комментарий
// 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 приглашает оставить отзыв, но ещё не переводит следующий текст в комментарий.
Кнопка «Оставить отзыв» вызывает /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 -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/:channelTokenGET /open-channel/:channelToken/status → 0 | 1message.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.
Событие принятого диалога
{
"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+, 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");Доставка: максимум 3 попытки всего, таймаут 10 секунд. Сеть, таймаут, 3xx, 5xx и ошибки 408, 425, 429 допускают повтор; остальные 4xx завершают доставку. Задержки между попытками — 3/6 секунд для Open Channel и 5/10 для Event Webhooks плюс ожидание очереди. Быстро сохраняйте в надёжный inbox, а бизнес-обработку выполняйте отдельно. Пример локального NDJSON-inbox не реализует схему бизнес-валидации, retention, мониторинг или обработчик очереди.
Отправляется только 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 может означать проблему ключа, а не тела запроса.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_unavailable | HTTP 429: соблюдайте Retry-After; HTTP 503: ограниченный backoff только для безопасного повтора. |
Пример backoff повторяет только чтение сообщений, не POST. У messages, создания upload и finalize нет публичной гарантии идемпотентности: потерянный ответ не означает, что операция не выполнена. Не посылайте их автоматически заново. ratings требует ключ в JSON-теле и дедуплицирует запись оценки, но повтор может снова изменить CSAT-состояние; не переигрывайте оценку после начала комментария. Логируйте только код ошибки, HTTP-статус и доступный traceId — без ключей, visitor token и signed URL.
08 · Жизненный цикл API
Версии, изменения и устаревание
Текущая стабильная версия — v1. Несовместимые изменения выпускаются под новым префиксом версии. Об устаревании действующего маршрута документация сообщает заранее вместе с рекомендуемой заменой и датой отключения.
Откройте интерактивную спецификацию
В OpenAPI доступны схемы тел запросов, параметры строки запроса и пути, модели ответов и актуальные публичные маршруты текущей версии.