Un visitante tiene una duda a las once de la noche y tu formulario de contacto le parece un mensaje en una botella. El chat de soporte lo arregla: un botón de chat en cada página de tu web, y la conversación llega al CRM, donde tu equipo responde en tiempo real. Si ya añadiste widget.js para la edición en vivo, el chat está literalmente a cero líneas de código.
Cómo funciona el chat de soporte
- Un visitante hace clic en el botón de chat de la esquina de tu web y escribe un mensaje (con una captura de pantalla, si las palabras no le alcanzan).
- El mensaje aparece al instante en CRM → Soporte → Chats. Todo va por WebSocket, así que nadie tiene que darle a F5.
- Un operador responde desde el CRM y el visitante ve la respuesta al momento, con indicador de “escribiendo” y marcas de leído incluidos.
Para que nadie se pierda un chat mientras se prepara un café, los operadores tienen un contador de no leídos en el CRM, notificaciones del navegador y, si quieres, un bot de Telegram con horas de silencio. Todo eso se configura en el CRM, no en tu código.
El chat y el modo de consentimiento
Si activaste el modo de consentimiento en la etiqueta del widget, el chat no se queda esperando al banner de cookies: el visitante puede escribir desde el primer segundo. Lo que cambia antes del consentimiento:
- Identidad de usar y tirar. El chat usa un id temporal que vive solo en memoria: no se guarda nada en el navegador. Una conversación anterior se sigue recuperando con su propio
crm_chat_token. - Sin atribución. Sin
crm_vidnicrm_sid, el chat no queda vinculado al canal, las etiquetas UTM ni el enlace publicitario por el que llegó el visitante.
Dicho de otro modo: el soporte nunca depende de que alguien haya pulsado “Aceptar”; lo único que espera al consentimiento es la analítica.
Añade un chat en vivo a tu web
No hay código específico para el chat. La misma etiqueta que mueve todo lo demás hace el trabajo:
<script src="https://widget.sitecog.com/widget.js" defer></script>Cuando el chat está disponible para tu web, widget.js carga por su cuenta el bundle del chat (widget.support.js) y aparece un botón de chat flotante. Cuando no lo está, los visitantes no se descargan nada extra. Poner la etiqueta dos veces no hace daño: la segunda copia se ignora.
Comprueba que tu plan incluye el chat de soporte
El chat está disponible en los planes que lo incluyen. Sin chat en el plan, no hay botón, diga lo que diga el código.Comprueba que tu dominio es un sitio del CRM
El chat reconoce tu web por el dominio en el que se abre la página (elOrigindel navegador; elwww.se ignora). El dominio tiene que ser un sitio que hayas añadido en el CRM.Añade widget.js a la página
La etiqueta de arriba, o las variantes de Next.js / Vite de Widget y marcado → Paso 1.Dale estilo en el CRM
Icono, colores, posición y textos viven en CRM → Soporte → Ajustes: lo vemos más abajo.
Aspecto y textos en el CRM
Todo lo visual se configura en CRM → Soporte → Ajustes. Sin sobrescribir CSS y sin redesplegar: lo cambias ahí y el widget lo recoge.
| Opción | Valores permitidos | Notas |
|---|---|---|
| Icono | bubble, headset, question, envelope, spark o una imagen propia | Un icono propio se elige del almacenamiento de archivos de tu sitio. |
| Posición | bottom-right por defecto, bottom-left, top-right, top-left | Elige la esquina en la que no esté sentado tu banner de cookies. |
| Esquema de color | indigo por defecto, emerald, midnight, graphite | Paletas listas para el botón y la ventana del chat. |
| Título | hasta 80 caracteres | La cabecera de la ventana del chat. |
| Línea bajo el título | hasta 160 caracteres | La línea de debajo del título: buen sitio para un “solemos responder en 10 minutos”. |
| Texto de ejemplo en el campo de entrada | hasta 80 caracteres | La pista gris dentro del campo del mensaje. |
| Saludo en un chat vacío | hasta 200 caracteres | Se muestra en un chat vacío, antes de que el visitante haya escrito nada. |
Textos por idioma e idioma de respaldo
Cada texto se puede rellenar para cada idioma de tu sitio. El widget elige el texto para el idioma del visitante así:
- una coincidencia exacta de idioma;
- si no, una coincidencia por las dos primeras letras;
- si no, la primera entrada que hayas rellenado.
Si un texto está vacío, el widget usa su propio texto integrado. Así que puedes dejarlo todo en blanco y tener igualmente un chat de lo más decente: los ajustes son para cuando quieres que suene a ti.
Respuesta automática
Activa la respuesta automática y escribe un texto por idioma (hasta 1000 caracteres). Responde al primer mensaje de una conversación nueva, para que el visitante sepa que se le ha escuchado aunque todo el equipo esté en una reunión. Una conversación cuenta como nueva tras N minutos de silencio: tú eliges N entre 1 y 180; por defecto son 5.
Cuando no hay nadie conectado
Un chat en vivo sin nadie al otro lado es una trampa: el visitante escribe su pregunta, espera, cierra la pestaña… y nunca sabrás quién era. El modo sin conexión convierte el chat en un formulario de “déjanos tu correo” justo en esos momentos, para que la pregunta no desaparezca junto con el visitante.
Se activa en CRM → Soporte → Ajustes → “Cuando no hay nadie conectado”. El chat pasa al modo sin conexión cuando:
- ningún operador tiene el CRM abierto para este sitio. Cuenta cualquier página del CRM, no solo los chats: el operador está “conectado” hasta que cierra la última pestaña del CRM;
- está fuera del horario laboral, si lo configuras: días laborables, desde / hasta y una zona horaria. El horario es opcional; sin él solo se aplica la primera regla. Fuera de horario el chat está sin conexión aunque alguien tenga el CRM abierto por casualidad.
Qué cambia en el modo sin conexión:
- El widget muestra “Ahora no estamos conectados: deja tu correo y te responderemos” y pide el correo antes del mensaje. Sin correo no hay mensaje: si no, la respuesta no tendría adónde ir.
- En el CRM el chat lleva la marca “Solicitud sin conexión”, con el correo del visitante justo al lado.
- La notificación de Telegram (si conectaste el bot) sale de inmediato: un chat así está esperando por definición.
Mientras haya operadores conectados, el chat funciona como siempre y el correo es opcional: el visitante puede pulsar igualmente “Recibir respuestas por correo”, algo muy útil para quien está a punto de cerrar la pestaña.
Respuestas por correo
Si el visitante dejó su correo y no ha leído la respuesta del operador en el widget en unos 2 minutos, se la enviamos por correo. Varias respuestas seguidas llegan en un solo correo, no como una ráfaga de avisos. El correo contiene solo las respuestas del operador, no toda la conversación, y un enlace a la página de tu web desde la que escribió el visitante, para que pueda seguir allí mismo.
Idioma del widget
El widget habla el idioma de tu página: lee <html lang> y se queda con las dos primeras letras, así que en-GB y en significan inglés por igual.
- Hay textos de interfaz integrados para
ru,en,uk,es,deyzh. Cualquier otro idioma recibe inglés. - Tus propios textos del CRM siguen las reglas de respaldo de arriba.
- ¿Una SPA con selector de idioma? Basta con actualizar
document.documentElement.lang: el widget detecta el cambio y redibuja sus textos sobre la marcha, sin recargar.
function setLanguage(lang) {
// ...cambia tus propias traducciones...
document.documentElement.lang = lang; // el chat te sigue
}Identifica a los usuarios con sesión iniciada
Por defecto, un operador ve “Visitante”. Si tu web tiene cuentas, puedes hacerlo mejor: pon el nombre del usuario y tu id interno en localStorage y el operador verá con quién está hablando.
support_client_namestring, ≤ 80 charsopcionalsupport_client_idstring, ≤ 64 charsopcionaluser_8421). Se muestra en la ficha del visitante, para que los operadores encuentren la cuenta en tu propio panel de administración.El widget solo lee estas claves. Las envía al conectarse y otra vez antes de cada mensaje si han cambiado, así que da igual si las pones antes de que cargue la página o justo después de que el usuario inicie sesión. Al cerrar sesión, borra las dos claves: si no, la siguiente persona en el mismo ordenador hereda el nombre.
// 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 {
// El almacenamiento está bloqueado (ajustes de privacidad estrictos): el chat sigue funcionando, solo que de forma anónima
}
}
// Tras iniciar sesión correctamente
setSupportIdentity({ id: 'user_8421', name: 'Anna Schmidt' });
// Al cerrar sesión
setSupportIdentity(null);import { useEffect } from 'react';
type User = { id: string; name: string };
// user: undefined = aún cargando, null = sin sesión, objeto = con sesión
export function useSupportIdentity(user: User | null | undefined) {
useEffect(() => {
if (user === undefined) return; // no borres las claves mientras la autenticación sigue cargando
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 {
// El almacenamiento está bloqueado: el chat sigue siendo anónimo, no se rompe nada
}
}, [user]);
}
// En el shell de tu app:
// 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; // no renderiza nada, solo sincroniza las claves
}
// app/layout.tsx: un Server Component conoce la sesión con certeza
// const user = await getCurrentUser(); // tu autenticación
// ...
// <body>
// {children}
// <SupportIdentity id={user?.id} name={user?.name} />
// <Script src="https://widget.sitecog.com/widget.js" strategy="afterInteractive" />
// </body>Adjuntos y límites
Los visitantes pueden adjuntar archivos a sus mensajes: una captura del error vale más que mil palabras.
| Qué | Límite |
|---|---|
| Imágenes | jpeg, png, gif, webp, avif |
| Documentos | pdf, doc, docx, xls, xlsx, txt, csv |
| Tamaño de archivo | hasta 10 MB |
| Mensajes | 20 por minuto por chat |
| Conexiones | hasta 12 a la vez desde una IP |
| Chats nuevos | 10 por IP por hora |
Los límites son generosos para las personas y fastidiosos para los bots, que es justo la idea. Una oficina llena de gente detrás de una sola IP con la web abierta en una docena de pestañas es el único caso en el que podrías notar el límite de conexiones.
Abrir el chat desde tu propio botón
El botón redondo de la esquina le va bien a la mayoría de las webs, pero a veces el chat pide encajar en tu propio diseño: un enlace “Habla con nosotros” en la cabecera, un botón en la página de precios, un icono de ayuda en la app. Hay dos formas de hacerlo, y una no necesita ni una línea de JavaScript.
Sin JavaScript: data-crm-chat
Pon data-crm-chat en cualquier elemento y un clic abrirá el chat:
<a href="/contacts" data-crm-chat="open">Habla con nosotros</a>
<button type="button" data-crm-chat="toggle">💬 Soporte</button>open(también el valor por defecto si lo dejas vacío),closeotoggle.- Los elementos que se añaden más tarde (modales, rutas del lado del cliente) también funcionan: el widget escucha todo el documento.
- Plan B incorporado. Si el chat no está disponible en la web (o
widget.jsestá bloqueado), el clic no se intercepta y el enlace simplemente va a suhref. Apúntalo a tu página de contacto y nadie se topará con un botón muerto.
Desde JavaScript: window.crmChat
crmChat.open()functionopcionalcrmChat.close()functionopcionalcrmChat.toggle()functionopcionalcrmChat.isOpen()() => booleanopcionalcrmChat.availableboolean | nullopcionaltrue: el chat funciona en esta web; false: no funciona (plan, ajustes); null: aún no lo sabemos, el widget lo está preguntando.crmChat.unreadnumberopcionalwidget.js crea window.crmChat al instante, antes de que se cargue el propio chat. Las llamadas que se hagan en ese primer segundo se guardan y se ejecutan en cuanto el chat está listo, así que un clic en tu botón nunca se pierde. En el modo Live del CRM el chat no se carga en absoluto y crmChat simplemente no hace nada: tu código tampoco se romperá ahí.
document.querySelector('#help').addEventListener('click', () => {
// ?. por si un bloqueador de anuncios no dejó cargar widget.js
window.crmChat?.open();
});export function HelpButton() {
return (
<button type="button" onClick={() => window.crmChat?.open()}>
¿Necesitas ayuda?
</button>
);
}Oculta el botón integrado
¿Tienes tu propio botón? Oculta el botón redondo del widget con data-chat-button="hidden" en la misma etiqueta script. El panel se sigue abriendo y cerrando como siempre: tiene su propia × en la cabecera.
<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" />Muestra las respuestas sin leer en tu botón
Sin el botón redondo, el visitante necesita otra forma de enterarse de que el operador ha contestado. El widget lanza un evento crm:chat en window cada vez que algo cambia, con { available, open, unread } en detail. Úsalo para un contador y para ocultar tu botón cuando el chat no esté disponible.
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;
}
// Uso
export function ChatButton() {
const { available, unread } = useCrmChat();
if (available === false) return null;
return (
<button type="button" onClick={() => window.crmChat?.open()}>
Soporte {unread > 0 && <span className="badge">{unread}</span>}
</button>
);
}Content Security Policy para el chat
¿Tu web no tiene CSP? Sáltate esta sección. Si la tiene, el chat necesita estas fuentes:
script-src https://widget.sitecog.com
connect-src https://back.sitecog.com wss://back.sitecog.com
style-src 'unsafe-inline'
img-src <el host desde el que se sirven los archivos de tu CRM>script-src: para cargarwidget.jsy el bundle del chat.connect-src: para la petición de ajustes y el WebSocket. Fíjate en la entradawss://:https://por sí solo no cubre los WebSockets.style-src: la ventana del chat vive en un shadow DOM con un<style>inline, así que una política estricta puede necesitar'unsafe-inline'(por ejemplo,style-src 'self' 'unsafe-inline').img-src: los adjuntos y el icono propio se sirven desde el almacenamiento de archivos de tu sitio. Abre cualquier imagen del CRM y permite su host.
Avanzado: monta tu propia interfaz de chat
Un cliente propio son tres pasos, y uno más cuando no hay nadie conectado:
Lee los ajustes
¿Está activado el chat para este sitio? ¿Qué dicen los textos? ¿Hay alguien conectado?Inicia un chat
Consigue un token de chat por HTTP y guárdalo.Deja un correo, en modo sin conexión
¿Ahora no puede responder nadie? Guarda el correo del visitante antes de su primer mensaje.Habla por el WebSocket
Conéctate con el token, envía y recibe frames.
Todos los endpoints de soporte son públicos: no hay clave de API. El sitio se reconoce por la cabecera Origin del navegador (con Referer como alternativa), así que llámalos desde páginas de tu dominio real. Un dominio que no es un sitio del CRM recibe 400 con {"message":"unknown_domain"}; una petición sin ningún origen recibe {"message":"unknown_origin"}.
1. Lee los ajustes del chat
GET https://back.sitecog.com/support/config?lang=enlangquerystringopcionalen. Se aplican las mismas reglas de respaldo.{
"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: no muestres nada y para aquí.lookobjectlook →
iconstringbubble, headset, question, envelope o spark.iconUrlstring | nullpositionstringbottom-right, bottom-left, top-right o top-left.skinstringindigo, emerald, midnight o graphite. En tu propia interfaz, tradúcelo a tus colores, o ignóralo.textsobjectnull significa “sin rellenar, usa tu propio texto por defecto”.texts →
titlestring | nullsubtitlestring | nullplaceholderstring | nullgreetingstring | nullofflineobjectenabled es true. La presencia cambia de un minuto a otro, así que vuelve a preguntar al abrir la ventana del chat en lugar de guardarlo en caché mucho tiempo.offline →
formbooleanfalse: nunca pidas el correo, simplemente chatea.awaybooleantrue: ahora no puede responder nadie; muestra el formulario y pide el correo antes del primer mensaje (paso 3). Siempre false cuando form es false.reasonstring | nullafter_hours, fuera del horario laboral; no_agents, ningún operador tiene el CRM abierto. null cuando away es false.2. Inicia o reanuda un chat
POST https://back.sitecog.com/support/chat/start
Content-Type: application/json
{
"visitorId": "5f1c2a9e-3b7d-4c1e-9a0f-8d2b6e4c7a10",
"lang": "en",
"token": "<el token que recibiste la última vez, si lo tienes>"
}visitorIdstringobligatorioA–Z a–z 0–9 _ . : -. Si widget.js está en la página, reutiliza su crm_vid de localStorage; si no, genera el tuyo una vez (un UUID sirve) y guárdalo. Con el modo de consentimiento activo y sin consentimiento todavía, no hay crm_vid: genera un id temporal en memoria y no lo guardes.langstringopcionalen.tokenstringopcional{
"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: el chat está desactivado para este sitio; la respuesta no trae nada más.tokenstring (JWT)localStorage) y sobrescríbelo siempre con el más reciente. Abre el WebSocket y reanuda este chat la próxima vez.chatIdnumberlastSeqnumberseq es tu cursor para todo lo que viene a continuación.readSeqnumberstatusstringopen o closed, según lo hayan dejado los operadores en el CRM.messagesMessage[]emailstring | nullofflineobjectoffline en los ajustes: { form, away, reason }, en el momento de la petición.errorstring | nullinvalid_visitor (visitorId incorrecto) o rate_limit_exceeded (demasiados chats nuevos desde esta IP).3. Deja un correo
Cuando offline.away es true, guarda el correo del visitante antes de enviar su primer mensaje: es lo que hace el widget integrado, y la única forma de que la respuesta llegue a alguien que ya cerró la pestaña. Mientras haya operadores conectados, la misma llamada sirve para un botón opcional de “Recibir respuestas por correo”.
POST https://back.sitecog.com/support/chat/contact
Content-Type: application/json
{
"token": "<token del chat de chat/start>",
"email": "anna@example.com",
"page": "https://shop.example/delivery?utm_source=newsletter"
}tokenstringobligatoriochat/start. Solo puedes asociar un correo a tu propio chat.emailstringobligatoriopagestringopcional#fragmento: el ejemplo de arriba se guarda como https://shop.example/delivery.{ "ok": true, "email": "anna@example.com" }{ "ok": false, "error": "invalid_email" }error | Qué ha pasado |
|---|---|
invalid_email | Eso no parece una dirección de correo. Pide al visitante que la revise. |
invalid_token | El token falta, ha caducado o es de otro sitio. Vuelve a llamar a chat/start. |
rate_limit_exceeded | Comparte el límite de 20 por minuto con los mensajes del chat. Espera un poco y reintenta. |
disabled | El chat se ha desactivado para el sitio. |
chat_not_found | El chat se eliminó. Olvida el token e inicia uno nuevo. |
// chat: la respuesta de chat/start (o un frame ready más reciente)
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); // no perdemos el mensaje, que corrija la dirección
chat.email = res.email;
}
send(text); // el frame send del WebSocket, paso 4
}4. Conéctate al WebSocket
wss://back.sitecog.com/support/wsEl token va en la lista de subprotocolos, no en la URL: las URL acaban en los logs y los subprotocolos no. Pasa dos subprotocolos: la versión del protocolo y token. + tu token de chat. El navegador envía Origin por su cuenta, y tiene que ser tu sitio.
const socket = new WebSocket('wss://back.sitecog.com/support/ws', [
'crm.support.v1',
'token.' + chatToken,
]);
socket.onopen = () => {
// Saluda y dile al servidor cuál es el último mensaje que ya tienes
socket.send(JSON.stringify({ t: 'hello', since: lastSeq }));
};Tras el hello, el servidor responde con un frame ready que contiene todo lo que te perdiste desde since. Cada frame es un objeto JSON con su tipo en t, de 8 KB como máximo. El servidor manda un ping cada 25 segundos; los navegadores responden a los pings solos, no tienes que hacer nada.
Frames que envías
hello{ t, since }since: el último seq que tienes (0 si no tienes ninguno). Se responde con ready.send{ t, id, text, file? }id: tu propio id de mensaje, [A-Za-z0-9_-], de 1 a 64 caracteres; reenviar el mismo id nunca crea un duplicado, así que reintenta sin miedo. text: hasta 4000 caracteres. file: el recibo de una subida (mira adjuntos).read{ t, seq }seq”. Alimenta las marcas de leído que ve el operador.typing{ t }history{ t, before }seq = before (0 = desde el más reciente). Se responde con history.profile{ t, name?, contact?, clientId? }name ≤ 80, contact (teléfono o email) ≤ 120, clientId ≤ 64. Al menos uno no debe estar vacío.{ "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 que recibes
readyframehello: el estado del chat más lo que te perdiste.ready →
chatIdnumbervisitorOnlinebooleanlastSeqnumberstatusstringopen o closed.read{ agent, visitor }unreadnumbermessagesMessage[]since.gapbooleantrue si te perdiste más de lo que cabe en una sola puesta al día: carga el resto con history.emailstring | nullchat/start.offline{ form, away, reason }messageframemessage →
chatIdnumbermMessagem →
seqnumberauthorstringvisitor, agent o system.textstringtextContent), nunca como HTML.atstring (ISO 8601)file{ url, name, mime, size, kind } | nullkind es image o doc.ack{ id, seq, at }send con este id se ha guardado como el mensaje seq. Cambia el estado “enviando…” a “enviado”.read{ chatId, by, seq }by) ha leído hasta seq: por ejemplo, el operador ha leído tus mensajes.typing{ chatId, by }history{ chatId, before, messages, done }history. done: true: no hay nada más antiguo.presenceframechat_goneframechat/start para tener un chat nuevo.error{ code, id? }id viene relleno cuando se refiere a uno de tus mensajes. Los códigos están más abajo.{
"t": "message",
"chatId": 123,
"m": { "seq": 6, "author": "agent", "text": "Your parcel left the warehouse today 🚚", "at": "2026-10-01T09:20:11.000Z" }
}Un cliente mínimo completo
Iniciar → conectar → enviar → recibir, con reconexiones. Unas 90 líneas, sin dependencias: enchufa tus propios render y markSent y tendrás un chat funcionando.
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 {} },
};
// Reutiliza el id de visitante del widget si está; si no, guarda el nuestro
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; // el mismo mensaje puede llegar de start, ready y message
seen.add(m.seq);
lastSeq = Math.max(lastSeq, m.seq);
render(m); // tu interfaz: m.author, m.text (¡como texto!), 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; // el chat está desactivado para este sitio
if (data.error) throw new Error(data.error);
store.set('my_chat_token', data.token); // válido 180 días
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(); // un chat completamente nuevo
} else if (frame.t === 'error') console.warn('[chat]', frame.code, frame.id);
};
socket.onclose = (event) => {
if (event.code === 4402 || event.code === 4403) return; // desactivado / prohibido: paramos
failures += 1;
const delay = Math.min(30000, 1000 * 2 ** failures) + Math.random() * 1000;
// Varios fallos seguidos pueden significar que el token ya no vale: empezamos de nuevo
setTimeout(() => (failures > 3 ? boot() : connect(token)), delay);
};
}
function send(text) {
const id = crypto.randomUUID(); // se puede reenviar: mismo id = mismo mensaje
socket.send(JSON.stringify({ t: 'send', id, text }));
return id; // muéstralo como "enviando…" hasta el ack con este id
}
boot();Reconexiones y mensajes perdidos
- Guarda el
seqmás alto que hayas mostrado. Tras reconectar, envía{ "t": "hello", "since": lastSeq }: el framereadytrae exactamente lo que te perdiste. - Si
ready.gapestrue, te has perdido mucho; rellena el hueco con peticioneshistory. - Los mensajes pueden llegar dos veces (de
chat/starty deready, por ejemplo). Elimina duplicados porseq. - Espera cada vez más entre intentos (1 s, 2 s, 4 s… con algo de aleatoriedad): un reinicio del servidor no debería convertirse en una estampida de reconexiones.
- ¿No sabes si un mensaje llegó al servidor? Envíalo de nuevo con el mismo
id. Se guarda una sola vez.
Adjuntos en una interfaz propia
Un archivo va en tres saltos cortos: consigue un permiso, sube el archivo y envía el recibo.
// 1. Un permiso para subir: válido durante 5 minutos
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. Sube el archivo (imágenes o documentos, hasta 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. Envía un mensaje con el recibo (el texto puede ir vacío)
socket.send(JSON.stringify({ t: 'send', id: crypto.randomUUID(), text: '', file: upload.receipt }));Errores y códigos de cierre
Los errores en una conexión activa llegan como { "t": "error", "code": "…", "id": "…" }:
| Código | Qué ha pasado |
|---|---|
rate_limit_exceeded | Más de 20 mensajes por minuto en este chat. Espera y reenvía con el mismo id. |
invalid_text | Falta el texto o tiene más de 4000 caracteres. |
invalid_file | No se pudo verificar el recibo de la subida: ha caducado o es de otro chat. Vuelve a subir el archivo. |
invalid_message_id | El id no tiene de 1 a 64 caracteres de A–Z a–z 0–9 _ -. |
frame_too_large | El frame pesa más de 8 KB. |
invalid_json | El frame no es un JSON válido. |
unknown_frame | t desconocido. Una errata, o un frame de una versión más nueva del protocolo. |
empty_profile | Un frame profile con todos los campos vacíos. |
chat_not_found | El chat ya no existe. Inicia uno nuevo. |
disabled | El chat se ha desactivado para el sitio. |
La propia conexión puede rechazarse o cerrarse:
| Cuándo | Código | Significado |
|---|---|---|
| Handshake | 401 unauthorized | El token falta, no es válido o ha caducado. Vuelve a llamar a chat/start. |
| Handshake | 429 too_many_connections | Más de 12 conexiones desde esta IP. Cierra las que sobran. |
| Cierre | 4402 | El chat se ha desactivado para el sitio. No vuelvas a conectar. |
| Cierre | 4403 | Prohibido. No vuelvas a conectar. |
Solución de problemas
No aparece el botón del chat
- El plan no incluye el chat, o el chat está desactivado. El endpoint de ajustes responde
{ "enabled": false }. - Dominio desconocido. La página se abre en un dominio que no es un sitio del CRM: un host de staging,
localhost, un dominio nuevo que aún no has añadido. (Elwww.da igual, se quita). - Lo bloquea la CSP. Busca “Refused to load” o “Refused to connect” en la consola del navegador y revisa la sección de CSP.
- Estás mirando la web dentro del CRM. En el modo Live el widget solo carga el editor: ni chat ni analítica. Abre la web en una pestaña normal. (El editor, por cierto, solo se carga en el frame Live del propio CRM: si otra web mete la tuya en su iframe, allí no hay editor).
- Falta
widget.jsen esta página: es fácil que se escape cuando una web tiene varios layouts. - Acabas de activar el chat y tu navegador aún recuerda los ajustes antiguos durante hasta ~10 minutos (mira más abajo).
Puedes preguntarle directamente al servidor qué opina de tu dominio:
curl "https://back.sitecog.com/support/config?lang=en" -H "Origin: https://your-site.com"No se ven los cambios hechos en el CRM
- Los ajustes se guardan en caché en el navegador durante unos 10 minutos. Espera, o borra la clave
crm_chat_cfgen DevTools → Application → Local Storage y recarga. - Has editado los textos de un idioma, pero la página tiene otro
<html lang>. Revisa las reglas de respaldo. - Un campo de texto está vacío, así que el widget muestra su texto integrado: es así por diseño.