.method.get { background: var(--success); } .method.put { background: #3B82F6; } h4 { margin-top: 1rem; color: var(--text); } ul, ol { margin: 0.5rem 0 1rem; padding-left: 1.5rem; color: var(--text-muted); } li { margin-bottom: 0.35rem; } .toc { columns: 2; column-gap: 2rem; padding-left: 1.2rem; } .toc a, a { color: #A5B4FC; text-decoration: none; } .toc a:hover, a:hover { text-decoration: underline; } .warn { background: rgba(245, 158, 11, 0.1); border-left-color: #F59E0B; } .warn .alert-title { color: #F59E0B; } .tag { display: inline-block; background: var(--code-bg); border: 1px solid var(--border); border-radius: 4px; padding: 0 0.4em; font-size: 0.75rem; color: var(--text-muted); } pre code { white-space: pre; } .flow { font-family: 'Fira Code', Consolas, monospace; font-size: 0.8rem; line-height: 1.5; } .cfgform { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-top: 0.5rem; } .cfgform label { display: block; font-size: 0.8rem; font-weight: 600; color: var(--text-muted); } .cfgform input { width: 100%; margin-top: 0.25rem; padding: 0.5rem 0.65rem; background: var(--code-bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font: inherit; font-size: 0.9rem; } .cfgform input:focus { outline: 2px solid var(--primary); outline-offset: 1px; } .snipgroup { margin-top: 0.5rem; } .tabs { display: flex; flex-wrap: wrap; gap: 0.25rem; border-bottom: 1px solid var(--border); } .tabs button { background: none; border: 0; color: var(--text-muted); padding: 0.5rem 0.8rem; cursor: pointer; font: inherit; font-size: 0.85rem; border-bottom: 2px solid transparent; margin-bottom: -1px; } .tabs button:hover { color: var(--text); } .tabs button[aria-selected="true"] { color: var(--text); border-bottom-color: var(--primary); font-weight: 600; } .tabs button:focus-visible, .copy-btn:focus-visible { outline: 2px solid #A5B4FC; outline-offset: 2px; } .tabpanel { position: relative; } .tabpanel .copy-btn { position: absolute; top: 0.6rem; right: 0.6rem; padding: 0.3rem 0.7rem; } .tabpanel pre { margin-top: 0.75rem; padding-top: 1rem; } @media (max-width: 640px) { body { padding: 1rem; } .toc { columns: 1; } }
Documentação oficial de implementação: widget embutível, API pública e integração com o sistema de atendimento.
O webchat conecta um chat no seu site ao sistema de atendimento. O visitante conversa pelo widget; o hub guarda a conversa, repassa cada mensagem ao sistema de atendimento por webhook e entrega as respostas dos atendentes de volta ao widget por polling. A v2 adiciona anexos, recibos de leitura, horário de atendimento, presença (online e digitando) e restauração de histórico.
visitante (widget) --POST /api/webhooks/webchat/:token--> hub --webhook message.received--> sistema de atendimento
visitante (widget) <--GET /api/webchat/messages (polling)-- hub <--POST /api/messages/sendText|sendMedia-- atendente
visitante (widget) --POST /api/webchat/presence--> hub --webhook presence.update--> sistema ("digitando...")
atendente abre o ticket --POST /api/messages/markAsRead--> hub (status read) --> widget mostra "lida" (✓✓)Convenções desta página: {API} é a URL pública do hub (ex.:
https://sua-api.com.br), {token} é o token UUID
da conexão webchat e x-api-key é a chave pública do widget
(wc_...). O CORS é aberto, então o widget funciona em qualquer domínio.
Informe os dados da sua conexão e todos os exemplos abaixo são preenchidos na hora. Também é possível abrir esta
página com ?token=SEU_TOKEN&key=wc_SUA_CHAVE (e
&api=https://sua-api.com.br se a API estiver em outro domínio). Nada é enviado
para fora do navegador.
Uma linha no site. Escolha o seu ambiente:
Cada exemplo gera o visitorId (128 bits), envia uma mensagem, faz polling com
lastMessageId, since e
cursor, e mostra como responder, reagir, editar, apagar e avisar leitura. Contrato
completo na seção Interações v2.1.
No painel, vá em Conexões e crie uma conexão do tipo webchat. Anote o
token e a chave wc_... (a tela de
configurações do webchat já mostra o snippet pronto).
Antes do fechamento de </body>:
<script
src="https://sua-api.com.br/webchat/widget.js"
data-token="SEU_TOKEN"
data-api-key="wc_SUA_CHAVE"
defer></script>Pronto. O widget busca aparência, horário e limites no hub, então mudanças feitas no painel valem sem editar o site. Um arquivo único, sem dependências e sem build; usa Shadow DOM, então o CSS do seu site não interfere e o do widget não vaza.
Para testar manualmente: /webchat-demo?token=SEU_TOKEN&key=wc_SUA_CHAVE.
| Atributo | Padrão | Descrição |
|---|---|---|
data-tokenobrigatório | - | Token UUID da conexão. |
data-api-keyobrigatório | - | Chave pública do widget (wc_...). |
data-api-url | origem do src | URL do hub, se o script for servido de outro lugar (CDN, proxy). |
data-position | painel | right ou left; sobrepõe o painel. |
data-color | painel | Cor de destaque em hexadecimal (ex.: #0F766E). O contraste do texto é calculado sozinho. |
data-title | painel | Título do cabeçalho. |
data-collect-whatsapp | optional | required, optional ou off. Quando preenchido, valida 10 a 15 dígitos. |
data-open | - | true abre o chat ao carregar. |
data-lang | pt-BR | pt-BR, en ou es. |
data-footer | - | false oculta o rodapé "Atendimento por ...". |
Recursos incluídos: pré-chat (nome, WhatsApp e primeira mensagem), mensagem de boas-vindas, banner offline,
status de envio (enviando, enviada, lida), anexos por clipe, arrastar e soltar e colar imagem, miniaturas com
lightbox, vídeo, áudio e documentos, formatação *negrito*_itálico_~tachado~`código` e links, badge de não lidas com som discreto, tema escuro automático, tela
cheia em telas abaixo de 480px e menu "Encerrar conversa". Nada é renderizado com innerHTML.
| Método | Descrição |
|---|---|
HubWebchat.open() | Abre a janela. |
HubWebchat.close() | Fecha a janela. |
HubWebchat.toggle() | Alterna abrir e fechar. |
HubWebchat.identify({ name, whatsapp }) | Pré-preenche o pré-chat (e os dados enviados) com o usuário logado no seu site. |
HubWebchat.on(evento, fn) | Eventos: message (nova resposta do atendente, recebe { id, text, mediaUrl, mediaType, fileName, timestamp }), open e close. |
<button onclick="HubWebchat.open()">Fale conosco</button>
<script>
window.addEventListener('load', function () {
HubWebchat.identify({ name: 'Ana Souza', whatsapp: '5511999999999' });
HubWebchat.on('message', function (m) { console.log('Nova resposta', m.text); });
});
</script>Chamadas feitas antes do widget terminar de carregar ficam em fila e são executadas assim que ele estiver pronto.
O widget só cria window.HubWebchat no escopo global.
Em Conexões, abra as configurações da conexão webchat. Tudo é salvo por
PUT /api/webchat/connections/:token/settings (JWT) e lido pelo widget a cada carregamento.
| Grupo | Campos |
|---|---|
| Aparência | title, color, avatarUrl, position (right|left), welcomeMessage. |
| Horário de atendimento | businessHours: fuso (timezone) e faixas HH:MM por dia (mon a sun), início menor que fim, até 4 faixas por dia. null remove o horário. |
| Modo | availabilityMode: auto segue o horário (sem horário configurado, fica sempre online), online força online, offline força offline. |
| Offline | offlineMessage: texto exibido no banner quando offline. O visitante ainda pode enviar mensagem; ela fica na fila de atendimento. |
curl -X PUT "https://sua-api.com.br/api/webchat/connections/SEU_TOKEN/settings" \
-H "Authorization: Bearer SEU_JWT" -H "Content-Type: application/json" \
-d '{
"title": "Atendimento", "color": "#4F46E5",
"welcomeMessage": "Olá! Como podemos ajudar?",
"offlineMessage": "Estamos fora do horário. Deixe sua mensagem.",
"availabilityMode": "auto",
"businessHours": {
"timezone": "America/Sao_Paulo",
"days": { "mon": [["08:00","18:00"]], "tue": [["08:00","18:00"]], "wed": [["08:00","18:00"]],
"thu": [["08:00","18:00"]], "fri": [["08:00","18:00"]], "sat": [["09:00","13:00"]], "sun": [] }
}
}'GET /api/webchat/connections/:token/settings (JWT) devolve as configurações atuais, a
apiKey, a URL do script e o snippet pronto. Quando offline em modo
auto, o widget mostra "Offline · voltamos às HH:MM" no fuso do visitante, usando
availability.nextOpenAt.
Todos os endpoints públicos usam o header x-api-key, exceto a configuração do widget.
Erros seguem { "error": { "code", "message" } } (ou { "error": "..." }).
{
"success": true,
"config": {
"title": "Atendimento", "color": "#4F46E5", "welcomeMessage": "Olá! Como podemos ajudar?",
"businessName": "Empresa X", "avatarUrl": null, "position": "right",
"offlineMessage": "Estamos fora do horário...",
"availability": { "status": "online", "mode": "auto", "timezone": "America/Sao_Paulo", "nextOpenAt": null },
"upload": { "enabled": true, "maxSizeBytes": 16777216, "accept": ["image/png", "application/pdf", "..."] },
"polling": { "openIntervalMs": 3000, "idleIntervalMs": 15000 },
"presence": { "heartbeatIntervalMs": 25000, "typingThrottleMs": 3000 }
}
}availability.status: online ou offline; nextOpenAt é ISO 8601 do próximo expediente (ou null).polling, presence e upload em vez de fixar números no seu código.Header x-api-key; corpo multipart/form-data com o campo
file (um arquivo).
curl -X POST "https://sua-api.com.br/api/webchat/upload/SEU_TOKEN" \
-H "x-api-key: wc_SUA_CHAVE" -F "file=@foto.png"{ "success": true,
"attachment": { "url": "https://sua-api.com.br/uploads/media/12/webchat/foto-169..-abc.png",
"mimeType": "image/png", "mediaType": "image", "fileName": "foto.png", "size": 48213 } }| Status | Código | Significado |
|---|---|---|
| 400 | MISSING_FILE | Nenhum arquivo no campo file. |
| 401 | INVALID_API_KEY | Chave inválida. |
| 413 | FILE_TOO_LARGE | Acima do limite de tamanho. |
| 415 | UNSUPPORTED_MEDIA_TYPE | Tipo não aceito (SVG e HTML são sempre recusados). |
| 429 | RATE_LIMIT_EXCEEDED | Muitos uploads. |
mediaType: image, video, audio ou document.
| Campo | Tipo | Descrição |
|---|---|---|
visitorId | string | Identificador do visitante, ^[A-Za-z0-9_-]{8,128}$obrigatório |
visitorName | string | Nome do visitante. |
whatsapp | string | WhatsApp com DDI e DDD, só dígitos. |
message | string | Texto ou legenda, até 4096 caracteres. Opcional quando há attachment. |
attachment | object | { url, mimeType, fileName, mediaType }, exatamente como devolvido pelo upload. |
clientMessageId | string | Id gerado no cliente; é devolvido na resposta para casar a mensagem local. |
É obrigatório informar visitorId e (message não vazia ou
attachment). A attachment.url precisa ter sido emitida pelo upload
desta conexão; qualquer outra URL retorna 400 INVALID_ATTACHMENT.
curl -X POST "https://sua-api.com.br/api/webhooks/webchat/SEU_TOKEN" \
-H "Content-Type: application/json" -H "x-api-key: wc_SUA_CHAVE" \
-d '{
"visitorId": "visitor_3f9c1a7e5b2d4c8091a6e3f7d2b4c5a8",
"visitorName": "Ana", "whatsapp": "5511999999999",
"message": "Segue a foto",
"attachment": { "url": "https://sua-api.com.br/uploads/media/12/webchat/foto-169..-abc.png",
"mimeType": "image/png", "fileName": "foto.png", "mediaType": "image" },
"clientMessageId": "c_lr8x2k_1"
}'{ "ok": true, "chatId": 55, "messageId": 9012, "clientMessageId": "c_lr8x2k_1",
"status": "received", "timestamp": "2026-10-17T12:00:00.000Z" }Guarde o messageId na mensagem local: é com ele que você casa os recibos de leitura.
{
"success": true,
"messages": [
{ "id": 9013, "direction": "out", "text": "Olá Ana!", "type": "text", "mediaUrl": null, "mediaType": null,
"mimeType": null, "fileName": null, "timestamp": "2026-10-17T12:00:05.000Z" },
{ "id": 9014, "direction": "out", "text": "segue o boleto", "type": "media",
"mediaUrl": "https://sua-api.com.br/uploads/media/12/boleto-....pdf", "mediaType": "document",
"mimeType": "application/pdf", "fileName": "boleto.pdf", "timestamp": "..." }
],
"receipts": { "lastReadMessageId": 9012 },
"availability": { "status": "online", "mode": "auto", "timezone": "America/Sao_Paulo", "nextOpenAt": null }
}messages traz só respostas da empresa com id > lastMessageId, em ordem crescente, no máximo 50 por chamada.mediaType: image, video, audio, document, sticker ou null. type pode ser text, media, interactive ou template; exiba o text.receipts.lastReadMessageId: toda mensagem do visitante com messageId <= lastReadMessageId está lida (✓✓).availability acompanha cada resposta, então o status online/offline fica atualizado sem chamada extra.limit de 1 a 100; ordem crescente. Use quando o widget já tem
visitorId mas nenhuma mensagem local (outro navegador, storage limpo).
{ "success": true,
"messages": [ { "id": 9012, "direction": "in", "text": "...", "type": "image", "mediaUrl": "...",
"mediaType": "image", "mimeType": "image/png", "fileName": "foto.png",
"status": "read", "timestamp": "..." } ],
"hasMore": false, "receipts": { "lastReadMessageId": 9012 } }Aqui direction: "in" é a mensagem do visitante e "out" a da empresa.
curl -X POST "https://sua-api.com.br/api/webchat/presence/SEU_TOKEN/visitor_3f9c..." \
-H "Content-Type: application/json" -H "x-api-key: wc_SUA_CHAVE" \
-d '{ "state": "typing" }'state: online, typing, paused ou offline. Resposta: { "success": true, "availability": { ... } }.
online a cada presence.heartbeatIntervalMs (25 s) enquanto a página estiver visível.typing ao digitar, no máximo uma vez a cada typingThrottleMs (3 s); paused ao esvaziar o campo ou perder o foco.offline em pagehide, com fetch(..., { keepalive: true }) (aceita header, ao contrário de sendBeacon).{ "token": "TOKEN_DA_CONEXAO", "messageId": "wc_... ou id numérico" }Para webchat, marca essa mensagem do visitante e todas as anteriores da mesma conversa como
read. O widget passa a exibir ✓✓ via receipts.lastReadMessageId.
As respostas saem pelos endpoints de envio padrão do hub, usando o visitorId como
destinatário (o phoneNumber do chat):
POST /api/messages/sendText e POST /api/messages/sendMedia.
O hub detecta que a conexão é webchat, não envia via WhatsApp e deixa a mensagem disponível para o polling.
Não use o WhatsApp real do visitante como destinatário. Use sempre o visitorId
do chat, para não duplicar conversas.
Quem integra o sistema de atendimento recebe, no webhook da conexão, o evento
presence.update para exibir "digitando..." e online/offline do visitante:
{
"event": "presence.update",
"timestamp": "2026-10-17T12:00:00.000Z",
"connection": { "id": "<token>", "channel": "webchat", "businessName": "Empresa X" },
"chat": { "id": 55, "phoneNumber": "visitor_3f9c...", "visitorId": "visitor_3f9c...", "contactName": "Ana" },
"presence": { "state": "composing", "lastSeenAt": "2026-10-17T12:00:00.000Z" }
}presence.state | Origem (state do widget) |
|---|---|
composing | typing |
paused | paused |
available | online |
unavailable | offline |
available e unavailable só são enviados quando o estado muda; composing no máximo uma vez a cada 3 s por visitante.Tudo aditivo: widgets v2 que ignoram os campos novos continuam funcionando. Ids são os do servidor (messageId do envio, id do polling e do histórico). Só mensagens que já têm id podem ser citadas, editadas, apagadas ou reagidas.
{
"id": 9013, "direction": "out", "text": "Olá Ana!", "...": "campos da v2",
"replyTo": { "id": 9012, "direction": "in", "text": "preview até 140 chars", "mediaType": null, "deleted": false },
"editedAt": "2026-10-17T12:01:00.000Z",
"deleted": false,
"welcome": false,
"reactions": [ { "by": "agent", "emoji": "👍" }, { "by": "visitor", "emoji": "❤️" } ]
}replyTo: mensagem citada ou null. Renderize como bloco de citação dentro da bolha; clicar rola até a original, se estiver carregada. direction: "in" é o visitante ("Você"), "out" é o atendente.editedAt preenchido: mostre "editada".deleted: true: text, mediaUrl, mimeType, fileName e replyTo vêm null. Mostre "Mensagem apagada" (sem mídia nem ações).reactions: no máximo uma por lado (agent e visitor); renderize como pílulas sob a bolha.welcome: true: saudação oficial (veja abaixo).{
"success": true,
"messages": [ ... ],
"updates": [ { "id": 9010, "...": "mensagem completa" } ],
"cursor": "2026-10-17T12:01:03.000Z",
"agentTyping": false,
"receipts": { "lastReadMessageId": 9012 },
"availability": { ... }
}messages: respostas novas (id > lastMessageId).updates: mensagens dos dois lados alteradas depois de since (edição, exclusão, reação, status), máx. 100. Mescle na mensagem local de mesmo id; o merge precisa ser idempotente (o cursor tem 2 s de sobreposição). Sem since (primeiro poll) vem []. Se o id não existe localmente, insira a mensagem em ordem de id (pode ser uma resposta que ainda estava sendo processada, como um anexo lento, quando outra mais nova foi entregue).cursor: guarde (junto do histórico local) e envie em since no próximo poll. É opaco para o cliente.agentTyping: true enquanto um atendente digita (expira em 10 s). Mostre uma bolha "digitando..." no fim da lista e esconda quando virar false ou quando chegar mensagem nova. O servidor devolve false quando a mesma resposta traz messages novas.messages passam de sent para delivered e o sistema de atendimento recebe message.status = delivered.No POST /api/webhooks/webchat/:token envie "replyToId": 9013 (id de qualquer mensagem da mesma conversa). Id inexistente ou de outra conversa: 400 INVALID_REPLY_TO. O atendimento recebe message.rawData.context = { "id": "<whatsappMessageId da citada>" }.
Header x-api-key; :messageId é o id do servidor.
| Ação | Rota | Corpo | Regras |
|---|---|---|---|
| Editar | PATCH /api/webchat/messages/:token/:visitorId/:messageId | { "text": "novo" } | Só do próprio visitante, não apagada, com texto, até 15 min. 1 a 4096 caracteres. |
| Apagar | DELETE /api/webchat/messages/:token/:visitorId/:messageId | - | Só do próprio visitante, até 48 h. Confirme com o usuário antes (sem window.confirm em widgets embutidos). |
| Reagir | POST /api/webchat/messages/:token/:visitorId/:messageId/reaction | { "emoji": "👍" } | Qualquer mensagem não apagada. "" remove. Até 16 caracteres. Tocar na própria reação atual deve removê-la. |
Resposta 200: { "success": true, "message": { ...formato público } }; substitua a mensagem local pela devolvida. Erros: 404 MESSAGE_NOT_FOUND, 403 NOT_OWN_MESSAGE, 409 MESSAGE_DELETED, 409 EDIT_WINDOW_EXPIRED, 409 DELETE_WINDOW_EXPIRED, 400 INVALID_TEXT, 400 INVALID_EMOJI. O widget oficial mostra um aviso curto para cada um.
Repasse ao sistema de atendimento: edição chega como message.received com message.type = "edit" (rawData.edit.original_message_id), exclusão como type = "revoke" e reação como event: "message.reaction" (reaction.emoji = null quando removida).
{ "lastMessageId": 9014 } // 200: { "success": true, "updated": 2 }Marca como read as respostas da empresa com id <= lastMessageId (máx. 100 por chamada) e avisa o atendimento (message.status = read). Chame quando o painel estiver aberto, a aba visível (document.visibilityState === "visible") e houver resposta nova renderizada, no máximo uma vez a cada 2 s, também ao abrir o painel e ao voltar para a aba. Erros devem ser silenciosos (best effort).
config.welcomeMessage pode ser null/vazio: nesse caso não exiba nenhuma saudação. Quando definida, mostre-a localmente como prévia antes da conversa começar. Ao receber a primeira mensagem do visitante, o hub cria uma mensagem de saída (id menor que a do visitante) com "welcome": true, entregue pelo polling normal (lastMessageId=0) ou pelo histórico. Ao receber uma mensagem com welcome: true, descarte a prévia local para a saudação não aparecer duas vezes, mantendo a ordem do servidor (saudação primeiro). Ao restaurar o histórico com a saudação oficial, não monte a prévia.
Com o token da empresa; token é o da conexão e to é o visitorId.
| Ação | Rota | Corpo |
|---|---|---|
| Digitando | POST /api/messages/sendPresence | { "token", "to", "state": "composing" | "recording" | "paused" } (vale 10 s; reenvie a cada ~5 s) |
| Editar | POST /api/messages/edit | { "token", "to", "messageId", "text" } |
| Apagar | POST /api/messages/delete | { "token", "to", "messageId" } |
| Reagir | POST /api/messages/sendReaction | { "token", "to", "reactionMessageId", "reactionEmoji" } ("" remove) |
| Responder | sendText / sendMedia | context: { "message_id": "<wid da citada>" } |
Editar e apagar só valem para mensagens da empresa (direction: out). Reação e resposta no webchat não geram mensagem nova: a reação vai para reactions da mensagem alvo e a citação para replyTo.
Se preferir uma interface própria, o ciclo é: gerar visitorId, enviar mensagens,
fazer polling, e opcionalmente anexos e presença.
# 1) configuração
curl "https://sua-api.com.br/api/webchat/widget/SEU_TOKEN"
# 2) enviar mensagem
curl -X POST "https://sua-api.com.br/api/webhooks/webchat/SEU_TOKEN" \
-H "Content-Type: application/json" -H "x-api-key: wc_SUA_CHAVE" \
-d '{"visitorId":"visitor_3f9c1a7e5b2d4c8091a6e3f7d2b4c5a8","visitorName":"Ana","message":"Olá"}'
# 3) buscar respostas
curl "https://sua-api.com.br/api/webchat/messages/SEU_TOKEN/visitor_3f9c1a7e5b2d4c8091a6e3f7d2b4c5a8?lastMessageId=0" \
-H "x-api-key: wc_SUA_CHAVE"const API = 'https://sua-api.com.br';
const TOKEN = 'SEU_TOKEN';
const KEY = 'wc_SUA_CHAVE';
const headers = { 'x-api-key': KEY, 'Content-Type': 'application/json' };
// visitorId com 128 bits, guardado por conexão
function getVisitorId() {
const k = 'wc_visitor_' + TOKEN;
let id = localStorage.getItem(k);
if (!id) {
const b = crypto.getRandomValues(new Uint8Array(16));
id = 'visitor_' + Array.from(b, x => x.toString(16).padStart(2, '0')).join('');
localStorage.setItem(k, id);
}
return id;
}
async function sendText(text, name) {
const res = await fetch(`${API}/api/webhooks/webchat/${TOKEN}`, {
method: 'POST', headers,
body: JSON.stringify({ visitorId: getVisitorId(), visitorName: name, message: text,
clientMessageId: 'c_' + Date.now() })
});
return res.json(); // { ok, messageId, clientMessageId, ... }
}
async function sendFile(file, caption) {
const fd = new FormData();
fd.append('file', file);
const up = await fetch(`${API}/api/webchat/upload/${TOKEN}`, {
method: 'POST', headers: { 'x-api-key': KEY }, body: fd // não defina Content-Type
}).then(r => r.json());
return fetch(`${API}/api/webhooks/webchat/${TOKEN}`, {
method: 'POST', headers,
body: JSON.stringify({ visitorId: getVisitorId(), message: caption || '', attachment: up.attachment })
}).then(r => r.json());
}
let last = 0;
async function poll() {
const res = await fetch(`${API}/api/webchat/messages/${TOKEN}/${getVisitorId()}?lastMessageId=${last}`,
{ headers: { 'x-api-key': KEY } });
const data = await res.json();
for (const m of data.messages || []) {
last = Math.max(last, m.id);
// renderize com textContent, nunca com innerHTML
}
// data.receipts.lastReadMessageId => marque como lidas as suas mensagens com messageId <= esse valor
setTimeout(poll, 3000);
}
poll();Pause o polling com a página oculta (visibilitychange), use backoff
exponencial em erro (até 60 s), deduplique mensagens por id, valide tipo e tamanho do
arquivo antes do upload e nunca insira texto do servidor com innerHTML.
| Item | Limite |
|---|---|
| Tamanho de anexo | 16 MB por padrão (upload.maxSizeBytes na configuração) |
| Tipos aceitos | Lista em upload.accept: imagens (JPEG, PNG, GIF, WebP), vídeo (MP4, WebM, QuickTime), áudio (MP3, MP4, OGG, WebM, WAV, AAC, M4A), PDF, Word, Excel, PowerPoint, TXT, CSV e ZIP. SVG e HTML são recusados. |
| Rate limit de upload | 30 uploads a cada 10 minutos por conexão e IP (429) |
| Texto da mensagem | 4096 caracteres |
| Polling | Máx. 50 mensagens por chamada; histórico de 1 a 100 por página |
| Histórico local do widget | 200 mensagens por token |
x-api-key é pública. Ela aparece no HTML do seu site e identifica a conexão, não autentica o visitante. Não reutilize essa chave para nada sensível.visitorId é o segredo da conversa. Quem o conhece lê o histórico. Por isso ele deve ter 128 bits de aleatoriedade: visitor_ + 32 hexadecimais de crypto.getRandomValues. Não use Date.now() nem Math.random(), não o exponha em URLs, logs ou analytics.attachment.url que não tenha sido emitida pelo upload da própria conexão (400 INVALID_ATTACHMENT).textContent) e links só com http(s), rel="noopener noreferrer".visitorId vazou, "Encerrar conversa" gera um novo e apaga o histórico local.visitorId no formato antigo (visitor_<timestamp>_<aleatório>) continua aceito. Widgets novos devem gerar o formato de 128 bits./api/webhooks/webchat/:token; a rota antiga /api/webhook/webchat/:token (singular) continua respondendo, mas está deprecated; use o plural.visitorId antigo guardado em localStorage (webchat_visitor) não é lido pelo widget novo, que cria uma nova identidade por conexão.receipts) e disponibilidade (availability) chegam como campos extras no polling, sem quebrar clientes antigos.Confira no console do navegador se há o aviso [HubWebchat]. Causas comuns:
data-token ou data-api-key ausentes, token incorreto
(a rota de configuração responde 404) ou script bloqueado por CSP. Se o seu site usa Content-Security-Policy, libere o domínio do hub em
script-src, connect-src (e img-src/media-src para anexos).
A chave não corresponde ao token informado. Copie novamente o snippet nas configurações da conexão.
Verifique o modo (availabilityMode) e o fuso (timezone) em
businessHours. O modo offline força offline mesmo dentro do horário.
Mesmo offline o visitante pode enviar mensagens.
Responda usando o visitorId do chat como destinatário. O widget só busca mensagens com polling
(a cada 3 s com o chat aberto e 15 s fechado) e pausa quando a aba está oculta; ao voltar para a aba, atualiza na hora.
A leitura depende do atendente abrir o ticket e o sistema chamar POST /api/messages/markAsRead.
O widget só marca como lida a mensagem com messageId menor ou igual a
receipts.lastReadMessageId.
413: arquivo acima do limite; 415: tipo não aceito;
429: aguarde alguns minutos. Se estiver atrás de proxy, confira o limite de corpo
(client_max_body_size no nginx) e que o domínio dos anexos é acessível pelo visitante.
O visitorId fica no localStorage de cada navegador. Outro
navegador ou aparelho é outro visitante. Sem storage (modo privado restrito) o widget funciona, mas a conversa não sobrevive ao recarregamento.
Não. O widget carrega uma única vez por página; um segundo script é ignorado.
Cor, título, avatar, posição e mensagens vêm do painel (ou de data-*). O CSS está encapsulado em Shadow DOM, por isso não é sobrescrito por estilos do site.