Diil Docs
  1. Документация
  2. Виджет

Чат поддержки: онлайн-чат на сайте одним тегом

Обновлено:

У посетителя вопрос в одиннадцать вечера, а форма обратной связи для него — письмо в бутылке. Чат поддержки решает это: кнопка чата на каждой странице сайта, а переписка падает в CRM, где ваша команда отвечает в реальном времени. Если widget.js у вас уже стоит ради живого редактирования, до чата ровно ноль строк кода.

Как устроен чат поддержки

  1. Посетитель нажимает кнопку чата в углу сайта и пишет сообщение — а если слов не хватает, прикладывает скриншот.
  2. Сообщение тут же появляется в CRM → Поддержка → Чаты. Всё идёт через WebSocket, жать F5 никому не нужно.
  3. Оператор отвечает из CRM, посетитель сразу видит ответ — вместе с «печатает…» и отметками о прочтении.

Чтобы чат не потерялся, пока все пьют кофе, у операторов есть счётчик непрочитанных в CRM, уведомления браузера и, по желанию, Telegram-бот с тихими часами. Всё это настраивается в CRM, а не в вашем коде.

Если на сайте включён режим согласия, чат не ждёт, пока посетитель разберётся с баннером: кнопка на месте, написать можно сразу. Разница только под капотом:

  • до согласия виджет не создаёт и не хранит crm_vid, поэтому чат получает одноразовый идентификатор, который живёт только в памяти вкладки и никуда не записывается;
  • разговор, начатый раньше, по-прежнему восстанавливается по своему crm_chat_token;
  • без идентификаторов посетителя и сессии чат не привязывается к рекламному каналу и UTM-меткам — атрибуции у такого разговора нет.

Как добавить онлайн-чат на сайт

Отдельного кода для чата нет. Хватает того же единственного тега, на котором держится всё остальное:

Перед </body>, на каждой страницеhtml
<script src="https://widget.sitecog.com/widget.js" defer></script>

Когда чат для сайта доступен, widget.js сам подгружает модуль чата (widget.support.js), и в углу появляется плавающая кнопка. Когда недоступен — посетители не скачивают ничего лишнего. Если тег случайно попал на страницу дважды, ничего страшного: вторая копия игнорируется.

  1. Убедитесь, что чат входит в ваш тариф

    Чат работает на тарифах, где он есть. Нет чата в тарифе — нет и кнопки, что бы ни было написано в коде.
  2. Проверьте, что домен добавлен как сайт в CRM

    Чат узнаёт сайт по домену, на котором открыта страница (Origin браузера; www. не учитывается). Этот домен должен быть сайтом в CRM.
  3. Поставьте widget.js на страницу

    Тег выше или варианты для Next.js и Vite из раздела Виджет и разметка → Шаг 1.
  4. Оформите чат в CRM

    Иконка, цвета, положение и тексты — в CRM → Поддержка → Настройки, подробности ниже.

Внешний вид и тексты в CRM

Всё, что касается внешнего вида, настраивается в CRM → Поддержка → Настройки. Никаких переопределений CSS и выкладок: поменяли там — виджет подхватил.

НастройкаДопустимые значенияЗаметки
Иконкаbubble, headset, question, envelope, spark или своя картинкаСвою иконку выбирают из файлового хранилища сайта.
Положениеbottom-right по умолчанию, bottom-left, top-right, top-leftВыбирайте угол, где не живёт баннер про cookie.
Цветовая схемаindigo по умолчанию, emerald, midnight, graphiteГотовые палитры для кнопки и окна чата.
Заголовокдо 80 символовШапка окна чата.
Подзаголовокдо 160 символовСтрока под заголовком — хорошее место для «обычно отвечаем за 10 минут».
Подсказка в поле вводадо 80 символовСерый текст в пустом поле сообщения.
Приветствиедо 200 символовПоказывается в пустом чате, пока посетитель ничего не написал.

Тексты на разных языках и запасной вариант

Каждый текст можно заполнить для каждого языка сайта. Виджет выбирает текст под язык посетителя так:

  1. точное совпадение языка;
  2. иначе — совпадение по первым двум буквам;
  3. иначе — первый заполненный вариант.

Если текст пустой, виджет берёт свой встроенный. Так что можно вообще ничего не заполнять и получить вполне приличный чат — настройки нужны, когда хочется, чтобы он говорил вашим голосом.

Автоответ

Включите автоответ и напишите текст для каждого языка (до 1000 символов). Он отвечает на первое сообщение нового разговора — посетитель видит, что его услышали, даже если вся команда на планёрке. Новым разговор считается после N минут тишины: N вы задаёте сами, от 1 до 180, по умолчанию 5.

Когда никого нет в сети

Живой чат, где никто не отвечает, — ловушка: посетитель задаёт вопрос, ждёт, закрывает вкладку, и вы так и не узнаёте, кто это был. Офлайн-режим в такие минуты превращает чат в форму «оставьте почту» — и вопрос не исчезает вместе с посетителем.

Включается он в CRM → Поддержка → Настройки → «Когда никого нет в сети». Чат уходит в офлайн-режим, когда:

  • ни у одного оператора не открыта CRM этого сайта. Считается любая страница CRM, не только чаты: оператор «в сети», пока не закрыта последняя вкладка CRM;
  • сейчас нерабочее время, если вы его задали: рабочие дни, время «с» и «до» и часовой пояс. Расписание необязательно — без него действует только первое правило. Вне рабочего времени чат в офлайне, даже если у кого-то случайно открыта CRM.

Что меняется в офлайн-режиме:

  1. Виджет пишет «Сейчас мы не в сети — оставьте почту, и мы ответим» и просит почту до сообщения. Без почты сообщение не уйдёт: иначе ответу просто некуда было бы прийти.
  2. В CRM чат помечается «Офлайн-заявка», и почта посетителя видна тут же.
  3. Уведомление в Telegram (если бот подключён) уходит сразу — такой чат ждёт ответа по определению.

Пока операторы в сети, чат работает как обычно, а почта необязательна: посетитель всё равно может нажать «Получать ответы на почту» — пригодится тому, кто вот-вот закроет вкладку.

Ответы на почту

Если посетитель оставил почту и не прочитал ответ оператора в виджете примерно за 2 минуты, ответ уходит ему письмом. Несколько ответов подряд приходят одним письмом, а не очередью уведомлений. В письме только ответы оператора — не вся переписка — и ссылка на ту страницу вашего сайта, с которой писал посетитель: продолжить разговор можно прямо там.

Язык виджета

Виджет говорит на языке вашей страницы: читает <html lang> и берёт первые две буквы, так что en-GB и en для него одно и то же.

  • Встроенные тексты интерфейса есть для ru, en, uk, es, de и zh. Для остальных языков — английский.
  • Ваши тексты из CRM выбираются по правилам выше.
  • Одностраничное приложение с переключателем языка? Просто поменяйте document.documentElement.lang — виджет заметит и перерисует тексты на лету, без перезагрузки.
Смена языка в SPAjs
function setLanguage(lang) {
  // ...переключаем свои переводы...
  document.documentElement.lang = lang; // чат подстроится сам
}

Как передать в чат вошедшего пользователя

По умолчанию оператор видит «Посетитель». Если на сайте есть личные кабинеты, можно лучше: положите имя пользователя и ваш внутренний id в localStorage, и оператор будет знать, с кем говорит.

support_client_namestring, ≤ 80 символовнеобязательно
Показывается оператору вместо «Посетитель». По нему ищут чаты в CRM.
support_client_idstring, ≤ 64 символовнеобязательно
Ваш внутренний id пользователя (например, user_8421). Виден в карточке посетителя, чтобы оператор нашёл аккаунт в вашей админке.

Виджет эти ключи только читает. Он отправляет их при подключении и ещё раз перед каждым сообщением, если они изменились, — поэтому неважно, запишете вы их до загрузки страницы или сразу после входа. При выходе удаляйте оба ключа: иначе следующий человек за тем же компьютером унаследует чужое имя.

// support-identity.js
function setSupportIdentity(user) {
  try {
    if (user) {
      localStorage.setItem('support_client_name', user.name.slice(0, 80));
      localStorage.setItem('support_client_id', String(user.id).slice(0, 64));
    } else {
      localStorage.removeItem('support_client_name');
      localStorage.removeItem('support_client_id');
    }
  } catch {
    // Хранилище заблокировано (строгие настройки приватности) — чат работает, просто анонимно
  }
}

// После успешного входа
setSupportIdentity({ id: 'user_8421', name: 'Анна Шмидт' });

// При выходе
setSupportIdentity(null);

Вложения и ограничения

Посетители могут прикладывать файлы — скриншот ошибки стоит тысячи слов.

ЧтоОграничение
Картинкиjpeg, png, gif, webp, avif
Документыpdf, doc, docx, xls, xlsx, txt, csv
Размер файладо 10 МБ
Сообщения20 в минуту на один чат
Подключениядо 12 одновременно с одного IP
Новые чаты10 в час с одного IP

Людям этих лимитов хватает с запасом, ботам — мешают, в этом и смысл. Упереться в лимит подключений можно разве что из офиса, где вся команда сидит за одним IP и держит сайт открытым в десятке вкладок.

Открыть чат своей кнопкой

Круглая кнопка в углу подходит большинству сайтов, но иногда чату место в вашем собственном дизайне: ссылка «Написать нам» в шапке, кнопка на странице тарифов, значок помощи в приложении. Есть два способа — и один из них без единой строчки JavaScript.

Без JavaScript: data-crm-chat

Поставьте data-crm-chat на любой элемент — и клик по нему откроет чат:

Любая страницаhtml
<a href="/contacts" data-crm-chat="open">Написать нам</a>

<button type="button" data-crm-chat="toggle">💬 Поддержка</button>
  • open (он же — для пустого значения), close или toggle.
  • Элементы, добавленные позже — модальные окна, страницы одностраничного приложения, — тоже работают: виджет слушает весь документ.
  • Встроенный запасной вариант. Если чат на сайте недоступен (или widget.js заблокирован), клик не перехватывается, и ссылка просто ведёт по своему href. Направьте её на страницу контактов — и никто не упрётся в мёртвую кнопку.

Из JavaScript: window.crmChat

crmChat.open()functionнеобязательно
Открывает панель чата.
crmChat.close()functionнеобязательно
Закрывает её.
crmChat.toggle()functionнеобязательно
Открывает, если закрыта, и закрывает, если открыта.
crmChat.isOpen()() => booleanнеобязательно
Открыта ли панель прямо сейчас.
crmChat.availableboolean | nullнеобязательно
true — чат на сайте работает; false — нет (тариф, настройки); null — ещё неизвестно, виджет как раз спрашивает.
crmChat.unreadnumberнеобязательно
Ответы оператора, которые посетитель ещё не видел. Пока панель открыта — всегда 0.

widget.js создаёт window.crmChat сразу, ещё до загрузки самого чата. Вызовы, сделанные в эту первую секунду, запоминаются и выполняются, как только чат готов, — клик по вашей кнопке не потеряется. В режиме Live в CRM чат не загружается вовсе, а crmChat тихо ничего не делает — так что и там ваш код не упадёт.

document.querySelector('#help').addEventListener('click', () => {
  // ?. — на случай, если блокировщик рекламы не дал загрузиться widget.js
  window.crmChat?.open();
});

Скрыть встроенную кнопку

Есть своя кнопка? Спрячьте круглую кнопку виджета атрибутом data-chat-button="hidden" на том же теге скрипта. Панель открывается и закрывается как обычно — у неё свой крестик в шапке.

<script src="https://widget.sitecog.com/widget.js" data-chat-button="hidden" defer></script>

Показать непрочитанные на своей кнопке

Без круглой кнопки посетителю нужен другой способ заметить, что оператор ответил. Виджет при каждом изменении присылает событие crm:chat на window с { available, open, unread } в detail. Сделайте по нему значок — и спрячьте свою кнопку, если чат недоступен.

useCrmChat.tstsx
import { useEffect, useState } from 'react';

type ChatState = { available: boolean | null; open: boolean; unread: number };

export function useCrmChat(): ChatState {
  const [state, setState] = useState<ChatState>({ available: null, open: false, unread: 0 });

  useEffect(() => {
    const api = window.crmChat;
    if (api) setState({ available: api.available, open: api.isOpen(), unread: api.unread });

    const onChange = (e: Event) => setState((e as CustomEvent<ChatState>).detail);
    window.addEventListener('crm:chat', onChange);
    return () => window.removeEventListener('crm:chat', onChange);
  }, []);

  return state;
}

// Как пользоваться
export function ChatButton() {
  const { available, unread } = useCrmChat();
  if (available === false) return null;

  return (
    <button type="button" onClick={() => window.crmChat?.open()}>
      Поддержка {unread > 0 && <span className="badge">{unread}</span>}
    </button>
  );
}

Content Security Policy для чата

Если CSP на сайте нет — смело пропускайте раздел. Если есть, чату нужны такие источники:

Добавьте в свою Content-Security-Policytext
script-src  https://widget.sitecog.com
connect-src https://back.sitecog.com wss://back.sitecog.com
style-src   'unsafe-inline'
img-src     <хост, с которого отдаются файлы из CRM>
  • script-src — чтобы загрузились widget.js и модуль чата.
  • connect-src — для запроса настроек и WebSocket. Обратите внимание на wss://: одного https:// для WebSocket недостаточно.
  • style-src — окно чата живёт в shadow DOM со встроенным <style>, поэтому строгой политике может понадобиться 'unsafe-inline' (например, style-src 'self' 'unsafe-inline').
  • img-src — вложения и своя иконка отдаются из файлового хранилища сайта. Откройте любую картинку из CRM и разрешите её хост.

Для продвинутых: свой интерфейс чата

Свой клиент — это три шага и ещё один, когда никого нет в сети:

  1. Прочитать настройки

    Включён ли чат для сайта, что написано в текстах и есть ли кто-то в сети?
  2. Начать чат

    Получить по HTTP токен чата и сохранить его.
  3. Оставить почту — в офлайн-режиме

    Ответить сейчас некому? Сохраните почту посетителя до его первого сообщения.
  4. Разговаривать через WebSocket

    Подключиться с токеном, отправлять и получать кадры.

Все адреса поддержки публичные, ключ API не нужен. Сайт узнаётся по заголовку Origin браузера (запасной вариант — Referer), поэтому вызывайте их со страниц на настоящем домене. Домен, который не добавлен сайтом в CRM, получит 400 с {"message":"unknown_domain"}, запрос совсем без источника — {"message":"unknown_origin"}.

1. Прочитать настройки чата

GET https://back.sitecog.com/support/config?lang=en
langquerystringнеобязательно
Язык текстов, например en. Работают те же правила выбора.
{
  "enabled": true,
  "look": {
    "icon": "bubble",
    "iconUrl": null,
    "position": "bottom-right",
    "skin": "indigo",
    "texts": {
      "title": null,
      "subtitle": null,
      "placeholder": null,
      "greeting": null
    }
  },
  "offline": {
    "form": true,
    "away": true,
    "reason": "no_agents"
  }
}
enabledboolean
Доступен ли чат для этого сайта. false — ничего не показывайте и дальше не идите.
lookobject
То, что выбрано в CRM → Поддержка → Настройки. Есть только при enabled: true.
look →
iconstring
bubble, headset, question, envelope или spark.
iconUrlstring | null
Адрес своей картинки-иконки; null, если выбрана встроенная.
positionstring
bottom-right, bottom-left, top-right или top-left.
skinstring
indigo, emerald, midnight или graphite. В своём интерфейсе сопоставьте со своими цветами — или проигнорируйте.
textsobject
Тексты для запрошенного языка. null — «не заполнено, берите свой текст по умолчанию».
texts →
titlestring | null
Заголовок окна чата, ≤ 80 символов.
subtitlestring | null
Строка под заголовком, ≤ 160 символов.
placeholderstring | null
Подсказка в поле ввода, ≤ 80 символов.
greetingstring | null
Приветствие в пустом чате, ≤ 200 символов.
offlineobject
Может ли кто-то ответить прямо сейчас — см. Когда никого нет в сети. Есть только при enabled: true. Присутствие меняется поминутно, так что переспрашивайте при открытии окна чата, а не держите ответ в кэше подолгу.
offline →
formboolean
Офлайн-режим включён в CRM. false — почту не просите никогда, просто чат.
awayboolean
true — ответить сейчас некому: покажите форму и попросите почту до первого сообщения (шаг 3). При form: false всегда false.
reasonstring | null
Почему чат в офлайне: after_hours — нерабочее время, no_agents — ни у одного оператора не открыта CRM. null, когда away равно false.

2. Начать или продолжить чат

POST https://back.sitecog.com/support/chat/start
Content-Type: application/json

{
  "visitorId": "5f1c2a9e-3b7d-4c1e-9a0f-8d2b6e4c7a10",
  "lang": "en",
  "token": "<токен, полученный в прошлый раз, если он есть>"
}
visitorIdstringобязательно
Постоянный id этого браузера: 6–128 символов из A–Z a–z 0–9 _ . : -. Если на странице работает widget.js, возьмите его crm_vid из localStorage; если нет — сгенерируйте свой один раз (UUID подойдёт) и храните. Включён режим согласия, а согласия ещё нет? Тогда crm_vid не будет — сгенерируйте временный id и держите его в памяти, ничего не сохраняя.
langstringнеобязательно
Язык посетителя, например en.
tokenstringнеобязательно
Токен чата из прошлого вызова. Токен действителен — возвращается тот же чат с историей. Токена нет или он недействителен — создаётся новый чат (а новых чатов не больше 10 в час с одного IP, так что токен лучше не терять).
Ответjson
{
  "enabled": true,
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
  "chatId": 123,
  "lastSeq": 5,
  "readSeq": 5,
  "status": "open",
  "messages": [
    { "seq": 1, "author": "visitor", "text": "Здравствуйте! А в Австрию доставляете?", "at": "2026-10-01T09:12:03.000Z" },
    { "seq": 2, "author": "agent", "text": "Здравствуйте, Анна! Да, 2–4 дня.", "at": "2026-10-01T09:13:40.000Z" }
  ],
  "email": null,
  "offline": { "form": true, "away": false, "reason": null }
}
enabledboolean
false — чат для сайта выключен, больше в ответе ничего нет.
tokenstring (JWT)
Токен чата, действует 180 дней. Сохраните его (например, в localStorage) и всегда перезаписывайте последним полученным. Им открывается WebSocket и продолжается этот же чат в следующий раз.
chatIdnumber
Id чата.
lastSeqnumber
Номер последнего сообщения. Сообщения в чате нумеруются 1, 2, 3…; seq — ваш курсор во всём, что ниже.
readSeqnumber
До какого сообщения посетитель дочитал.
statusstring
open или closed — как выставили операторы в CRM.
messagesMessage[]
Последние 50 сообщений, от старых к новым. Форма Message та же, что и в кадрах WebSocket.
emailstring | null
Почта, которую посетитель уже оставил в этом чате. Второй раз не спрашивайте — покажите «Ответим на …» с возможностью сменить адрес.
offlineobject
Та же форма, что offline в настройках: { form, away, reason }, на момент запроса.
errorstring | null
Приходит вместо токена, если чат начать не удалось: invalid_visitor (неверный visitorId) или rate_limit_exceeded (слишком много новых чатов с этого IP).

3. Оставить почту

Когда offline.away равно true, сохраните почту посетителя до отправки его первого сообщения — так делает встроенный виджет, и только так ответ дойдёт до человека, который уже закрыл вкладку. Пока операторы в сети, тот же вызов работает для необязательной кнопки «Получать ответы на почту».

POST https://back.sitecog.com/support/chat/contact
Content-Type: application/json

{
  "token": "<токен чата из chat/start>",
  "email": "anna@example.com",
  "page": "https://shop.example/delivery?utm_source=newsletter"
}
tokenstringобязательно
Токен чата из chat/start. Почту можно привязать только к своему чату.
emailstringобязательно
Куда присылать ответы. Повторный вызов с другим адресом заменяет прежний.
pagestringнеобязательно
Адрес страницы, с которой пишет посетитель. Из него получится ссылка «продолжить на сайте» в письме. Принимаются только страницы вашего сайта, а параметры и #фрагмент отбрасываются — адрес из примера сохранится как https://shop.example/delivery.
{ "ok": true, "email": "anna@example.com" }
errorЧто случилось
invalid_emailЭто не похоже на адрес почты. Попросите посетителя проверить.
invalid_tokenТокена нет, он истёк или выдан другому сайту. Вызовите chat/start заново.
rate_limit_exceededЛимит общий с сообщениями чата — 20 в минуту. Подождите немного и повторите.
disabledЧат для сайта выключили.
chat_not_foundЧат удалён. Забудьте токен и начните новый.
Офлайн-режим в своём клиентеjs
// chat — ответ chat/start (или более свежий кадр ready)
async function sendFirstMessage(text, email) {
  if (chat.offline.away && !chat.email) {
    const res = await fetch('https://back.sitecog.com/support/chat/contact', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ token: chatToken, email, page: location.href }),
    }).then((r) => r.json());

    if (!res.ok) return showEmailError(res.error); // сообщение не теряем, пусть поправит адрес
    chat.email = res.email;
  }
  send(text); // кадр send по WebSocket из шага 4
}

4. Подключиться к WebSocket

wss://back.sitecog.com/support/ws

Токен передаётся в списке подпротоколов, а не в адресе: адреса оседают в логах, подпротоколы — нет. Подпротоколов два: версия протокола и token. + ваш токен чата. Origin браузер отправит сам, и это должен быть ваш сайт.

const socket = new WebSocket('wss://back.sitecog.com/support/ws', [
  'crm.support.v1',
  'token.' + chatToken,
]);

socket.onopen = () => {
  // Здороваемся и сообщаем серверу последнее сообщение, которое у нас уже есть
  socket.send(JSON.stringify({ t: 'hello', since: lastSeq }));
};

На hello сервер отвечает кадром ready со всем, что вы пропустили после since. Каждый кадр — JSON-объект с типом в поле t, не больше 8 КБ. Сервер шлёт ping каждые 25 секунд; браузер отвечает на него сам, делать ничего не нужно.

Кадры, которые отправляете вы

hello{ t, since }
Начало сеанса. since — последний seq, который у вас есть (0, если ничего). Ответ — ready.
send{ t, id, text, file? }
Отправить сообщение. id — ваш собственный id сообщения, [A-Za-z0-9_-], 1–64 символа; повтор с тем же id никогда не создаёт дубль, так что повторяйте смело. text — до 4000 символов. file — квитанция загрузки (см. вложения).
read{ t, seq }
«Я прочитал всё до seq». Отсюда берутся отметки о прочтении, которые видит оператор.
typing{ t }
Посетитель печатает. Отправляйте не чаще раза в ~2 секунды, пока он набирает текст.
history{ t, before }
Подгрузить старые сообщения: по 50 штук до seq = before (0 — начиная с самых новых). Ответ — history.
profile{ t, name?, contact?, clientId? }
Рассказать оператору, кто это: name ≤ 80, contact (телефон или почта) ≤ 120, clientId ≤ 64. Хотя бы одно поле должно быть непустым.
Примерыjson
{ "t": "hello", "since": 5 }
{ "t": "send", "id": "m-1727775123-1", "text": "А в Австрию доставляете?" }
{ "t": "read", "seq": 7 }
{ "t": "typing" }
{ "t": "history", "before": 51 }
{ "t": "profile", "name": "Анна Шмидт", "contact": "anna@example.com", "clientId": "user_8421" }

Кадры, которые приходят вам

readyframe
Ответ на hello: состояние чата и всё пропущенное.
ready →
chatIdnumber
Id чата.
visitorOnlineboolean
В сети ли посетитель в этом чате.
lastSeqnumber
Номер последнего сообщения.
statusstring
open или closed.
read{ agent, visitor }
До какого seq дочитала каждая сторона.
unreadnumber
Сколько сообщений посетитель ещё не прочитал — удобно для значка на кнопке.
messagesMessage[]
Сообщения после since.
gapboolean
true, если пропущено больше, чем помещается в одну догрузку, — остальное подтяните через history.
emailstring | null
Почта, которую посетитель оставил в этом чате, — как в chat/start.
offline{ form, away, reason }
Может ли кто-то ответить прямо сейчас — та же форма, что в настройках. Приходит при каждом переподключении, так что долго открытая страница заметит, что операторы ушли или вернулись.
messageframe
Новое сообщение в чате — ответ оператора, автоответ и так далее.
message →
chatIdnumber
Id чата.
mMessage
Само сообщение.
m →
seqnumber
Порядковый номер в чате. По нему сортируйте и убирайте дубли.
authorstring
visitor, agent или system.
textstring
Обычный текст. Выводите его как текст (textContent), никогда как HTML.
atstring (ISO 8601)
Когда сообщение сохранено.
file{ url, name, mime, size, kind } | null
Есть только у вложений. kind — image или doc.
ack{ id, seq, at }
Ваш send с этим id сохранён как сообщение seq. Самое время сменить «отправляется…» на «отправлено».
read{ chatId, by, seq }
Кто-то (by) дочитал до seq — например, оператор прочитал ваши сообщения.
typing{ chatId, by }
Другая сторона печатает. Покажите точки на несколько секунд.
history{ chatId, before, messages, done }
Ответ на ваш запрос history. done: true — старше ничего нет.
presenceframe
Кто-то в чате появился в сети или ушёл.
chat_goneframe
Чат удалили в CRM. Выбросьте сохранённый токен и снова вызовите chat/start — начнётся новый чат.
error{ code, id? }
С кадром что-то не так; id заполнен, если речь о вашем сообщении. Коды — ниже.
Сообщение от оператораjson
{
  "t": "message",
  "chatId": 123,
  "m": { "seq": 6, "author": "agent", "text": "Посылка сегодня уехала со склада 🚚", "at": "2026-10-01T09:20:11.000Z" }
}

Минимальный клиент целиком

Старт → подключение → отправка → приём, плюс переподключения. Около 90 строк без зависимостей — допишите свои render и markSent, и чат готов.

support-chat.jsjs
const SUPPORT = 'https://back.sitecog.com/support';
const WS_URL = 'wss://back.sitecog.com/support/ws';

const store = {
  get: (k) => { try { return localStorage.getItem(k); } catch { return null; } },
  set: (k, v) => { try { localStorage.setItem(k, v); } catch {} },
  del: (k) => { try { localStorage.removeItem(k); } catch {} },
};

// Берём id посетителя от виджета, если он есть, иначе храним свой
function visitorId() {
  let id = store.get('crm_vid') || store.get('my_chat_vid');
  if (!id) {
    id = crypto.randomUUID();
    store.set('my_chat_vid', id);
  }
  return id;
}

let socket = null;
let lastSeq = 0;
let failures = 0;
const seen = new Set();

function show(m) {
  if (seen.has(m.seq)) return; // одно и то же сообщение может прийти из start, ready и message
  seen.add(m.seq);
  lastSeq = Math.max(lastSeq, m.seq);
  render(m); // ваш интерфейс: m.author, m.text (как текст!), m.at, m.file
}

async function boot() {
  const res = await fetch(SUPPORT + '/chat/start', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      visitorId: visitorId(),
      lang: (document.documentElement.lang || 'en').slice(0, 2),
      token: store.get('my_chat_token') || undefined,
    }),
  });
  const data = await res.json();
  if (!data.enabled) return;            // чат для сайта выключен
  if (data.error) throw new Error(data.error);

  store.set('my_chat_token', data.token); // действует 180 дней
  data.messages.forEach(show);
  lastSeq = Math.max(lastSeq, data.lastSeq);
  connect(data.token);
}

function connect(token) {
  socket = new WebSocket(WS_URL, ['crm.support.v1', 'token.' + token]);

  socket.onopen = () => {
    failures = 0;
    socket.send(JSON.stringify({ t: 'hello', since: lastSeq }));
  };

  socket.onmessage = (event) => {
    const frame = JSON.parse(event.data);
    if (frame.t === 'ready') frame.messages.forEach(show);
    else if (frame.t === 'message') show(frame.m);
    else if (frame.t === 'ack') markSent(frame.id, frame.seq);
    else if (frame.t === 'chat_gone') {
      store.del('my_chat_token');
      socket.onclose = null;
      socket.close();
      boot();                           // совершенно новый чат
    } else if (frame.t === 'error') console.warn('[chat]', frame.code, frame.id);
  };

  socket.onclose = (event) => {
    if (event.code === 4402 || event.code === 4403) return; // чат выключен / доступ запрещён: стоп
    failures += 1;
    const delay = Math.min(30000, 1000 * 2 ** failures) + Math.random() * 1000;
    // Несколько неудач подряд — возможно, токен больше не действует: начинаем заново
    setTimeout(() => (failures > 3 ? boot() : connect(token)), delay);
  };
}

function send(text) {
  const id = crypto.randomUUID();       // повторять безопасно: тот же id — то же сообщение
  socket.send(JSON.stringify({ t: 'send', id, text }));
  return id;                            // показываем «отправляется…» до ack с этим id
}

boot();

Переподключение и пропущенные сообщения

  • Храните наибольший показанный seq. После переподключения отправьте { "t": "hello", "since": lastSeq } — кадр ready принесёт ровно то, что вы пропустили.
  • Если ready.gap равен true, пропущено много — заполните дыру запросами history.
  • Одно сообщение может прийти дважды (например, из chat/start и из ready). Убирайте дубли по seq.
  • Делайте паузы между попытками (1 с, 2 с, 4 с… с небольшой случайной добавкой) — перезапуск сервера не должен превращаться в лавину переподключений.
  • Не уверены, дошло ли сообщение? Отправьте его ещё раз с тем же id — сохранится один раз.

Вложения в своём интерфейсе

Файл проходит три коротких шага: получить разрешение, загрузить файл, отправить квитанцию.

// 1. Разрешение на загрузку — действует 5 минут
const { permit } = await fetch('https://back.sitecog.com/support/chat/upload-permit', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ token: chatToken }),
}).then((r) => r.json());

// 2. Загружаем файл (картинки или документы, до 10 МБ)
const form = new FormData();
form.append('file', fileInput.files[0]);
const upload = await fetch('https://back.sitecog.com/storage/support/upload', {
  method: 'POST',
  headers: { 'x-support-permit': permit },
  body: form,
}).then((r) => r.json());
// upload = { receipt, url, name, mime, size, kind }

// 3. Отправляем сообщение с квитанцией (текст может быть пустым)
socket.send(JSON.stringify({ t: 'send', id: crypto.randomUUID(), text: '', file: upload.receipt }));

Ошибки и коды закрытия

Ошибки на живом соединении приходят как { "t": "error", "code": "…", "id": "…" }:

КодЧто случилось
rate_limit_exceededБольше 20 сообщений в минуту в этом чате. Подождите и повторите с тем же id.
invalid_textТекста нет или он длиннее 4000 символов.
invalid_fileКвитанцию загрузки не удалось проверить — истекла или от другого чата. Загрузите файл заново.
invalid_message_idid — не 1–64 символа из A–Z a–z 0–9 _ -.
frame_too_largeКадр больше 8 КБ.
invalid_jsonКадр — не корректный JSON.
unknown_frameНеизвестный t. Опечатка или кадр из более новой версии протокола.
empty_profileКадр profile, в котором все поля пустые.
chat_not_foundЧата больше нет. Начните новый.
disabledЧат для сайта выключен.

Само соединение тоже может быть отклонено или закрыто:

КогдаКодЗначение
Рукопожатие401 unauthorizedТокена нет, он неверный или истёк. Снова вызовите chat/start.
Рукопожатие429 too_many_connectionsБольше 12 подключений с этого IP. Закройте лишние.
Закрытие4402Чат для сайта выключили. Не переподключайтесь.
Закрытие4403Доступ запрещён. Не переподключайтесь.

Если что-то не работает

Кнопка чата не появляется

  • Чата нет в тарифе или он выключен. Адрес настроек отвечает { "enabled": false }.
  • Неизвестный домен. Страница открыта на домене, который не добавлен сайтом в CRM: тестовый хост, localhost, новый домен, который ещё не завели. (www. роли не играет — он отбрасывается.)
  • Мешает CSP. Поищите в консоли браузера «Refused to load» или «Refused to connect» и сверьтесь с разделом про CSP.
  • Вы смотрите на сайт внутри CRM. В режиме Live виджет загружает только редактор — ни чата, ни аналитики. Откройте сайт в обычной вкладке. (А внутри чужого iframe, куда кто-то встроил ваш сайт, редактор не загружается вовсе — он работает только во фрейме Live самой CRM.)
  • На этой странице нет widget.js — легко упустить, когда у сайта несколько шаблонов.
  • Чат только что включили, а браузер ещё помнит старые настройки — до ~10 минут (см. ниже).

Можно спросить у сервера напрямую, что он думает о вашем домене:

curl "https://back.sitecog.com/support/config?lang=en" -H "Origin: https://your-site.com"

Изменения из CRM не видны

  • Настройки кэшируются в браузере примерно на 10 минут. Либо подождите, либо удалите ключ crm_chat_cfg в DevTools → Application → Local Storage и обновите страницу.
  • Вы правили тексты для одного языка, а у страницы другой <html lang>. Сверьтесь с правилами выбора.
  • Поле текста пустое, поэтому виджет показывает встроенный текст — так и задумано.