MobiariDocs

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:

SocketQuem se conectaEscopo
/ws/conversationsMembros da equipe: o painel, os apps mobile, as suas ferramentasTodas as conversas de uma loja
/ws/widgetO 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/widget

Todos 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...e2

Obtenha um novo ticket a cada tentativa de conexão.

Handshakes que falham respondem com um status HTTP e o corpo de erro de sempre:

StatuscodeSignificado
400store_id_requiredBearer token sem ?store_id=
401unauthorized / invalid_ticketCredenciais ausentes ou expiradas, ou um ticket já usado
403missing_permissionSem 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):

typedataQuando
conversation:newum 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:updateduma ConversationStatus, responsável, contadores de não lidas, etiquetas, última mensagem... mudaram
message:newuma ConversationMessageQualquer mensagem: do contato, da equipe, do bot ou uma linha do sistema
message:updateduma ConversationMessageO 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:newuma ConversationNoteAlgué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
resyncnenhumVocê ficou para trás e perdeu eventos: busque tudo de novo (veja abaixo)
pongnenhumResposta 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

FrameEfeito
{ "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": {}, "...": "..." } }
typedata
message:new, message:updateda mensagem
conversation:updatedsomente { "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)
resyncnenhum: recarregue as mensagens
pongnenhum

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, URLSessionWebSocketTask e 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 o pong nã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:

  1. A lista. Busque de novo a primeira página de GET /v1/stores/{store_id}/conversations (atividade mais recente primeiro) e GET /v1/stores/{store_id}/conversations/counts para os contadores.

  2. 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=100

    Continue chamando com o mesmo after mais cursor={next_cursor} enquanto has_more for true. Sem um id de mensagem como referência, use created_after={timestamp}. Mensagens que você já tem podem chegar de novo como message:updated (status de entrega): substitua pelo id.

  3. 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.

Nesta página