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
chatUrlfrom 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:
sdkVersionandplatformquery 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"
}| Claim | Meaning |
|---|---|
sub | The user. Signed, so it can't be changed. |
env | The environment. Signed, so a dev token can't reach production. Old tokens without env count as development. |
cvs | The conversations this token can use. |
scopes | What the user can do. |
aud | Always 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.
| Code | Meaning | Reconnect? |
|---|---|---|
1000 | Normal closure | No. It was on purpose. |
4401 | Authentication failed | No. Retrying a bad token won't help. |
4403 | Origin not allowed | No |
4429 | Too many connections | Yes, after waiting |
4440 | Token expired | Yes, with a new token |
4500 | Server restarting | Yes, 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 anackorerrorwith the sameid. That's how you match replies to requests.
Client → server
| Type | Fields | Notes |
|---|---|---|
room.join | room | Access is checked here |
room.leave | room | |
message.send | room, text, messageType?, replyTo?, clientMessageId?, attachmentId?, metadata?, mentions? | mentions: up to 50 user ids |
message.update | messageId, text | Author only |
message.delete | messageId | Author, or chat:moderate |
reaction.add / reaction.remove | messageId, emoji | Safe to repeat |
typing.start / typing.stop | room | |
read.mark | messageId | Marks this message and all before it |
presence.set | status | online, away, offline |
ping | — | Optional check from your side; the server answers pong |
moderation.ban / moderation.unban / moderation.mute / moderation.unmute | room, userId, durationSeconds?, reason? | MODERATOR or ADMIN of that conversation. See Moderation |
Server → client
| Type | Carries |
|---|---|
connected | connectionId, userId, scopes, expiresAt, heartbeatIntervalMs |
ack | id, ok, data |
error | id?, code, message, retryAfterSeconds?, reason? (on MESSAGE_BLOCKED), scope? ("conversation" on the per-conversation send cap) |
room.joined | room, name, presence[], typing[], mode, viewer, lastSeq, eventSeq, online, presenceTruncated? |
room.left | room, reason? ("BANNED" when a ban removed you) |
message / message.updated | message; message.updated also has reason ("edit" or "link_preview") |
message.deleted | messageId, roomId, deletedAt, deletedBy |
message.pinned / message.unpinned | room, messageId, message, by, at |
reaction.added / reaction.removed | messageId, roomId, userId, emoji, at |
member.joined / member.left | roomId, userId, role, at |
member.banned / member.unbanned / member.muted / member.unmuted | roomId, userId, by, expiresAt? |
typing.started / typing.stopped | roomId, userId |
presence | roomId, userId, status, at |
presence.count | room, online. Only in rooms above the large-presence threshold (default 200), at most every 5 s |
read | roomId, userId, messageId, seq, at |
inbox.updated | See below. No room join needed |
pong | id? |
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:
connected: the token was accepted.heartbeatIntervalMsis how often the server checks the connection is alive (see the checklist below).room.joined: Alice is now subscribed to the conversation, and gets who's online and typing right now.ack: the message is saved.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
chatUrlwith?token=. - Wait for
connectedbefore sending anything. - Send
room.joinfor 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
pingframe is optional, for checking the connection from your side. - Give every
message.sendaclientMessageId, and reuse it on retries. - Reconnect on
4429,4440(with a new token), and4500, with growing waits. Stop on4401and4403. - 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
codeanerrorframe can carry.