livqenoDocs

ChatWebSocket Protocol

WebSocket Protocol

The raw chat protocol, for building your own client or debugging.

This page describes the messages ("frames") that travel between a chat client and Livqeno over a WebSocket, a long-lived two-way connection that lets the server push messages to the app instantly.

Most people don't need this page. @livqeno/chat and the other chat SDKs handle the protocol for you, including reconnecting, catching up, and retries. The SDK is the supported way to use chat.

Read this if you:

  • are building a client in a language without a Livqeno SDK (say, a Rust or Swift-only app), or
  • want to understand the frames you see in your browser's devtools (Network → WS) while debugging.

Where this fits: your backend makes a chat token as usual. Instead of passing it to an SDK, your own code opens the WebSocket below and speaks these frames.

Endpoint

wss://api.livqeno.com/v1/chat/ws?token=<chat token>
  • Use the chatUrl from your chat token response as the address. It already points at the right host and ends in /v1/chat/ws. Don't hard-code it.
  • Always use wss:// in production. ws:// is for local development only.
  • Optional: sdkVersion and platform query parameters. They help with debugging and change nothing.

Authentication

The token goes in the URL, because browsers can't set headers on a WebSocket connection.

URLs can end up in logs, so chat tokens are made to be safe there:

  • Short-lived: 1 hour by default, 6 hours at most. No tokens that never expire.
  • Revocable: checked every time you connect.
  • Limited: one user, and optionally only some conversations.

Token claims

A chat token is a signed JWT: a small JSON object, plus a signature that proves Livqeno made it. You can decode it to read these claims, but you can't change them without breaking the signature.

{
  "jti": "ctk_7Qd2nF...",
  "sub": "user-123",
  "pid": "<project uuid>",
  "env": "PRODUCTION",
  "cvs": ["<conversation uuid>"],
  "scopes": ["chat:read", "chat:send"],
  "iat": 1787054953,
  "exp": 1787058553,
  "aud": "raven-chat",
  "iss": "raven"
}
ClaimMeaning
subThe user. Signed, so it can't be changed.
envThe environment. Signed, so a dev token can't reach production. Old tokens without env count as development.
cvsThe conversations this token can use.
scopesWhat the user can do.
audAlways raven-chat. A dashboard login or an RTC token won't work here.

Origin

Browsers always send an Origin header (the site's address, like https://app.example.com), and page code can't fake it.

  • If it's not on your allowed list, the connection closes with 4403.
  • If there's no Origin (non-browser clients), it's allowed.

Close codes

When the server closes the connection, the close code tells you why, and whether reconnecting can help. Chat uses codes from 4000 to 4999. They differ from RTC's codes, so you can tell which one closed.

CodeMeaningReconnect?
1000Normal closureNo. It was on purpose.
4401Authentication failedNo. Retrying a bad token won't help.
4403Origin not allowedNo
4429Too many connectionsYes, after waiting
4440Token expiredYes, with a new token
4500Server restartingYes, after waiting. It's a deploy, not an error.

@livqeno/chat stops on 4401 and 4403 and reports failed, instead of retrying forever. Your own client should do the same, and wait longer between each retry (for example 1s, 2s, 4s…) for the codes that allow one.

Frame format

  • Every frame is a JSON object with a type.
  • Fields are flat, so frames are easy to read in devtools.
  • If you send a frame with an id, you get back an ack or error with the same id. That's how you match replies to requests.

Client → server

TypeFieldsNotes
room.joinroomAccess is checked here
room.leaveroom
message.sendroom, text, messageType?, replyTo?, clientMessageId?, attachmentId?, metadata?, mentions?mentions: up to 50 user ids
message.updatemessageId, textAuthor only
message.deletemessageIdAuthor, or chat:moderate
reaction.add / reaction.removemessageId, emojiSafe to repeat
typing.start / typing.stoproom
read.markmessageIdMarks this message and all before it
presence.setstatusonline, away, offline
ping—Optional check from your side; the server answers pong
moderation.ban / moderation.unban / moderation.mute / moderation.unmuteroom, userId, durationSeconds?, reason?MODERATOR or ADMIN of that conversation. See Moderation

Server → client

TypeCarries
connectedconnectionId, userId, scopes, expiresAt, heartbeatIntervalMs
ackid, ok, data
errorid?, code, message, retryAfterSeconds?, reason? (on MESSAGE_BLOCKED), scope? ("conversation" on the per-conversation send cap)
room.joinedroom, name, presence[], typing[], mode, viewer, lastSeq, eventSeq, online, presenceTruncated?
room.leftroom, reason? ("BANNED" when a ban removed you)
message / message.updatedmessage; message.updated also has reason ("edit" or "link_preview")
message.deletedmessageId, roomId, deletedAt, deletedBy
message.pinned / message.unpinnedroom, messageId, message, by, at
reaction.added / reaction.removedmessageId, roomId, userId, emoji, at
member.joined / member.leftroomId, userId, role, at
member.banned / member.unbanned / member.muted / member.unmutedroomId, userId, by, expiresAt?
typing.started / typing.stoppedroomId, userId
presenceroomId, userId, status, at
presence.countroom, online. Only in rooms above the large-presence threshold (default 200), at most every 5 s
readroomId, userId, messageId, seq, at
inbox.updatedSee below. No room join needed
pongid?

Every message carries seq, a string: its position in the conversation, gapless. Message, edit, delete, reaction, pin, membership and moderation frames also carry eventSeq, a string that increases per conversation. Two concurrent events can arrive slightly out of order. If you see a gap, wait briefly, then fetch GET /v1/chat/conversations/:room/events?afterEventSeq=N (kept 7 days; older answers 410 EVENTS_EXPIRED). Typing, presence and read frames have no eventSeq.

inbox.updated

Sent to every connection of a member, without a room.join, when a message lands in one of their conversations (with lastMessage) or their read position, mute or notification level changes (without it):

{ "type": "inbox.updated", "conversationId": "conv_…",
  "lastMessage": { "id": "msg_…", "seq": "42", "senderId": "bob", "content": "hi", "type": "text", "createdAt": "…" },
  "unreadCount": 3, "unreadMentions": 1, "lastReadSeq": "39",
  "muted": false, "notifications": "all", "at": "…" }

The counts are this user's own, capped at 999. It isn't sent for live-stream audience chats or for conversations with more than 1,000 members. After a reconnect, re-read GET /v1/chat/me/conversations.

A full exchange

This is what Alice's client sends (→) and receives (←) when she connects, joins a conversation, and sends one message:

→  (upgrade with ?token=…)
←  {"type":"connected","connectionId":"ccn_8Kd…","userId":"alice",
    "scopes":["chat:read","chat:send"],"expiresAt":"2026-08-18T13:19:49Z",
    "heartbeatIntervalMs":25000}

→  {"type":"room.join","id":"j1","room":"conv_9WcQ…"}
←  {"type":"room.joined","id":"j1","room":"conv_9WcQ…","name":"support",
    "presence":[{"userId":"bob","status":"online"}],"typing":[]}

→  {"type":"message.send","id":"m1","room":"conv_9WcQ…",
    "text":"Hello everyone!","clientMessageId":"client_1"}
←  {"type":"ack","id":"m1","ok":true,"data":{
    "status":"stored","deduplicated":false,
    "message":{"id":"msg_3xR…","senderId":"alice","createdAt":"…"}}}
←  {"type":"message","message":{"id":"msg_3xR…", ...}}

Step by step:

  1. connected: the token was accepted. heartbeatIntervalMs is how often the server checks the connection is alive (see the checklist below).
  2. room.joined: Alice is now subscribed to the conversation, and gets who's online and typing right now.
  3. ack: the message is saved.
  4. message: the delivery to everyone, including the sender. So everyone shows the same message in the same order.

Building your own client: checklist

  • Connect to chatUrl with ?token=.
  • Wait for connected before sending anything.
  • Send room.join for each conversation. Without it you receive nothing.
  • Answer the server's WebSocket-level pings. Standard WebSocket libraries and browsers do this automatically. A connection that misses one is closed. The ping frame is optional, for checking the connection from your side.
  • Give every message.send a clientMessageId, and reuse it on retries.
  • Reconnect on 4429, 4440 (with a new token), and 4500, with growing waits. Stop on 4401 and 4403.
  • After reconnecting, load what you missed with Message History. The SDKs do this for you; a raw client must do it itself.
  • Ignore frame types you don't know.

Next steps

  • Chat Events: how the SDK turns these frames into events.
  • Errors: every code an error frame can carry.