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
- A visitor clicks the chat button in the corner of your site and writes a message (with a screenshot, if words fail them).
- The message shows up instantly in CRM → Support → Chats. Everything runs over a WebSocket, so nobody has to press F5.
- 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.
The chat and consent mode
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:
<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.
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.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'sOrigin;www.is ignored). The domain must be a site you added in the CRM.Add widget.js to the page
The tag above, or the Next.js / Vite variants from Widget & markup → Step 1.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.
| Option | Allowed values | Notes |
|---|---|---|
| Icon | bubble, headset, question, envelope, spark or your own image | A custom icon is picked from your site's file storage. |
| Position | bottom-right default, bottom-left, top-right, top-left | Pick the corner your cookie banner is not sitting in. |
| Colour skin | indigo default, emerald, midnight, graphite | Ready-made palettes for the button and the chat window. |
| Title | up to 80 characters | The header of the chat window. |
| Subtitle | up to 160 characters | The line under the title — a good place for “we usually reply within 10 minutes”. |
| Input placeholder | up to 80 characters | The grey hint in the message field. |
| Greeting | up to 200 characters | Shown 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:
- an exact language match;
- otherwise a match on the first two letters;
- 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:
- 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.
- In the CRM the chat gets an Offline request mark, with the visitor's email right next to it.
- 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,deandzh. 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.
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 charsoptionalsupport_client_idstring, ≤ 64 charsoptionaluser_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);import { useEffect } from 'react';
type User = { id: string; name: string };
// user: undefined = still loading, null = signed out, object = signed in
export function useSupportIdentity(user: User | null | undefined) {
useEffect(() => {
if (user === undefined) return; // don't wipe the keys while auth is still loading
try {
if (user) {
localStorage.setItem('support_client_name', user.name.slice(0, 80));
localStorage.setItem('support_client_id', user.id.slice(0, 64));
} else {
localStorage.removeItem('support_client_name');
localStorage.removeItem('support_client_id');
}
} catch {
// Storage is blocked — the chat stays anonymous, nothing breaks
}
}, [user]);
}
// In your app shell:
// const { user } = useAuth();
// useSupportIdentity(user);// app/support-identity.tsx
'use client';
import { useEffect } from 'react';
export function SupportIdentity({ id, name }: { id?: string; name?: string }) {
useEffect(() => {
try {
if (id && name) {
localStorage.setItem('support_client_name', name.slice(0, 80));
localStorage.setItem('support_client_id', id.slice(0, 64));
} else {
localStorage.removeItem('support_client_name');
localStorage.removeItem('support_client_id');
}
} catch {}
}, [id, name]);
return null; // renders nothing, it only syncs the keys
}
// app/layout.tsx — a Server Component knows the session for sure
// const user = await getCurrentUser(); // your auth
// ...
// <body>
// {children}
// <SupportIdentity id={user?.id} name={user?.name} />
// <Script src="https://widget.sitecog.com/widget.js" strategy="afterInteractive" />
// </body>Attachments and limits
Visitors can attach files to their messages — a screenshot of the error is worth a thousand words.
| What | Limit |
|---|---|
| Images | jpeg, png, gif, webp, avif |
| Documents | pdf, doc, docx, xls, xlsx, txt, csv |
| File size | up to 10 MB |
| Messages | 20 per minute per chat |
| Connections | up to 12 at the same time from one IP |
| New chats | 10 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:
<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),closeortoggle.- 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.jsis blocked), the click is not intercepted and the link simply goes to itshref. Point it to your contacts page and nobody hits a dead button.
From JavaScript: window.crmChat
crmChat.open()functionoptionalcrmChat.close()functionoptionalcrmChat.toggle()functionoptionalcrmChat.isOpen()() => booleanoptionalcrmChat.availableboolean | nulloptionaltrue — the chat works on this site; false — it doesn't (plan, settings); null — we don't know yet, the widget is still asking.crmChat.unreadnumberoptionalwidget.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();
});export function HelpButton() {
return (
<button type="button" onClick={() => window.crmChat?.open()}>
Need help?
</button>
);
}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>import Script from 'next/script';
<Script src="https://widget.sitecog.com/widget.js" data-chat-button="hidden" strategy="afterInteractive" />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.
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:
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 loadwidget.jsand the chat bundle.connect-src— for the settings request and the WebSocket. Note thewss://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:
Read the settings
Is the chat on for this site? What do the texts say? Is anyone online?Start a chat
Get a chat token over HTTP and keep it.Leave an email — in offline mode
Nobody can answer now? Save the visitor's email before their first message.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=enlangquerystringoptionalen. 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"
}
}{ "enabled": false }enabledbooleanfalse — show nothing and stop here.lookobjectlook →
iconstringbubble, headset, question, envelope or spark.iconUrlstring | nullpositionstringbottom-right, bottom-left, top-right or top-left.skinstringindigo, emerald, midnight or graphite. In your own UI, map it to your colours — or ignore it.textsobjectnull means “not filled in, use your own default”.texts →
titlestring | nullsubtitlestring | nullplaceholderstring | nullgreetingstring | nullofflineobjectenabled is true. Presence changes by the minute, so ask again when the visitor opens your chat window rather than caching it for long.offline →
formbooleanfalse — never ask for an email, just chat.awaybooleantrue — 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 | nullafter_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>"
}visitorIdstringrequiredA–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.langstringoptionalen.tokenstringoptional{
"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 }
}enabledbooleanfalse — the chat is off for this site; the response has nothing else.tokenstring (JWT)localStorage) and always overwrite it with the latest one. It opens the WebSocket and resumes this chat next time.chatIdnumberlastSeqnumberseq is your cursor for everything below.readSeqnumberstatusstringopen or closed, as set by operators in the CRM.messagesMessage[]emailstring | nullofflineobjectoffline in the settings: { form, away, reason }, fresh at the moment of the request.errorstring | nullinvalid_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"
}tokenstringrequiredchat/start. An email can only be attached to your own chat.emailstringrequiredpagestringoptional#fragment are dropped — the example above is stored as https://shop.example/delivery.{ "ok": true, "email": "anna@example.com" }{ "ok": false, "error": "invalid_email" }error | What happened |
|---|---|
invalid_email | That doesn't look like an email address. Ask the visitor to check it. |
invalid_token | The token is missing, expired or from another site. Call chat/start again. |
rate_limit_exceeded | Shares the 20-per-minute limit with the chat's messages. Wait a bit and retry. |
disabled | The chat has been turned off for the site. |
chat_not_found | The chat was deleted. Drop the token and start a new one. |
// 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/wsThe 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 }since — the last seq you have (0 if none). Answered with ready.send{ t, id, text, file? }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 }seq”. Drives the read marks the operator sees.typing{ t }history{ t, before }seq = before (0 = from the newest). Answered with history.profile{ t, name?, contact?, clientId? }name ≤ 80, contact (phone or email) ≤ 120, clientId ≤ 64. At least one must be non-empty.{ "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
readyframehello: the state of the chat plus what you missed.ready →
chatIdnumbervisitorOnlinebooleanlastSeqnumberstatusstringopen or closed.read{ agent, visitor }unreadnumbermessagesMessage[]since.gapbooleantrue if you missed more than fits into one catch-up — load the rest with history.emailstring | nullchat/start.offline{ form, away, reason }messageframemessage →
chatIdnumbermMessagem →
seqnumberauthorstringvisitor, agent or system.textstringtextContent), never as HTML.atstring (ISO 8601)file{ url, name, mime, size, kind } | nullkind is image or doc.ack{ id, seq, at }send with this id was stored as message seq. Turn the “sending…” state into “sent”.read{ chatId, by, seq }by) has read up to seq — e.g. the operator read your messages.typing{ chatId, by }history{ chatId, before, messages, done }history request. done: true — there is nothing older.presenceframechat_goneframechat/start again for a fresh chat.error{ code, id? }id is set when it is about one of your messages. Codes are listed below.{
"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.
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
seqyou have shown. After a reconnect, send{ "t": "hello", "since": lastSeq }— thereadyframe brings exactly what you missed. - If
ready.gapistrue, you missed a lot; fill the hole withhistoryrequests. - Messages can arrive twice (from
chat/startand fromready, for example). De-duplicate byseq. - 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": "…" }:
| Code | What happened |
|---|---|
rate_limit_exceeded | More than 20 messages a minute in this chat. Wait and resend with the same id. |
invalid_text | The text is missing or longer than 4000 characters. |
invalid_file | The upload receipt could not be verified — expired, or from another chat. Upload again. |
invalid_message_id | The id is not 1–64 characters of A–Z a–z 0–9 _ -. |
frame_too_large | The frame is bigger than 8 KB. |
invalid_json | The frame is not valid JSON. |
unknown_frame | Unknown t. Typo, or a frame from a newer protocol. |
empty_profile | A profile frame with all fields empty. |
chat_not_found | The chat no longer exists. Start a new one. |
disabled | The chat has been turned off for the site. |
The connection itself can be refused or closed:
| When | Code | Meaning |
|---|---|---|
| Handshake | 401 unauthorized | The token is missing, invalid or expired. Call chat/start again. |
| Handshake | 429 too_many_connections | More than 12 connections from this IP. Close the extra ones. |
| Close | 4402 | The chat was turned off for the site. Don't reconnect. |
| Close | 4403 | Forbidden. 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.jsis 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_cfgkey 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.