Caixa de entrada em tempo real
Os dois WebSockets por trás da caixa de entrada compartilhada e do chat da loja virtual. URLs, autenticação, todos os eventos, heartbeats, reconexões e como recuperar o que ficou para trás via REST.
A caixa de entrada compartilhada (WhatsApp, e-mail e chat da loja virtual) envia as mudanças por WebSockets. Existem dois sockets:
| Socket | Quem se conecta | Escopo |
|---|---|---|
/ws/conversations | Membros da equipe: o painel, os apps mobile, as suas ferramentas | Todas as conversas de uma loja |
/ws/widget | O navegador de um cliente (o widget de chat) | Uma conversa |
O OpenAPI não consegue descrever WebSockets, por isso eles são documentados
aqui e não na referência da Admin API. Tudo o que você
pode fazer em um socket, além de typing e viewing, você faz via REST; o
socket só avisa que algo mudou.
Os dois sockets respondem na raiz e em /v1:
wss://api.mobiari.com/v1/ws/conversations
wss://api.mobiari.com/v1/ws/widgetTodos os frames são frames de texto JSON.
Socket do agente: /ws/conversations
Autenticação
Escolha uma opção:
Bearer token (apps nativos, servidores). Envie o mesmo header
Authorization de qualquer chamada à API e informe a loja:
GET /v1/ws/conversations?store_id=0199a0c4-...
Authorization: Bearer <session access token or mobi_st_ store API token>Quem chama precisa da permissão conversations.read nessa loja.
Ticket (navegadores). Navegadores não conseguem definir headers em um WebSocket, então primeiro trocam o token por um ticket de uso único válido por um minuto:
POST /v1/stores/{store_id}/conversations/ws-ticket
Authorization: Bearer <token>{ "ticket": "9f1c...e2", "expires_in": 60 }wss://api.mobiari.com/v1/ws/conversations?ticket=9f1c...e2Obtenha um novo ticket a cada tentativa de conexão.
Handshakes que falham respondem com um status HTTP e o corpo de erro de sempre:
| Status | code | Significado |
|---|---|---|
| 400 | store_id_required | Bearer token sem ?store_id= |
| 401 | unauthorized / invalid_ticket | Credenciais ausentes ou expiradas, ou um ticket já usado |
| 403 | missing_permission | Sem conversations.read nessa loja |
A sessão continua aberta depois que o access token que a abriu expira. A próxima reconexão precisa de um token novo.
Eventos do servidor
Depois do upgrade, o servidor envia hello:
{ "type": "hello", "store_id": "0199a0c4-...", "user_id": "0199a0c5-..." }Todos os outros eventos têm o mesmo envelope:
{
"type": "message:new",
"store_id": "0199a0c4-...",
"conversation_id": "0199b1d2-...",
"audience": "all",
"data": { },
"origin": "0199a000-..."
}audience e origin são detalhes internos de roteamento; ignore-os. Tipos
de evento (outros podem ser adicionados: ignore os desconhecidos):
type | data | Quando |
|---|---|---|
conversation:new | um ConversationListItem (uma linha da caixa de entrada: a Conversation mais display_name, assignee_name, customer_name, waiting_since) | Uma nova conversa recebeu a primeira mensagem. Enviado uma única vez, logo antes do message:new dessa mensagem, então last_message_preview e display_name já vêm preenchidos |
conversation:updated | uma Conversation | Status, responsável, contadores de não lidas, etiquetas, última mensagem... mudaram |
message:new | uma ConversationMessage | Qualquer mensagem: do contato, da equipe, do bot ou uma linha do sistema |
message:updated | uma ConversationMessage | O status de entrega mudou (sent → delivered → read, ou failed) |
messages:read | { "conversation_id", "by": "visitor" } | O cliente leu as mensagens da equipe (chat web) |
note:new | uma ConversationNote | Alguém adicionou uma nota interna |
cart:updated | { "conversation_id", "cart": ConversationCart | null } | O carrinho da venda assistida mudou (null: desvinculado) |
typing | { "conversation_id", "from": "visitor" | "agent", "user_id"?, "typing": bool } | Alguém está digitando. Remova o indicador depois de cerca de 6 segundos sem repetição |
viewing | { "conversation_id" | null, "user_id", "user_name", "viewing": bool } | Um membro da equipe abriu (ou saiu de) uma conversa |
presence | { "online": [user_id, ...] } ou { "conversation_id", "visitor_online": bool } | Membros da equipe conectados à caixa de entrada; o chat do cliente foi aberto ou fechado |
resync | nenhum | Você ficou para trás e perdeu eventos: busque tudo de novo (veja abaixo) |
pong | nenhum | Resposta ao seu ping |
conversation:new traz uma linha completa da caixa de entrada: insira-a na
lista como está. Uma conversa que nunca recebe mensagem (um chat web aberto
e abandonado sem ninguém escrever) não gera conversation:new.
conversation:updated traz a Conversation pura, sem o display_name, o
assignee_name e o waiting_since da linha da lista: mescle-a com a linha
que você já tem, ou busque a linha de novo.
Exemplo de message:new para uma mensagem de WhatsApp com foto:
{
"type": "message:new",
"store_id": "0199a0c4-...",
"conversation_id": "0199b1d2-...",
"audience": "all",
"data": {
"id": "0199b1d3-...",
"conversation_id": "0199b1d2-...",
"store_id": "0199a0c4-...",
"sender": "visitor",
"agent_user_id": null,
"text": "Chegou assim",
"payload": { "attachments": [{ "kind": "image", "media_id": "1234", "mime_type": "image/jpeg" }] },
"external_id": "wamid.HBgM...",
"delivery_status": "sent",
"delivery_error": null,
"read_at": null,
"created_at": "2026-09-26T14:03:11.204Z",
"attachments": [
{
"index": 0,
"kind": "image",
"name": null,
"mime_type": "image/jpeg",
"size": null,
"url": "https://api.mobiari.com/v1/stores/0199a0c4-.../conversations/0199b1d2-.../messages/0199b1d3-.../attachments/0",
"path": "/v1/stores/0199a0c4-.../conversations/0199b1d2-.../messages/0199b1d3-.../attachments/0"
}
]
}
}As urls dos anexos exigem o header Authorization, como qualquer chamada
à API. Nos eventos, elas usam o endereço público da API; path é o mesmo
link relativo à origem da API, para clientes que falam com outro host (um
servidor de staging ou local).
Exemplo de message:updated depois que o WhatsApp informa que a mensagem
foi lida:
{
"type": "message:updated",
"conversation_id": "0199b1d2-...",
"data": { "id": "0199b1e0-...", "sender": "agent", "delivery_status": "read", "read_at": "2026-09-26T14:05:40Z", "...": "..." }
}Mensagens para o servidor
| Frame | Efeito |
|---|---|
{ "type": "ping" } | O servidor responde { "type": "pong" } |
{ "type": "typing", "conversation_id": "...", "typing": true } | Mostra ao cliente (chat web) e à equipe que você está digitando. Repita a cada poucos segundos enquanto digita; envie false quando parar |
{ "type": "viewing", "conversation_id": "..." | null, "user_name": "Ana" } | Informa à equipe qual conversa você tem aberta (null quando nenhuma) |
POST /v1/stores/{store_id}/conversations/{conversation_id}/typing faz o
mesmo que o frame typing, só que via HTTP.
Socket do visitante: /ws/widget
O widget de chat abre esse socket assim que o cliente tem uma conversa,
com a chave do canal, o id da conversa e o token de visitante retornados
por POST /widget/{key}/conversations:
wss://api.mobiari.com/v1/ws/widget?key=ch_3b1f...&conversation_id=0199b1d2-...&token=8f8b...O token também pode ir em um header X-Visitor-Token em vez da query. As
origens permitidas do canal se aplicam. O handshake responde 404 (chave ou
conversa desconhecida), 403 (canal desativado ou origem não permitida) ou
401 (token inválido).
O servidor envia os eventos da conversa, sem os internos (notas, presença,
viewing):
{ "type": "message:new", "conversation_id": "0199b1d2-...", "data": { "id": "...", "sender": "agent", "text": "Oi!", "payload": {}, "...": "..." } }type | data |
|---|---|
message:new, message:updated | a mensagem |
conversation:updated | somente { "status", "bot_active", "assignee_user_id", "unread_for_visitor" } |
cart:updated | { "conversation_id", "cart" } |
typing | { "conversation_id", "from", "typing" } (mostre quando from não for visitor) |
resync | nenhum: recarregue as mensagens |
pong | nenhum |
O widget pode enviar { "type": "typing", "typing": true } e
{ "type": "ping" }.
Heartbeats e keep-alive
- O servidor envia um frame de ping do WebSocket a cada 30 segundos.
Navegadores,
URLSessionWebSocketTaske OkHttp respondem com um pong automaticamente. - O servidor fecha uma conexão da qual não recebe nada (nenhum frame, pongs incluídos) por 75 segundos.
- Os clientes também devem enviar
{ "type": "ping" }a cada 25 segundos e considerar a conexão morta se opongnão chegar em 10 segundos. Proxies e redes móveis derrubam conexões TCP ociosas sem avisar; é assim que você percebe. - Apps mobile: feche o socket quando o app for para segundo plano e reconecte quando ele voltar ao primeiro plano; nesse intervalo, conte com as notificações push.
Reconexão
Reconecte sempre que o socket fechar, com backoff exponencial e jitter: 1 s, 2 s, 4 s ... até 30 s, zerando depois de uma conexão que se manteve. Obtenha um novo ticket (navegadores) ou um access token novo (apps) antes de cada tentativa. Um handshake recusado com 401 ou 403 não é temporário: renove o token (ou obtenha um novo ticket) uma vez e depois pare.
Os eventos não são reenviados. O que aconteceu enquanto você estava
desconectado (ou quando o servidor envia resync) você busca via REST:
-
A lista. Busque de novo a primeira página de
GET /v1/stores/{store_id}/conversations(atividade mais recente primeiro) eGET /v1/stores/{store_id}/conversations/countspara os contadores. -
A conversa aberta. Busque o que veio depois da mensagem mais recente que você tem, da mais antiga para a mais nova:
GET /v1/stores/{store_id}/conversations/{conversation_id}/messages?after={last_message_id}&limit=100Continue chamando com o mesmo
aftermaiscursor={next_cursor}enquantohas_morefor true. Sem um id de mensagem como referência, usecreated_after={timestamp}. Mensagens que você já tem podem chegar de novo comomessage:updated(status de entrega): substitua peloid. -
A conversa.
GET /v1/stores/{store_id}/conversations/{conversation_id}para status, responsável, notas e a janela de resposta do WhatsApp.
Elimine duplicatas pelo id em todos os lugares: um evento pode chegar
enquanto a chamada REST que o cobre ainda está em andamento.
Envio e a janela de 24 horas do WhatsApp
As respostas vão via REST, não pelo socket:
POST /v1/stores/{store_id}/conversations/{conversation_id}/messages
Authorization: Bearer <token>
Idempotency-Key: 5d3c7e0a-...
Content-Type: application/json
{ "text": "Olá! Já verifico o seu pedido." }A resposta é 201 com { "message", "conversation" }, e a mesma mensagem
chega como message:new no socket (elimine duplicatas pelo id). Envie um
Idempotency-Key em toda resposta para que uma nova tentativa depois de
uma resposta perdida não envie a mensagem duas vezes.
O WhatsApp só aceita mensagens livres dentro de 24 horas desde a última mensagem do contato. Fora dessa janela, a chamada responde:
{
"error": "The WhatsApp 24-hour reply window is closed: the contact has not written in the last 24 hours",
"code": "whatsapp_window_closed",
"details": { "window_expired_at": "2026-09-25T02:47:25Z" }
}com status 422, e nada é armazenado. O reply_window_expires_at em
GET .../conversations/{conversation_id} informa quando a janela fecha,
para que você possa desativar o campo de mensagem com antecedência.