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 receive message.new, message.updated, message.deleted, and structural events for the channel.
  • Members who are connected but not watching — receive notification.message_new instead of message.new (badge/preview material), plus structural events. They do not receive message.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.