Diil Docs
  1. Docs
  2. The widget

Support chat: live chat on your site in one tag

Updated:

A visitor has a question at 11 p.m. and your contact form feels like a message in a bottle. The support chat fixes that: a chat button on every page of your site, and the conversation lands in the CRM where your team answers in real time. If you already added widget.js for live editing, the chat is literally zero lines of code away.

How the support chat works

  1. A visitor clicks the chat button in the corner of your site and writes a message (with a screenshot, if words fail them).
  2. The message shows up instantly in CRM → Support → Chats. Everything runs over a WebSocket, so nobody has to press F5.
  3. An operator answers from the CRM, and the visitor sees the reply right away — typing indicators and read marks included.

So that nobody misses a chat while making coffee, operators get an unread counter in the CRM, browser notifications and, if you like, a Telegram bot with quiet hours. All of that is set up in the CRM, not in your code.

If your site runs the widget in consent mode, the chat doesn't wait for the cookie banner: a visitor with a question can write straight away. Until they consent, though, the chat uses a one-off identity that lives only in memory and isn't stored anywhere. An older conversation still comes back as usual through its own crm_chat_token.

The other difference: before consent there are no visitor and session ids, so the chat isn't linked to a channel, UTM tags or an ad link. The conversation itself is all there — only the “where did they come from” part is missing.

Add a live chat to your website

There is no chat-specific code. The same single tag that powers everything else does the job:

Before </body>, on every pagehtml
<script src="https://widget.sitecog.com/widget.js" defer></script>

When the chat is available for your site, widget.js loads the chat bundle (widget.support.js) by itself and a floating chat button appears. When it is not, visitors download nothing extra. Adding the tag twice is harmless — the second copy is ignored.

  1. Make sure your plan includes the support chat

    The chat is available on plans that include it. No chat in the plan — no button, no matter what the code says.
  2. Check that your domain is a site in the CRM

    The chat recognizes your site by the domain the page is opened on (the browser's Origin; www. is ignored). The domain must be a site you added in the CRM.
  3. Add widget.js to the page

    The tag above, or the Next.js / Vite variants from Widget & markup → Step 1.
  4. Style it in the CRM

    Icon, colours, position and texts live in CRM → Support → Settings — see below.

Appearance and texts in the CRM

Everything visual is configured in CRM → Support → Settings. No CSS overrides, no redeploys: change it there and the widget picks it up.

OptionAllowed valuesNotes
Iconbubble, headset, question, envelope, spark or your own imageA custom icon is picked from your site's file storage.
Positionbottom-right default, bottom-left, top-right, top-leftPick the corner your cookie banner is not sitting in.
Colour skinindigo default, emerald, midnight, graphiteReady-made palettes for the button and the chat window.
Titleup to 80 charactersThe header of the chat window.
Subtitleup to 160 charactersThe line under the title — a good place for “we usually reply within 10 minutes”.
Input placeholderup to 80 charactersThe grey hint in the message field.
Greetingup to 200 charactersShown in an empty chat, before the visitor has written anything.

Texts per language and fallback

Every text can be filled in for each language of your site. The widget picks the text for the visitor's language like this:

  1. an exact language match;
  2. otherwise a match on the first two letters;
  3. otherwise the first entry you filled in.

If a text is empty, the widget uses its own built-in text. So you can leave everything blank and still get a perfectly decent chat — the settings are for when you want it to sound like you.

Auto-reply

Turn on the auto-reply and write a text per language (up to 1000 characters). It answers the first message of a new conversation, so the visitor knows they have been heard even when the whole team is in a meeting. A conversation counts as new after N minutes of silence — you choose N from 1 to 180, the default is 5.

When nobody is online

A live chat with nobody on the other end is a trap: the visitor types a question, waits, closes the tab — and you never find out who that was. Offline mode turns the chat into a “leave your email” form at exactly those moments, so the question doesn't vanish along with the visitor.

Turn it on in CRM → Support → Settings → “When nobody is online”. The chat switches to offline mode when:

  • no operator has the CRM open for this site. Any CRM page counts, not only the chats — the operator is “online” until the last CRM tab is closed;
  • it's outside working hours, if you set them: working days, from / to and a time zone. The schedule is optional — without it only the first rule applies. Outside the hours the chat is offline even if someone happens to have the CRM open.

What changes in offline mode:

  1. The widget says “We're offline — leave your email and we'll reply” and asks for an email before the message. No email — no message: otherwise the answer would have nowhere to go.
  2. In the CRM the chat gets an Offline request mark, with the visitor's email right next to it.
  3. The Telegram notification (if you connected the bot) goes out immediately — such a chat is waiting by definition.

While operators are online, the chat works as usual, and the email is optional: visitors can still click “Get replies by email” — handy for someone who is about to close the tab.

Replies by email

If the visitor left an email and hasn't read an operator's reply in the widget within about 2 minutes, the reply is emailed to them. Several replies in a row arrive as one email, not as a stream of notifications. The email contains only the operator's replies — not the whole conversation — and a link back to the page of your site where the visitor wrote, so they can continue right there.

Widget language

The widget speaks the language of your page: it reads <html lang> and takes the first two letters, so en-GB and en both mean English.

  • Built-in interface texts exist for ru, en, uk, es, de and zh. Any other language gets English.
  • Your own texts from the CRM follow the fallback rules above.
  • Single-page app with a language switcher? Just update document.documentElement.lang — the widget notices the change and redraws its texts on the fly, no reload needed.
SPA language switchjs
function setLanguage(lang) {
  // ...switch your own translations...
  document.documentElement.lang = lang; // the chat follows along
}

Identify signed-in users

By default an operator sees “Visitor”. If your site has accounts, you can do better: put the user's name and your internal id into localStorage, and the operator sees who they are talking to.

support_client_namestring, ≤ 80 charsoptional
Shown to the operator instead of “Visitor”. Chats can be searched by it in the CRM.
support_client_idstring, ≤ 64 charsoptional
Your internal user id (e.g. user_8421). Shown in the visitor card, so operators can find the account in your own admin panel.

The widget only reads these keys. It sends them when it connects and again before each message if they have changed — so it doesn't matter whether you set them before the page loads or right after the user signs in. On sign-out, remove both keys: otherwise the next person on the same computer inherits the name.

// 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 {
    // Storage is blocked (strict privacy settings) — the chat still works, just anonymously
  }
}

// After a successful sign-in
setSupportIdentity({ id: 'user_8421', name: 'Anna Schmidt' });

// On sign-out
setSupportIdentity(null);

Attachments and limits

Visitors can attach files to their messages — a screenshot of the error is worth a thousand words.

WhatLimit
Imagesjpeg, png, gif, webp, avif
Documentspdf, doc, docx, xls, xlsx, txt, csv
File sizeup to 10 MB
Messages20 per minute per chat
Connectionsup to 12 at the same time from one IP
New chats10 per IP per hour

The limits are generous for humans and annoying for bots, which is exactly the point. An office full of people behind one IP with the site open in a dozen tabs is the one case where you might notice the connection limit.

Open the chat from your own button

The round button in the corner is fine for most sites, but sometimes the chat belongs in your own design: a “Chat with us” link in the header, a button on the pricing page, a help icon in the app. Two ways to do it — one without a single line of JavaScript.

Without JavaScript: data-crm-chat

Put data-crm-chat on any element, and a click opens the chat:

Any pagehtml
<a href="/contacts" data-crm-chat="open">Chat with us</a>

<button type="button" data-crm-chat="toggle">💬 Support</button>
  • open (also the default for an empty value), close or toggle.
  • Elements added later — modals, client-side routes — work too: the widget listens on the whole document.
  • Built-in fallback. If the chat isn't available on the site (or widget.js is blocked), the click is not intercepted and the link simply goes to its href. Point it to your contacts page and nobody hits a dead button.

From JavaScript: window.crmChat

crmChat.open()functionoptional
Opens the chat panel.
crmChat.close()functionoptional
Closes it.
crmChat.toggle()functionoptional
Opens if closed, closes if open.
crmChat.isOpen()() => booleanoptional
Whether the panel is open right now.
crmChat.availableboolean | nulloptional
true — the chat works on this site; false — it doesn't (plan, settings); null — we don't know yet, the widget is still asking.
crmChat.unreadnumberoptional
Operator replies the visitor hasn’t seen yet. Always 0 while the panel is open.

widget.js creates window.crmChat immediately, before the chat itself has loaded. Calls made in that first second are remembered and run as soon as the chat is ready, so a click on your button is never lost. Inside the CRM's Live mode the chat doesn't load at all, and crmChat quietly does nothing — your code won't break there either.

document.querySelector('#help').addEventListener('click', () => {
  // ?. — in case an ad blocker kept widget.js from loading
  window.crmChat?.open();
});

Hide the built-in button

Got your own button? Hide the widget's round one with data-chat-button="hidden" on the same script tag. The panel still opens and closes as usual — it has its own × in the header.

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

Show unread replies on your button

Without the round button, the visitor needs another way to notice that the operator answered. The widget fires a crm:chat event on window whenever something changes, with { available, open, unread } in detail. Use it for a badge — and to hide your button when the chat isn't available.

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;
}

// Usage
export function ChatButton() {
  const { available, unread } = useCrmChat();
  if (available === false) return null;

  return (
    <button type="button" onClick={() => window.crmChat?.open()}>
      Support {unread > 0 && <span className="badge">{unread}</span>}
    </button>
  );
}

Content Security Policy for the chat

No CSP on your site? Skip this section. If you do have one, the chat needs these sources:

Add to your existing 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     <the host your CRM files are served from>
  • script-src — to load widget.js and the chat bundle.
  • connect-src — for the settings request and the WebSocket. Note the wss:// entry: https:// alone does not cover WebSockets.
  • style-src — the chat window lives in a shadow DOM with an inline <style>, so a strict policy may need 'unsafe-inline' (e.g. style-src 'self' 'unsafe-inline').
  • img-src — attachments and a custom icon are served from your site's file storage. Open any image from the CRM and allow its host.

Advanced: build your own chat UI

A custom client is three steps, plus a fourth when nobody is online:

  1. Read the settings

    Is the chat on for this site? What do the texts say? Is anyone online?
  2. Start a chat

    Get a chat token over HTTP and keep it.
  3. Leave an email — in offline mode

    Nobody can answer now? Save the visitor's email before their first message.
  4. Talk over the WebSocket

    Connect with the token, send and receive frames.

All support endpoints are public: there is no API key. The site is recognized by the browser's Origin header (with Referer as a fallback), so call them from pages on your real domain. A domain that is not a site in the CRM gets 400 with {"message":"unknown_domain"}; a request without any origin gets {"message":"unknown_origin"}.

1. Read the chat settings

GET https://back.sitecog.com/support/config?lang=en
langquerystringoptional
Language for the texts, e.g. en. The same fallback rules apply.
{
  "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
Whether the chat is available for this site. false — show nothing and stop here.
lookobject
What the operator chose in CRM → Support → Settings. Only present when enabled is true.
look →
iconstring
bubble, headset, question, envelope or spark.
iconUrlstring | null
URL of a custom icon image; null when a built-in icon is used.
positionstring
bottom-right, bottom-left, top-right or top-left.
skinstring
indigo, emerald, midnight or graphite. In your own UI, map it to your colours — or ignore it.
textsobject
Texts for the requested language. null means “not filled in, use your own default”.
texts →
titlestring | null
Chat window title, ≤ 80 characters.
subtitlestring | null
Line under the title, ≤ 160 characters.
placeholderstring | null
Input placeholder, ≤ 80 characters.
greetingstring | null
Greeting for an empty chat, ≤ 200 characters.
offlineobject
Whether anyone can answer right now — see When nobody is online. Only present when enabled is true. Presence changes by the minute, so ask again when the visitor opens your chat window rather than caching it for long.
offline →
formboolean
Offline mode is turned on in the CRM. false — never ask for an email, just chat.
awayboolean
true — nobody can answer now: show the email form and ask for the address before the first message (step 3). Always false when form is false.
reasonstring | null
Why the chat is offline: after_hours — outside working hours, no_agents — no operator has the CRM open. null when away is false.

2. Start or resume a chat

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

{
  "visitorId": "5f1c2a9e-3b7d-4c1e-9a0f-8d2b6e4c7a10",
  "lang": "en",
  "token": "<the token you got last time, if any>"
}
visitorIdstringrequired
A stable id of this browser: 6–128 characters from A–Z a–z 0–9 _ . : -. If widget.js runs on the page, reuse its crm_vid from localStorage; otherwise generate your own once (a UUID fits) and keep it. With consent mode on and no consent yet there is no crm_vid — generate a temporary id and keep it in memory only, without storing it.
langstringoptional
Visitor's language, e.g. en.
tokenstringoptional
The chat token from a previous start. Valid token — you get the same chat back with its history. No token or an invalid one — a new chat is created (and new chats are limited to 10 per IP per hour, so don't lose the token).
Responsejson
{
  "enabled": true,
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
  "chatId": 123,
  "lastSeq": 5,
  "readSeq": 5,
  "status": "open",
  "messages": [
    { "seq": 1, "author": "visitor", "text": "Hi! Do you ship to Austria?", "at": "2026-10-01T09:12:03.000Z" },
    { "seq": 2, "author": "agent", "text": "Hi Anna! Yes, 2–4 days.", "at": "2026-10-01T09:13:40.000Z" }
  ],
  "email": null,
  "offline": { "form": true, "away": false, "reason": null }
}
enabledboolean
false — the chat is off for this site; the response has nothing else.
tokenstring (JWT)
The chat token, valid for 180 days. Store it (e.g. in localStorage) and always overwrite it with the latest one. It opens the WebSocket and resumes this chat next time.
chatIdnumber
Id of the chat.
lastSeqnumber
Sequence number of the latest message. Messages are numbered 1, 2, 3… within a chat; seq is your cursor for everything below.
readSeqnumber
How far the visitor has read.
statusstring
open or closed, as set by operators in the CRM.
messagesMessage[]
The last 50 messages, oldest first. The Message shape is the same as in WebSocket frames.
emailstring | null
The email the visitor already left in this chat. Don't ask for it again — show “We'll reply to …” with a way to change it.
offlineobject
Same shape as offline in the settings: { form, away, reason }, fresh at the moment of the request.
errorstring | null
Present instead of a token when the chat could not be started: invalid_visitor (bad visitorId) or rate_limit_exceeded (too many new chats from this IP).

3. Leave an email

When offline.away is true, save the visitor's email before sending their first message — that is what the built-in widget does, and it is the only way the reply reaches someone who has already closed the tab. While operators are online the same call powers an optional “Get replies by email” button.

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

{
  "token": "<chat token from chat/start>",
  "email": "anna@example.com",
  "page": "https://shop.example/delivery?utm_source=newsletter"
}
tokenstringrequired
The chat token from chat/start. An email can only be attached to your own chat.
emailstringrequired
Where to send the replies. Calling it again with another address replaces the old one.
pagestringoptional
URL of the page the visitor is writing from. It becomes the “continue on the site” link in the email. Only pages of your own site are accepted, and the query string and #fragment are dropped — the example above is stored as https://shop.example/delivery.
{ "ok": true, "email": "anna@example.com" }
errorWhat happened
invalid_emailThat doesn't look like an email address. Ask the visitor to check it.
invalid_tokenThe token is missing, expired or from another site. Call chat/start again.
rate_limit_exceededShares the 20-per-minute limit with the chat's messages. Wait a bit and retry.
disabledThe chat has been turned off for the site.
chat_not_foundThe chat was deleted. Drop the token and start a new one.
Offline mode in a custom clientjs
// chat — the chat/start response (or a fresher ready frame)
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); // keep the message, let them fix the address
    chat.email = res.email;
  }
  send(text); // the WebSocket "send" frame from step 4
}

4. Connect to the WebSocket

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

The token goes into the subprotocol list, not into the URL — URLs end up in logs, subprotocols don't. Pass two subprotocols: the protocol version and token. + your chat token. The browser sends Origin by itself, and it must be your site.

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

socket.onopen = () => {
  // Say hello and tell the server the last message you already have
  socket.send(JSON.stringify({ t: 'hello', since: lastSeq }));
};

After hello the server answers with a ready frame that contains everything you missed since since. Every frame is a JSON object with a type in t, at most 8 KB. The server pings every 25 seconds; browsers answer pings automatically, you don't need to do anything.

Frames you send

hello{ t, since }
Start of the session. since — the last seq you have (0 if none). Answered with ready.
send{ t, id, text, file? }
Send a message. id — your own message id, [A-Za-z0-9_-], 1–64 characters; resending the same id never creates a duplicate, so retry freely. text — up to 4000 characters. file — a receipt from an upload (see attachments).
read{ t, seq }
“I have read everything up to seq”. Drives the read marks the operator sees.
typing{ t }
The visitor is typing. Send it at most once every ~2 seconds while they type.
history{ t, before }
Load older messages: 50 per page before seq = before (0 = from the newest). Answered with history.
profile{ t, name?, contact?, clientId? }
Tell the operator who this is: name ≤ 80, contact (phone or email) ≤ 120, clientId ≤ 64. At least one must be non-empty.
Examplesjson
{ "t": "hello", "since": 5 }
{ "t": "send", "id": "m-1727775123-1", "text": "Do you ship to Austria?" }
{ "t": "read", "seq": 7 }
{ "t": "typing" }
{ "t": "history", "before": 51 }
{ "t": "profile", "name": "Anna Schmidt", "contact": "anna@example.com", "clientId": "user_8421" }

Frames you receive

readyframe
Answer to hello: the state of the chat plus what you missed.
ready →
chatIdnumber
Id of the chat.
visitorOnlineboolean
Whether the visitor is online in this chat.
lastSeqnumber
Latest message number.
statusstring
open or closed.
read{ agent, visitor }
How far each side has read (seq).
unreadnumber
Messages the visitor has not read yet — handy for a badge.
messagesMessage[]
Messages after since.
gapboolean
true if you missed more than fits into one catch-up — load the rest with history.
emailstring | null
The email the visitor left in this chat, as in chat/start.
offline{ form, away, reason }
Whether anyone can answer right now — same shape as in the settings. Comes with every reconnect, so a long-open page notices when the operators leave or come back.
messageframe
A new message in the chat — an operator reply, an auto-reply and so on.
message →
chatIdnumber
Id of the chat.
mMessage
The message itself.
m →
seqnumber
Sequence number inside the chat. Use it to sort and to de-duplicate.
authorstring
visitor, agent or system.
textstring
Plain text. Render it as text (textContent), never as HTML.
atstring (ISO 8601)
When the message was stored.
file{ url, name, mime, size, kind } | null
Only present for attachments. kind is image or doc.
ack{ id, seq, at }
Your send with this id was stored as message seq. Turn the “sending…” state into “sent”.
read{ chatId, by, seq }
Someone (by) has read up to seq — e.g. the operator read your messages.
typing{ chatId, by }
The other side is typing. Show the dots for a few seconds.
history{ chatId, before, messages, done }
Answer to your history request. done: true — there is nothing older.
presenceframe
Someone in the chat came online or went offline.
chat_goneframe
The chat was deleted in the CRM. Drop the stored token and call chat/start again for a fresh chat.
error{ code, id? }
Something went wrong with a frame; id is set when it is about one of your messages. Codes are listed below.
A message from the operatorjson
{
  "t": "message",
  "chatId": 123,
  "m": { "seq": 6, "author": "agent", "text": "Your parcel left the warehouse today 🚚", "at": "2026-10-01T09:20:11.000Z" }
}

A complete minimal client

Start → connect → send → receive, with reconnects. About 90 lines, no dependencies — plug your own render and markSent and you have a working chat.

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 {} },
};

// Reuse the widget's visitor id if it is there, otherwise keep our own
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; // the same message can come from start, ready and message
  seen.add(m.seq);
  lastSeq = Math.max(lastSeq, m.seq);
  render(m); // your UI: m.author, m.text (as 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;            // chat is off for this site
  if (data.error) throw new Error(data.error);

  store.set('my_chat_token', data.token); // valid 180 days
  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();                           // a brand new chat
    } else if (frame.t === 'error') console.warn('[chat]', frame.code, frame.id);
  };

  socket.onclose = (event) => {
    if (event.code === 4402 || event.code === 4403) return; // turned off / forbidden: stop
    failures += 1;
    const delay = Math.min(30000, 1000 * 2 ** failures) + Math.random() * 1000;
    // A few failures in a row may mean the token is no longer valid — start over
    setTimeout(() => (failures > 3 ? boot() : connect(token)), delay);
  };
}

function send(text) {
  const id = crypto.randomUUID();       // safe to resend: same id = same message
  socket.send(JSON.stringify({ t: 'send', id, text }));
  return id;                            // show as "sending…" until the ack with this id
}

boot();

Reconnects and missed messages

  • Keep the highest seq you have shown. After a reconnect, send { "t": "hello", "since": lastSeq } — the ready frame brings exactly what you missed.
  • If ready.gap is true, you missed a lot; fill the hole with history requests.
  • Messages can arrive twice (from chat/start and from ready, for example). De-duplicate by seq.
  • Back off between attempts (1 s, 2 s, 4 s… with a bit of randomness) — a server restart shouldn't turn into a stampede of reconnects.
  • Unsure whether a message reached the server? Send it again with the same id. It is stored once.

Attachments in a custom UI

A file goes in three short hops: get a permit, upload the file, send the receipt.

// 1. A permit to upload — valid for 5 minutes
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. Upload the file (images or documents, up to 10 MB)
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. Send a message with the receipt (text may be empty)
socket.send(JSON.stringify({ t: 'send', id: crypto.randomUUID(), text: '', file: upload.receipt }));

Errors and close codes

Errors on a live connection come as { "t": "error", "code": "…", "id": "…" }:

CodeWhat happened
rate_limit_exceededMore than 20 messages a minute in this chat. Wait and resend with the same id.
invalid_textThe text is missing or longer than 4000 characters.
invalid_fileThe upload receipt could not be verified — expired, or from another chat. Upload again.
invalid_message_idThe id is not 1–64 characters of A–Z a–z 0–9 _ -.
frame_too_largeThe frame is bigger than 8 KB.
invalid_jsonThe frame is not valid JSON.
unknown_frameUnknown t. Typo, or a frame from a newer protocol.
empty_profileA profile frame with all fields empty.
chat_not_foundThe chat no longer exists. Start a new one.
disabledThe chat has been turned off for the site.

The connection itself can be refused or closed:

WhenCodeMeaning
Handshake401 unauthorizedThe token is missing, invalid or expired. Call chat/start again.
Handshake429 too_many_connectionsMore than 12 connections from this IP. Close the extra ones.
Close4402The chat was turned off for the site. Don't reconnect.
Close4403Forbidden. Don't reconnect.

Troubleshooting

The chat button doesn't appear

  • The plan doesn't include the chat, or the chat is turned off. The settings endpoint answers { "enabled": false }.
  • Unknown domain. The page is opened on a domain that is not a site in the CRM — a staging host, localhost, a new domain you haven't added yet. (www. doesn't matter, it is stripped.)
  • CSP blocks it. Look for “Refused to load” or “Refused to connect” in the browser console and check the CSP section.
  • You are looking at the site inside the CRM. In Live mode the widget loads only the editor — no chat, no analytics. Open the site in a normal tab. (The editor itself loads only in the CRM's Live frame: if another site embeds yours in its own iframe, no editor is loaded there.)
  • widget.js is missing on this page — easy to miss when a site has several layouts.
  • The chat was just enabled, and your browser still remembers the old settings for up to ~10 minutes (see below).

You can ask the server directly what it thinks about your domain:

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

Changes from the CRM are not visible

  • The settings are cached in the browser for about 10 minutes. Either wait, or delete the crm_chat_cfg key in DevTools → Application → Local Storage and reload.
  • You edited the texts for one language but the page has another <html lang>. Check the fallback rules.
  • A text field is empty, so the widget shows its built-in text — that is by design.