MobiariDocs

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:

SocketWho connectsScope
/ws/conversationsTeam members: the admin, the mobile apps, your toolsEvery conversation of one store
/ws/widgetA 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/widget

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

Get a new ticket for every connection attempt.

Failed handshakes answer with an HTTP status and the usual error body:

StatuscodeMeaning
400store_id_requiredBearer token without ?store_id=
401unauthorized / invalid_ticketNo or expired credentials, or a used ticket
403missing_permissionNo 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):

typedataWhen
conversation:newa 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:updateda ConversationStatus, assignee, unread counts, labels, last message... changed
message:newa ConversationMessageAny message: contact, team, bot or system line
message:updateda ConversationMessageDelivery status changed (sent → delivered → read, or failed)
messages:read{ "conversation_id", "by": "visitor" }The shopper read the team's messages (web chat)
note:newa ConversationNoteSomeone 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
resyncnoneYou fell behind and missed events: refetch (see below)
pongnoneAnswer 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

FrameEffect
{ "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": {}, "...": "..." } }
typedata
message:new, message:updatedthe 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)
resyncnone: reload the messages
pongnone

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, URLSessionWebSocketTask and 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 missing pong within 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:

  1. The list. Refetch the first page of GET /v1/stores/{store_id}/conversations (newest activity first) and GET /v1/stores/{store_id}/conversations/counts for badges.

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

    Keep calling with the same after plus cursor={next_cursor} while has_more is true. Without a message id to anchor on, use created_after={timestamp} instead. Messages you already have may come again as message:updated (delivery status): replace by id.

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

On this page