Realtime inbox
The two WebSockets behind the shared inbox and the storefront chat. URLs, authentication, every event, heartbeats, reconnects and how to catch up over REST.
The shared inbox (WhatsApp, e-mail and storefront chat) pushes changes over WebSockets. There are two sockets:
| Socket | Who connects | Scope |
|---|---|---|
/ws/conversations | Team members: the admin, the mobile apps, your tools | Every conversation of one store |
/ws/widget | A shopper's browser (the chat widget) | One conversation |
OpenAPI cannot describe WebSockets, so they are documented here rather than
in the Admin API reference. Everything you can do on a
socket besides typing and viewing you do over REST; the socket only tells
you that something changed.
Both sockets answer at the root and under /v1:
wss://api.mobiari.com/v1/ws/conversations
wss://api.mobiari.com/v1/ws/widgetAll frames are JSON text frames.
Agent socket: /ws/conversations
Authenticate
Pick one:
Bearer token (native apps, servers). Send the same Authorization
header as any API call and name the store:
GET /v1/ws/conversations?store_id=0199a0c4-...
Authorization: Bearer <session access token or mobi_st_ store API token>The caller needs the conversations.read permission in that store.
Ticket (browsers). Browsers cannot set headers on a WebSocket, so they trade their token for a one-minute, single-use ticket first:
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...e2Get a new ticket for every connection attempt.
Failed handshakes answer with an HTTP status and the usual error body:
| Status | code | Meaning |
|---|---|---|
| 400 | store_id_required | Bearer token without ?store_id= |
| 401 | unauthorized / invalid_ticket | No or expired credentials, or a used ticket |
| 403 | missing_permission | No conversations.read in that store |
The session stays open after the access token that opened it expires. The next reconnect needs a fresh token.
Events from the server
After the upgrade the server sends hello:
{ "type": "hello", "store_id": "0199a0c4-...", "user_id": "0199a0c5-..." }Every other event has the same envelope:
{
"type": "message:new",
"store_id": "0199a0c4-...",
"conversation_id": "0199b1d2-...",
"audience": "all",
"data": { },
"origin": "0199a000-..."
}audience and origin are internal routing details; ignore them. Event
types (more may be added: ignore unknown ones):
type | data | When |
|---|---|---|
conversation:new | a ConversationListItem (an inbox row: the Conversation plus display_name, assignee_name, customer_name, waiting_since) | A new conversation got its first message. Sent once, just before that message's message:new, so last_message_preview and display_name are already filled |
conversation:updated | a Conversation | Status, assignee, unread counts, labels, last message... changed |
message:new | a ConversationMessage | Any message: contact, team, bot or system line |
message:updated | a ConversationMessage | Delivery status changed (sent → delivered → read, or failed) |
messages:read | { "conversation_id", "by": "visitor" } | The shopper read the team's messages (web chat) |
note:new | a ConversationNote | Someone added an internal note |
cart:updated | { "conversation_id", "cart": ConversationCart | null } | The assisted-sale cart changed (null: detached) |
typing | { "conversation_id", "from": "visitor" | "agent", "user_id"?, "typing": bool } | Someone is typing. Clear the indicator after about 6 seconds without a repeat |
viewing | { "conversation_id" | null, "user_id", "user_name", "viewing": bool } | A team member opened (or left) a conversation |
presence | { "online": [user_id, ...] } or { "conversation_id", "visitor_online": bool } | Team members connected to the inbox; the shopper's chat opened or closed |
resync | none | You fell behind and missed events: refetch (see below) |
pong | none | Answer to your ping |
conversation:new carries a whole inbox row: insert it into the list as
is. A conversation that never gets a message (a web chat opened and left
without writing) sends no conversation:new. conversation:updated
carries the bare Conversation, without the list row's display_name,
assignee_name and waiting_since: merge it into the row you have, or
refetch the row.
Example message:new for a WhatsApp message with a photo:
{
"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"
}
]
}
}Attachment urls need the Authorization header, like any API call. In
events they use the API's public address; path is the same link relative
to the API origin, for clients that talk to another host (a staging or
local server).
Example message:updated after WhatsApp reports the message read:
{
"type": "message:updated",
"conversation_id": "0199b1d2-...",
"data": { "id": "0199b1e0-...", "sender": "agent", "delivery_status": "read", "read_at": "2026-09-26T14:05:40Z", "...": "..." }
}Messages to the server
| Frame | Effect |
|---|---|
{ "type": "ping" } | The server answers { "type": "pong" } |
{ "type": "typing", "conversation_id": "...", "typing": true } | Shows the shopper (web chat) and the team that you are typing. Repeat every few seconds while typing; send false when you stop |
{ "type": "viewing", "conversation_id": "..." | null, "user_name": "Ana" } | Tells the team which conversation you have open (null when none) |
POST /v1/stores/{store_id}/conversations/{conversation_id}/typing does the
same as the typing frame over HTTP.
Visitor socket: /ws/widget
The chat widget opens it once the shopper has a conversation, with the
channel key, the conversation id and the visitor token from
POST /widget/{key}/conversations:
wss://api.mobiari.com/v1/ws/widget?key=ch_3b1f...&conversation_id=0199b1d2-...&token=8f8b...The token can also go in an X-Visitor-Token header instead of the query.
The channel's allowed origins apply. The handshake answers 404 (unknown key
or conversation), 403 (disabled channel or origin not allowed) or 401 (bad
token).
The server sends the conversation's events, without internal ones (notes,
presence, viewing):
{ "type": "message:new", "conversation_id": "0199b1d2-...", "data": { "id": "...", "sender": "agent", "text": "Oi!", "payload": {}, "...": "..." } }type | data |
|---|---|
message:new, message:updated | the message |
conversation:updated | { "status", "bot_active", "assignee_user_id", "unread_for_visitor" } only |
cart:updated | { "conversation_id", "cart" } |
typing | { "conversation_id", "from", "typing" } (show it when from is not visitor) |
resync | none: reload the messages |
pong | none |
The widget may send { "type": "typing", "typing": true } and
{ "type": "ping" }.
Heartbeats and keep-alive
- The server sends a WebSocket ping frame every 30 seconds. Browsers,
URLSessionWebSocketTaskand OkHttp answer with a pong automatically. - The server closes a connection it has heard nothing from (no frame, pongs included) for 75 seconds.
- Clients should also send
{ "type": "ping" }every 25 seconds and treat a missingpongwithin 10 seconds as a dead connection. Proxies and mobile networks drop idle TCP connections silently; this is how you notice. - Mobile apps: close the socket when the app goes to the background and reconnect in the foreground; rely on push notifications in between.
Reconnecting
Reconnect whenever the socket closes, with exponential backoff and jitter: 1 s, 2 s, 4 s ... up to 30 s, reset after a connection that lasted. Get a new ticket (browsers) or a fresh access token (apps) before each attempt. A handshake refused with 401 or 403 is not transient: refresh the token (or get a new ticket) once, then stop.
Events are not replayed. Whatever happened while you were disconnected
(or when the server sends resync) you fetch over REST:
-
The list. Refetch the first page of
GET /v1/stores/{store_id}/conversations(newest activity first) andGET /v1/stores/{store_id}/conversations/countsfor badges. -
The open thread. Fetch what came after the newest message you have, oldest first:
GET /v1/stores/{store_id}/conversations/{conversation_id}/messages?after={last_message_id}&limit=100Keep calling with the same
afterpluscursor={next_cursor}whilehas_moreis true. Without a message id to anchor on, usecreated_after={timestamp}instead. Messages you already have may come again asmessage:updated(delivery status): replace byid. -
The conversation.
GET /v1/stores/{store_id}/conversations/{conversation_id}for status, assignee, notes and the WhatsApp reply window.
Deduplicate by id everywhere: an event can arrive while the REST call
that covers it is in flight.
Sending and the WhatsApp 24-hour window
Replies go over REST, not the 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." }It answers 201 with { "message", "conversation" }, and the same message
arrives as message:new on the socket (deduplicate by id). Send an
Idempotency-Key with every reply so a retry after a lost response does
not send it twice.
WhatsApp only accepts free-form messages within 24 hours of the contact's last message. Outside that window the call answers:
{
"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" }
}with status 422, and nothing is stored. reply_window_expires_at on
GET .../conversations/{conversation_id} says when the window closes, so
you can disable the composer ahead of time.