API — WebSocket
Real-time events are delivered over a single WebSocket per client, served by the gateway on the same port as the REST API.
Connecting
ws(s)://<host>/connect?api_key=<lck_...>&token=<user JWT>[&last_event_id=<ulid>]
Authentication happens during the upgrade via query parameters: api_key resolves the app (suspension enforced), token is the user JWT (or dev token for dev-mode apps). Upgrade requests to any other path are destroyed.
On auth failure the gateway sends a single local (non-protocol) frame and closes with code 4001:
{ "type": "connection.error", "error": { "message": "invalid user token: ..." } }
On success the first frame is a health.check carrying your connection id and unread state:
{
"type": "health.check",
"connection_id": "conn_9f2...",
"me": {
"id": "ada", "name": "Ada",
"total_unread_count": 3,
"unread_channels": 2,
"channel_reads": [ { "user_id": "ada", "cid": "messaging:general", "last_read_message_id": "01J...", "unread_count": 2 } ]
},
"created_at": "2026-07-08T12:00:00.000Z"
}
Keep connection_id — REST calls (watch, stop-watching, queryChannels?watch=true) use it to subscribe/unsubscribe this socket to channels. Connecting also marks the user active (presence hash online:<appId>, last_active_at, MAU tracking).
Frames the client may send
The gateway accepts exactly one inbound frame type; everything else is ignored:
{ "type": "health.check" }
The server answers with a health.check (echoing connection_id). The SDKs send this every 25 seconds as an application-level keepalive. The server also pings at the protocol level every 30 seconds and terminates connections that miss a pong.
Watching vs membership
Two delivery tiers per channel:
- Watchers — connections subscribed via
watch/queryChannels(watch: true). They receivemessage.new,message.updated,message.deleted, and structural events for the channel. - Members who are connected but not watching — receive
notification.message_newinstead ofmessage.new(badge/preview material), plus structural events. They do not receivemessage.updated/message.deleted.
For message.new / notification.message_new, the gateway injects the receiving user's own unread_count for that channel into the event.
Event types
All events carry type, created_at, usually cid/channel_id/channel_type, the acting user, and an event_id (ULID, used for replay). Shapes are defined in Protocol.
| Type | Payload highlights | Delivered to |
|---|---|---|
health.check |
connection_id; me on the initial hello |
The connection itself |
message.new |
message, receiver's unread_count |
Watchers |
notification.message_new |
same as message.new |
Connected members not watching |
message.updated |
message |
Watchers |
message.deleted |
message (empty text/attachments when soft-deleted) |
Watchers |
member.added |
member |
Watchers + all members' connections |
member.removed |
member |
Watchers + remaining members + the removed user |
channel.created |
channel |
Watchers + all members' connections |
channel.deleted |
— | Watchers + all members' connections |
connection.error |
error.message (local, non-protocol) |
The connection itself, on auth failure |
Reconnect and replay
Every emitted event is persisted to the events table with a monotonic per-app ULID event_id. Clients track the last event_id they saw and reconnect with ?last_event_id=<ulid>; after the hello, the gateway replays all newer events visible to that user — events explicitly targeted at them, plus events on channels they're a member of — in order, up to 500 events. The replay log is trimmed to 7 days by the nightly maintenance job.
The SDKs implement the full loop: automatic reconnect with exponential backoff + jitter (500 ms base, 30 s cap), last_event_id on the URL, keepalive every 25 s, and a reactive connectionStatus (connecting / connected / disconnected).
Scaling notes
The gateway is horizontally scalable: events are published on Redis pub/sub channel lc:evt and every instance delivers to its local connections; watch/unwatch subscriptions travel on the control channel lc:ctl, so a REST call handled by one instance can subscribe a socket held by another. Presence uses a per-app Redis hash of connection counts per user.