Ein Besucher hat um 23 Uhr eine Frage, und dein Kontaktformular fühlt sich an wie eine Flaschenpost. Der Support-Chat löst das: ein Chat-Button auf jeder Seite deiner Website, und die Unterhaltung landet im CRM, wo dein Team in Echtzeit antwortet. Hast du widget.js schon fürs Live-Editing eingebunden, ist der Chat buchstäblich null Zeilen Code entfernt.
So funktioniert der Support-Chat
- Ein Besucher klickt auf den Chat-Button in der Ecke deiner Site und schreibt eine Nachricht (mit Screenshot, wenn ihm die Worte fehlen).
- Die Nachricht erscheint sofort unter CRM → Support → Chats. Alles läuft über einen WebSocket, niemand muss F5 drücken.
- Ein Operator antwortet aus dem CRM, und der Besucher sieht die Antwort sofort — inklusive Tipp-Anzeige und Lesebestätigungen.
Damit niemand beim Kaffeekochen einen Chat verpasst, bekommen Operatoren einen Ungelesen-Zähler im CRM, Browser-Benachrichtigungen und auf Wunsch einen Telegram-Bot mit Ruhezeiten. All das stellst du im CRM ein, nicht in deinem Code.
Chat und Consent-Modus
Läuft auf deiner Site der Consent-Modus, funktioniert der Chat trotzdem schon vor der Einwilligung — wer eine Frage hat, muss dafür nicht erst den Cookie-Banner abnicken. Bis zur Zustimmung nutzt das Widget eine einmalige Identität, die nur im Arbeitsspeicher lebt und nirgends gespeichert wird. Eine ältere Unterhaltung kommt trotzdem zurück — über ihren eigenen crm_chat_token.
Nur Attribution gibt es vor der Einwilligung nicht: Ohne Besucher- und Session-ID ist der Chat mit keinem Kanal, keinen UTM-Tags und keinem Werbelink verknüpft.
Live-Chat zu deiner Website hinzufügen
Chat-spezifischen Code gibt es nicht. Derselbe einzelne Tag, der alles andere antreibt, erledigt den Job:
<script src="https://widget.sitecog.com/widget.js" defer></script>Ist der Chat für deine Site verfügbar, lädt widget.js das Chat-Bundle (widget.support.js) von selbst nach, und ein schwebender Chat-Button erscheint. Ist er es nicht, laden Besucher nichts zusätzlich herunter. Den Tag doppelt einzubinden schadet nicht — die zweite Kopie wird ignoriert.
Prüfen, ob dein Tarif den Support-Chat enthält
Der Chat ist in Tarifen verfügbar, die ihn enthalten. Kein Chat im Tarif — kein Button, egal was der Code sagt.Prüfen, ob deine Domain eine Site im CRM ist
Der Chat erkennt deine Site an der Domain, auf der die Seite geöffnet wird (demOrigindes Browsers;www.wird ignoriert). Die Domain muss eine Site sein, die du im CRM angelegt hast.widget.js auf der Seite einbinden
Der Tag oben oder die Next.js- / Vite-Varianten aus Widget & Markup → Schritt 1.Im CRM gestalten
Symbol, Farben, Position und Texte findest du unter CRM → Support → Einstellungen — siehe unten.
Aussehen und Texte im CRM
Alles Visuelle wird unter CRM → Support → Einstellungen konfiguriert. Keine CSS-Overrides, keine Redeploys: Dort ändern, und das Widget übernimmt es.
| Option | Erlaubte Werte | Hinweise |
|---|---|---|
| Symbol | bubble, headset, question, envelope, spark oder dein eigenes Bild | Ein eigenes Symbol wählst du aus dem Dateispeicher deiner Site. |
| Position | bottom-right Standard, bottom-left, top-right, top-left | Nimm die Ecke, in der nicht schon dein Cookie-Banner sitzt. |
| Farbschema | indigo Standard, emerald, midnight, graphite | Fertige Paletten für Button und Chatfenster. |
| Überschrift | bis 80 Zeichen | Der Kopf des Chatfensters. |
| Zeile unter der Überschrift | bis 160 Zeichen | Die Zeile unter der Überschrift — ein guter Platz für „Wir antworten meist innerhalb von 10 Minuten“. |
| Platzhalter im Eingabefeld | bis 80 Zeichen | Der graue Hinweis im Nachrichtenfeld. |
| Begrüßung im leeren Chat | bis 200 Zeichen | Wird in einem leeren Chat angezeigt, bevor der Besucher etwas geschrieben hat. |
Texte pro Sprache und Fallback
Jeder Text lässt sich für jede Sprache deiner Site ausfüllen. Das Widget wählt den Text für die Sprache des Besuchers so:
- exakter Sprachtreffer;
- sonst ein Treffer auf den ersten zwei Buchstaben;
- sonst der erste Eintrag, den du ausgefüllt hast.
Ist ein Text leer, nimmt das Widget seinen eigenen eingebauten Text. Du kannst also alles leer lassen und bekommst trotzdem einen völlig ordentlichen Chat — die Einstellungen sind dafür da, dass er nach dir klingt.
Automatische Antwort
Schalte die automatische Antwort ein und schreib einen Text pro Sprache (bis 1000 Zeichen). Sie antwortet auf die erste Nachricht einer neuen Unterhaltung, damit der Besucher weiß, dass er gehört wurde, auch wenn das ganze Team im Meeting sitzt. Eine Unterhaltung gilt nach N Minuten Stille als neu — N wählst du zwischen 1 und 180, Standard ist 5.
Wenn niemand online ist
Ein Live-Chat, in dem niemand antwortet, ist eine Falle: Der Besucher stellt seine Frage, wartet, schließt den Tab – und du erfährst nie, wer das war. Der Offline-Modus macht aus dem Chat genau in solchen Momenten ein „Hinterlass deine E-Mail“-Formular, damit die Frage nicht zusammen mit dem Besucher verschwindet.
Du schaltest ihn unter CRM → Support → Einstellungen → „Wenn niemand online ist“ ein. Der Chat geht in den Offline-Modus, wenn:
- kein Operator das CRM für diese Site offen hat. Jede CRM-Seite zählt, nicht nur die Chats – ein Operator ist „online“, bis er den letzten CRM-Tab schließt;
- gerade keine Arbeitszeit ist, falls du eine festgelegt hast: Arbeitstage, von / bis und eine Zeitzone. Der Zeitplan ist optional – ohne ihn gilt nur die erste Regel. Außerhalb der Arbeitszeit ist der Chat offline, auch wenn zufällig jemand das CRM offen hat.
Was sich im Offline-Modus ändert:
- Das Widget zeigt „Wir sind gerade offline – hinterlass deine E-Mail, wir antworten“ und fragt vor der Nachricht nach der E-Mail. Ohne E-Mail keine Nachricht: Sonst wüsste die Antwort nicht, wohin.
- Im CRM bekommt der Chat die Markierung „Offline-Anfrage“, die E-Mail des Besuchers steht gleich daneben.
- Die Telegram-Benachrichtigung (falls du den Bot verbunden hast) geht sofort raus – so ein Chat wartet per Definition.
Solange Operatoren online sind, läuft der Chat wie gewohnt, und die E-Mail ist freiwillig: Besucher können trotzdem auf „Antworten per E-Mail erhalten“ klicken – praktisch für alle, die den Tab gleich schließen wollen.
Antworten per E-Mail
Hat der Besucher eine E-Mail hinterlassen und die Antwort des Operators nicht innerhalb von etwa 2 Minuten im Widget gelesen, bekommt er sie per E-Mail. Mehrere Antworten hintereinander kommen als eine E-Mail an, nicht als Flut von Benachrichtigungen. Die E-Mail enthält nur die Antworten des Operators – nicht das ganze Gespräch – und einen Link zurück auf die Seite deiner Website, von der aus der Besucher geschrieben hat, damit er direkt dort weitermachen kann.
Sprache des Widgets
Das Widget spricht die Sprache deiner Seite: Es liest <html lang> und nimmt die ersten zwei Buchstaben, de-AT und de bedeuten also beide Deutsch.
- Eingebaute Oberflächentexte gibt es für
ru,en,uk,es,deundzh. Jede andere Sprache bekommt Englisch. - Deine eigenen Texte aus dem CRM folgen den Fallback-Regeln oben.
- Single-Page-App mit Sprachumschalter? Aktualisier einfach
document.documentElement.lang— das Widget bemerkt die Änderung und zeichnet seine Texte im Flug neu, ohne Reload.
function setLanguage(lang) {
// ...eigene Übersetzungen umschalten...
document.documentElement.lang = lang; // der Chat zieht mit
}Angemeldete Nutzer erkennen
Standardmäßig sieht ein Operator „Besucher“. Hat deine Site Konten, geht das besser: Leg den Namen des Nutzers und deine interne ID in localStorage ab, und der Operator sieht, mit wem er spricht.
support_client_namestring, ≤ 80 charsoptionalsupport_client_idstring, ≤ 64 charsoptionaluser_8421). Erscheint in der Besucherkarte, damit Operatoren das Konto in deinem eigenen Admin-Panel finden.Das Widget liest diese Keys nur. Es sendet sie beim Verbinden und erneut vor jeder Nachricht, falls sie sich geändert haben — es ist also egal, ob du sie vor dem Laden der Seite setzt oder direkt nach dem Login. Beim Logout entfernst du beide Keys: Sonst erbt die nächste Person am selben Rechner den Namen.
// 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 {
// Speicher blockiert (strenge Privatsphäre-Einstellungen) — der Chat funktioniert trotzdem, nur anonym
}
}
// Nach erfolgreichem Login
setSupportIdentity({ id: 'user_8421', name: 'Anna Schmidt' });
// Beim Logout
setSupportIdentity(null);import { useEffect } from 'react';
type User = { id: string; name: string };
// user: undefined = lädt noch, null = abgemeldet, Objekt = angemeldet
export function useSupportIdentity(user: User | null | undefined) {
useEffect(() => {
if (user === undefined) return; // die Keys nicht löschen, solange die Auth noch lädt
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 {
// Speicher blockiert — der Chat bleibt anonym, nichts geht kaputt
}
}, [user]);
}
// In deiner 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; // rendert nichts, synchronisiert nur die Keys
}
// app/layout.tsx — eine Server Component kennt die Session sicher
// const user = await getCurrentUser(); // deine Auth
// ...
// <body>
// {children}
// <SupportIdentity id={user?.id} name={user?.name} />
// <Script src="https://widget.sitecog.com/widget.js" strategy="afterInteractive" />
// </body>Anhänge und Limits
Besucher können ihren Nachrichten Dateien anhängen — ein Screenshot vom Fehler sagt mehr als tausend Worte.
| Was | Limit |
|---|---|
| Bilder | jpeg, png, gif, webp, avif |
| Dokumente | pdf, doc, docx, xls, xlsx, txt, csv |
| Dateigröße | bis 10 MB |
| Nachrichten | 20 pro Minute pro Chat |
| Verbindungen | bis zu 12 gleichzeitig von einer IP |
| Neue Chats | 10 pro IP pro Stunde |
Die Limits sind großzügig für Menschen und lästig für Bots, und genau darum geht es. Ein Büro voller Leute hinter einer IP, das die Site in einem Dutzend Tabs offen hat, ist der eine Fall, in dem du das Verbindungslimit bemerken könntest.
Den Chat über deinen eigenen Button öffnen
Der runde Button in der Ecke passt für die meisten Sites, aber manchmal gehört der Chat in dein eigenes Design: ein „Chatte mit uns“-Link im Header, ein Button auf der Preisseite, ein Hilfe-Icon in der App. Dafür gibt es zwei Wege — einer davon kommt ganz ohne JavaScript aus.
Ohne JavaScript: data-crm-chat
Setz data-crm-chat auf ein beliebiges Element, und ein Klick öffnet den Chat:
<a href="/contacts" data-crm-chat="open">Chatte mit uns</a>
<button type="button" data-crm-chat="toggle">💬 Support</button>open(auch der Standard bei leerem Wert),closeodertoggle.- Später hinzugefügte Elemente — Modals, clientseitige Routen — funktionieren ebenfalls: Das Widget lauscht auf dem ganzen Dokument.
- Eingebauter Fallback. Ist der Chat auf der Site nicht verfügbar (oder
widget.jsblockiert), wird der Klick nicht abgefangen, und der Link führt einfach zu seinemhref. Lass ihn auf deine Kontaktseite zeigen, dann landet niemand bei einem toten Button.
Aus JavaScript: window.crmChat
crmChat.open()functionoptionalcrmChat.close()functionoptionalcrmChat.toggle()functionoptionalcrmChat.isOpen()() => booleanoptionalcrmChat.availableboolean | nulloptionaltrue — der Chat funktioniert auf dieser Site; false — tut er nicht (Tarif, Einstellungen); null — wissen wir noch nicht, das Widget fragt gerade nach.crmChat.unreadnumberoptionalwidget.js legt window.crmChat sofort an, noch bevor der Chat selbst geladen ist. Aufrufe aus dieser ersten Sekunde werden gemerkt und ausgeführt, sobald der Chat bereit ist — ein Klick auf deinen Button geht also nie verloren. Im Live-Modus des CRM lädt der Chat gar nicht, und crmChat tut einfach still nichts — dein Code bricht also auch dort nicht.
document.querySelector('#help').addEventListener('click', () => {
// ?. — falls ein Adblocker widget.js am Laden gehindert hat
window.crmChat?.open();
});export function HelpButton() {
return (
<button type="button" onClick={() => window.crmChat?.open()}>
Brauchst du Hilfe?
</button>
);
}Den eingebauten Button ausblenden
Du hast deinen eigenen Button? Blende den runden Button des Widgets mit data-chat-button="hidden" am selben Script-Tag aus. Das Panel öffnet und schließt sich weiterhin wie gewohnt — es hat sein eigenes × im 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" />Ungelesene Antworten an deinem Button anzeigen
Ohne den runden Button braucht der Besucher einen anderen Weg, um zu merken, dass der Operator geantwortet hat. Das Widget feuert bei jeder Änderung ein crm:chat-Event auf window, mit { available, open, unread } in detail. Nutz es für ein Badge — und um deinen Button auszublenden, wenn der Chat nicht verfügbar ist.
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;
}
// Verwendung
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 für den Chat
Keine CSP auf deiner Site? Überspring diesen Abschnitt. Hast du eine, braucht der Chat diese Quellen:
script-src https://widget.sitecog.com
connect-src https://back.sitecog.com wss://back.sitecog.com
style-src 'unsafe-inline'
img-src <der Host, von dem deine CRM-Dateien ausgeliefert werden>script-src— umwidget.jsund das Chat-Bundle zu laden.connect-src— für den Einstellungs-Request und den WebSocket. Achte auf denwss://-Eintrag:https://allein deckt WebSockets nicht ab.style-src— das Chatfenster lebt in einem Shadow DOM mit einem Inline-<style>, eine strenge Policy braucht also eventuell'unsafe-inline'(z. B.style-src 'self' 'unsafe-inline').img-src— Anhänge und ein eigenes Symbol kommen aus dem Dateispeicher deiner Site. Öffne ein beliebiges Bild aus dem CRM und erlaube dessen Host.
Für Fortgeschrittene: eigene Chat-UI bauen
Ein eigener Client sind drei Schritte, plus ein vierter, wenn niemand online ist:
Einstellungen lesen
Ist der Chat für diese Site an? Was steht in den Texten? Ist jemand online?Einen Chat starten
Hol dir per HTTP einen Chat-Token und bewahr ihn auf.E-Mail hinterlassen – im Offline-Modus
Gerade kann niemand antworten? Speicher die E-Mail des Besuchers vor seiner ersten Nachricht.Über den WebSocket reden
Mit dem Token verbinden, Frames senden und empfangen.
Alle Support-Endpoints sind öffentlich: Es gibt keinen API-Key. Die Site wird am Origin-Header des Browsers erkannt (mit Referer als Fallback), ruf sie also von Seiten auf deiner echten Domain auf. Eine Domain, die keine Site im CRM ist, bekommt 400 mit {"message":"unknown_domain"}; ein Request ganz ohne Origin bekommt {"message":"unknown_origin"}.
1. Chat-Einstellungen lesen
GET https://back.sitecog.com/support/config?lang=enlangquerystringoptionalen. Es gelten dieselben Fallback-Regeln.{
"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 — nichts anzeigen und hier aufhören.lookobjectlook →
iconstringbubble, headset, question, envelope oder spark.iconUrlstring | nullpositionstringbottom-right, bottom-left, top-right oder top-left.skinstringindigo, emerald, midnight oder graphite. In deiner eigenen UI auf deine Farben mappen — oder ignorieren.textsobjectnull heißt „nicht ausgefüllt, nimm deinen eigenen Standard“.texts →
titlestring | nullsubtitlestring | nullplaceholderstring | nullgreetingstring | nullofflineobjectenabled true ist. Anwesenheit ändert sich minütlich, also frag beim Öffnen deines Chatfensters neu, statt die Antwort lange zu cachen.offline →
formbooleanfalse – nie nach der E-Mail fragen, einfach chatten.awaybooleantrue – gerade kann niemand antworten: Zeig das E-Mail-Formular und frag vor der ersten Nachricht nach der Adresse (Schritt 3). Immer false, wenn form false ist.reasonstring | nullafter_hours – außerhalb der Arbeitszeit, no_agents – kein Operator hat das CRM offen. null, wenn away false ist.2. Einen Chat starten oder fortsetzen
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>"
}visitorIdstringPflichtA–Z a–z 0–9 _ . : -. Läuft widget.js auf der Seite, nimm dessen crm_vid aus localStorage; sonst erzeug einmal deine eigene (eine UUID passt) und bewahr sie auf. Ist der Consent-Modus an und hat der Besucher noch nicht zugestimmt, gibt es kein crm_vid — dann erzeug eine temporäre ID nur im Arbeitsspeicher, statt eine zu speichern.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 — der Chat ist für diese Site aus; sonst steht nichts in der Response.tokenstring (JWT)localStorage) und überschreib ihn immer mit dem neuesten. Er öffnet den WebSocket und setzt diesen Chat beim nächsten Mal fort.chatIdnumberlastSeqnumberseq ist dein Cursor für alles Weitere.readSeqnumberstatusstringopen oder closed, wie von den Operatoren im CRM gesetzt.messagesMessage[]emailstring | nullofflineobjectoffline in den Einstellungen: { form, away, reason }, Stand zum Zeitpunkt der Anfrage.errorstring | nullinvalid_visitor (fehlerhafte visitorId) oder rate_limit_exceeded (zu viele neue Chats von dieser IP).3. E-Mail hinterlassen
Ist offline.away true, speicher die E-Mail des Besuchers, bevor du seine erste Nachricht sendest – so macht es das eingebaute Widget, und nur so erreicht die Antwort jemanden, der den Tab längst geschlossen hat. Solange Operatoren online sind, steckt derselbe Aufruf hinter einem optionalen Button „Antworten per E-Mail erhalten“.
POST https://back.sitecog.com/support/chat/contact
Content-Type: application/json
{
"token": "<Chat-Token aus chat/start>",
"email": "anna@example.com",
"page": "https://shop.example/delivery?utm_source=newsletter"
}tokenstringPflichtchat/start. Eine E-Mail lässt sich nur an den eigenen Chat hängen.emailstringPflichtpagestringoptional#Fragment fallen weg – das Beispiel oben wird als https://shop.example/delivery gespeichert.{ "ok": true, "email": "anna@example.com" }{ "ok": false, "error": "invalid_email" }error | Was passiert ist |
|---|---|
invalid_email | Das sieht nicht nach einer E-Mail-Adresse aus. Bitte den Besucher, sie zu prüfen. |
invalid_token | Der Token fehlt, ist abgelaufen oder gehört zu einer anderen Site. Ruf chat/start erneut auf. |
rate_limit_exceeded | Teilt sich das Limit von 20 pro Minute mit den Nachrichten des Chats. Kurz warten und erneut versuchen. |
disabled | Der Chat wurde für die Site ausgeschaltet. |
chat_not_found | Der Chat wurde gelöscht. Verwirf den Token und starte einen neuen. |
// chat – die Response von chat/start (oder ein frischerer 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); // Nachricht behalten, Adresse korrigieren lassen
chat.email = res.email;
}
send(text); // der WebSocket-Frame send aus Schritt 4
}4. Mit dem WebSocket verbinden
wss://back.sitecog.com/support/wsDer Token kommt in die Subprotokoll-Liste, nicht in die URL — URLs landen in Logs, Subprotokolle nicht. Gib zwei Subprotokolle mit: die Protokollversion und token. + deinen Chat-Token. Den Origin schickt der Browser von selbst, und er muss deine Site sein.
const socket = new WebSocket('wss://back.sitecog.com/support/ws', [
'crm.support.v1',
'token.' + chatToken,
]);
socket.onopen = () => {
// Hallo sagen und dem Server die letzte Nachricht nennen, die du schon hast
socket.send(JSON.stringify({ t: 'hello', since: lastSeq }));
};Nach hello antwortet der Server mit einem ready-Frame, der alles enthält, was du seit since verpasst hast. Jeder Frame ist ein JSON-Objekt mit dem Typ in t, höchstens 8 KB groß. Der Server pingt alle 25 Sekunden; Browser beantworten Pings automatisch, du musst nichts tun.
Frames, die du sendest
hello{ t, since }since — die letzte seq, die du hast (0, wenn keine). Wird mit ready beantwortet.send{ t, id, text, file? }id — deine eigene Nachrichten-ID, [A-Za-z0-9_-], 1–64 Zeichen; dieselbe id erneut zu senden erzeugt nie ein Duplikat, also wiederhol ruhig. text — bis 4000 Zeichen. file — eine Quittung aus einem Upload (siehe Anhänge).read{ t, seq }seq gelesen“. Steuert die Lesebestätigungen, die der Operator sieht.typing{ t }history{ t, before }seq = before (0 = ab der neuesten). Wird mit history beantwortet.profile{ t, name?, contact?, clientId? }name ≤ 80, contact (Telefon oder E-Mail) ≤ 120, clientId ≤ 64. Mindestens eins davon darf nicht leer sein.{ "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, die du empfängst
readyframehello: der Zustand des Chats plus das, was du verpasst hast.ready →
chatIdnumbervisitorOnlinebooleanlastSeqnumberstatusstringopen oder closed.read{ agent, visitor }unreadnumbermessagesMessage[]since.gapbooleantrue, wenn du mehr verpasst hast, als in ein Nachholen passt — lade den Rest mit history.emailstring | nullchat/start.offline{ form, away, reason }messageframemessage →
chatIdnumbermMessagem →
seqnumberauthorstringvisitor, agent oder system.textstringtextContent), niemals als HTML.atstring (ISO 8601)file{ url, name, mime, size, kind } | nullkind ist image oder doc.ack{ id, seq, at }send mit dieser id wurde als Nachricht seq gespeichert. Mach aus „wird gesendet…“ ein „gesendet“.read{ chatId, by, seq }by) hat bis seq gelesen — z. B. hat der Operator deine Nachrichten gelesen.typing{ chatId, by }history{ chatId, before, messages, done }history-Anfrage. done: true — es gibt nichts Älteres.presenceframechat_goneframechat/start erneut auf, um einen frischen Chat zu bekommen.error{ code, id? }id ist gesetzt, wenn es um eine deiner Nachrichten geht. Die Codes sind unten aufgelistet.{
"t": "message",
"chatId": 123,
"m": { "seq": 6, "author": "agent", "text": "Your parcel left the warehouse today 🚚", "at": "2026-10-01T09:20:11.000Z" }
}Ein kompletter minimaler Client
Starten → verbinden → senden → empfangen, mit Reconnects. Etwa 90 Zeilen, keine Abhängigkeiten — steck dein eigenes render und markSent ein, und du hast einen funktionierenden 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 {} },
};
// Die Besucher-ID des Widgets wiederverwenden, falls da, sonst eine eigene behalten
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; // dieselbe Nachricht kann aus start, ready und message kommen
seen.add(m.seq);
lastSeq = Math.max(lastSeq, m.seq);
render(m); // deine UI: m.author, m.text (als 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 ist für diese Site aus
if (data.error) throw new Error(data.error);
store.set('my_chat_token', data.token); // 180 Tage gültig
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(); // ein brandneuer Chat
} else if (frame.t === 'error') console.warn('[chat]', frame.code, frame.id);
};
socket.onclose = (event) => {
if (event.code === 4402 || event.code === 4403) return; // abgeschaltet / verboten: aufhören
failures += 1;
const delay = Math.min(30000, 1000 * 2 ** failures) + Math.random() * 1000;
// Mehrere Fehlschläge hintereinander können heißen, dass der Token nicht mehr gilt — neu anfangen
setTimeout(() => (failures > 3 ? boot() : connect(token)), delay);
};
}
function send(text) {
const id = crypto.randomUUID(); // gefahrlos erneut sendbar: gleiche id = gleiche Nachricht
socket.send(JSON.stringify({ t: 'send', id, text }));
return id; // als „wird gesendet…“ anzeigen, bis das ack mit dieser id kommt
}
boot();Reconnects und verpasste Nachrichten
- Merk dir die höchste
seq, die du angezeigt hast. Nach einem Reconnect sende{ "t": "hello", "since": lastSeq }— derready-Frame bringt genau das, was du verpasst hast. - Ist
ready.gaptrue, hast du viel verpasst; füll die Lücke mithistory-Anfragen. - Nachrichten können doppelt ankommen (zum Beispiel aus
chat/startund ausready). Dedupliziere nachseq. - Warte zwischen den Versuchen immer länger (1 s, 2 s, 4 s… mit etwas Zufall) — ein Server-Neustart soll nicht in einer Reconnect-Stampede enden.
- Unsicher, ob eine Nachricht den Server erreicht hat? Schick sie noch mal mit derselben
id. Sie wird einmal gespeichert.
Anhänge in einer eigenen UI
Eine Datei braucht drei kurze Hops: Erlaubnis holen, Datei hochladen, Quittung senden.
// 1. Eine Upload-Erlaubnis — 5 Minuten gültig
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. Datei hochladen (Bilder oder Dokumente, bis 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. Eine Nachricht mit der Quittung senden (text darf leer sein)
socket.send(JSON.stringify({ t: 'send', id: crypto.randomUUID(), text: '', file: upload.receipt }));Fehler und Close-Codes
Fehler auf einer laufenden Verbindung kommen als { "t": "error", "code": "…", "id": "…" }:
| Code | Was passiert ist |
|---|---|
rate_limit_exceeded | Mehr als 20 Nachrichten pro Minute in diesem Chat. Warte und sende erneut mit derselben id. |
invalid_text | Der Text fehlt oder ist länger als 4000 Zeichen. |
invalid_file | Die Upload-Quittung ließ sich nicht verifizieren — abgelaufen oder aus einem anderen Chat. Lade erneut hoch. |
invalid_message_id | Die id besteht nicht aus 1–64 Zeichen aus A–Z a–z 0–9 _ -. |
frame_too_large | Der Frame ist größer als 8 KB. |
invalid_json | Der Frame ist kein gültiges JSON. |
unknown_frame | Unbekanntes t. Tippfehler oder ein Frame aus einem neueren Protokoll. |
empty_profile | Ein profile-Frame, in dem alle Felder leer sind. |
chat_not_found | Den Chat gibt es nicht mehr. Starte einen neuen. |
disabled | Der Chat wurde für die Site abgeschaltet. |
Die Verbindung selbst kann abgelehnt oder geschlossen werden:
| Wann | Code | Bedeutung |
|---|---|---|
| Handshake | 401 unauthorized | Der Token fehlt, ist ungültig oder abgelaufen. Ruf chat/start erneut auf. |
| Handshake | 429 too_many_connections | Mehr als 12 Verbindungen von dieser IP. Schließ die überzähligen. |
| Close | 4402 | Der Chat wurde für die Site abgeschaltet. Nicht neu verbinden. |
| Close | 4403 | Verboten. Nicht neu verbinden. |
Fehlersuche
Der Chat-Button erscheint nicht
- Der Tarif enthält den Chat nicht, oder der Chat ist abgeschaltet. Der Einstellungs-Endpoint antwortet mit
{ "enabled": false }. - Unbekannte Domain. Die Seite ist auf einer Domain geöffnet, die keine Site im CRM ist — ein Staging-Host,
localhost, eine neue Domain, die du noch nicht eingetragen hast. (www.spielt keine Rolle, es wird entfernt.) - Die CSP blockiert es. Such in der Browser-Konsole nach „Refused to load“ oder „Refused to connect“ und prüf den CSP-Abschnitt.
- Du schaust dir die Site im CRM an. Im Live-Modus lädt das Widget nur den Editor — kein Chat, keine Analytics. Öffne die Site in einem normalen Tab. Den Editor gibt es dabei nur im Live-Frame des CRM; bettet jemand anderes deine Site in ein fremdes iframe ein, lädt das Widget dort keinen Editor.
widget.jsfehlt auf dieser Seite — leicht zu übersehen, wenn eine Site mehrere Layouts hat.- Der Chat wurde gerade erst aktiviert, und dein Browser erinnert sich noch bis zu ~10 Minuten an die alten Einstellungen (siehe unten).
Du kannst den Server direkt fragen, was er von deiner Domain hält:
curl "https://back.sitecog.com/support/config?lang=en" -H "Origin: https://your-site.com"Änderungen aus dem CRM sind nicht zu sehen
- Die Einstellungen werden etwa 10 Minuten im Browser gecacht. Entweder warten oder den Key
crm_chat_cfgunter DevTools → Application → Local Storage löschen und neu laden. - Du hast die Texte für eine Sprache bearbeitet, aber die Seite hat ein anderes
<html lang>. Prüf die Fallback-Regeln. - Ein Textfeld ist leer, also zeigt das Widget seinen eingebauten Text — so ist es gewollt.