API — Chat REST (v1)

The core chat API, served at the root path. Read API Reference first for authentication (server auth = x-api-key + x-api-secret; client auth = x-api-key + authorization: Bearer <user JWT>) and the error format. Payload types live in Protocol.

Endpoint index

Method + path Auth Purpose
GET /health none Liveness probe
POST /users server Upsert a user
PATCH /users/me client Update own profile
DELETE /users/:id server Delete a user
POST /channels/:type server Create a channel (idempotent)
POST /channels/:type/:id/members server Add members (idempotent, optional create-if-missing)
DELETE /channels/:type/:id/members/:userId server Remove a member
DELETE /channels/:type/:id server Delete (freeze) or hard-delete a channel
GET /channels client or server Query channels (filter + sort + pagination)
POST /channels/:type/:id/watch client Channel state + subscribe the WS connection
POST /channels/:type/:id/stop-watching client Unsubscribe the WS connection
POST /channels/:type/:id/messages client Send a message
GET /channels/:type/:id/messages client Message history (cursor pagination)
PUT /channels/:type/:id/messages/:messageId client Update own message
DELETE /channels/:type/:id/messages/:messageId client Delete own message (soft/hard)
POST /channels/:type/:id/read client Mark channel read
GET /unread client Unread summary across channels
POST /devices client Register a push device
DELETE /devices/:token client Unregister a push device
GET /devices client List own devices

Users

POST /users — upsert user (server auth)

Body (UpsertUserRequest): { "id": "ada", "name"?: "Ada", "image"?: "...", "role"?: "user", "custom"?: { ... } }

Upsert semantics: empty name/image never overwrite existing values; role is replaced (default "user"); custom is merged (existing || new). Creating a new user checks the max_mau plan limit (code 97 on violation) and increments the users_new metric.

Response: { "user": UserObject }

PATCH /users/me — update own profile (client auth)

Body: { "name"?: string, "image"?: string, "custom"?: object } — omitted fields are left unchanged; custom is merged. Creates the user row if it doesn't exist yet. This is what the SDKs' updateUser / connect-time profile sync calls.

Response: { "user": UserObject }

DELETE /users/:id — delete user (server auth)

Hard-deletes the user row plus their channel memberships, read state, and devices. Their messages remain (attributed to the departed user_id). 404 code 18 when the user doesn't exist.

Response: { "deleted": true }


Channels

POST /channels/:type — create channel (server auth)

Body (CreateChannelRequest):

{ "id": "pool-abc", "name": "Pool ABC", "members": ["ada", "lin"], "created_by_id": "ada", "custom": {} }
  • Idempotent: if the channel already exists it is returned unchanged — metadata conflicts never fail. Requested members are added idempotently either way.
  • The creator (created_by_id, required) is always added as a member with role owner; other members get role member.
  • Stub user rows are auto-created for member ids referenced before registration.
  • Joining members get their read state seeded at the latest message (unread starts at 0 from join time).
  • Emits channel.created (only on actual creation) and member.added per member actually inserted.

Response: { "channel": ChannelObject, "created": boolean }

POST /channels/:type/:id/members — add members (server auth)

Body (AddMembersRequest):

{
  "members": ["user-42"],
  "create_if_missing": { "created_by_id": "user-42", "name"?: "...", "custom"?: {} }
}
  • Idempotent: existing members are silently kept — "ensureMembership" as a single server-side call.
  • Without create_if_missing, a missing channel is 404 code 16. With it, the channel is created (as in POST /channels/:type) instead of failing.
  • Emits member.added only for members actually inserted.

Response: { "channel": ChannelObject, "created": boolean }

DELETE /channels/:type/:id/members/:userId — remove member (server auth)

Removes membership and read state. Emits member.removed to watchers, remaining members, and the removed user (targeted explicitly since they're no longer a member). 404 code 16 when the channel doesn't exist.

Response: { "removed": true }

DELETE /channels/:type/:id[?hard=true] — delete channel (server auth)

Soft delete (default) freezes the channel (no new messages; code 17 on send). ?hard=true deletes the row and cascades. Emits channel.deleted to watchers and all members.

Response: { "deleted": true }

GET /channels — query channels (client or server auth)

Query parameters (all values are strings):

Param Default Notes
filter {} JSON-encoded QueryChannelsFilter: { "members": { "$in": [...] }, "type": "...", "id": "..." | { "$in": [...] }, "cid": "..." | { "$in": [...] } } — conditions are ANDed
sort [{"field":"last_message_at","direction":-1}] JSON-encoded array of { field: 'last_message_at' | 'created_at' | 'member_count', direction: 1 | -1 } (nulls last)
limit / offset 30 / 0 limit is capped at 100
state true Include full channel state (messages/members/reads) per result
message_limit 25 Messages per channel when state=true (the SDKs send 25)
watch true + connection_id subscribes that WS connection to every returned channel (client auth only)
user_id Server auth only: scope results to this user's memberships (omit for an unscoped query)

Scoping: client-auth queries are always scoped to the caller's memberships; server-auth queries are unscoped unless user_id is given.

Response: { "channels": ChannelStateObject[] } (with state=false, each entry has empty messages/members/read).

POST /channels/:type/:id/watch — watch channel (client auth)

Returns the full channel state and subscribes the caller's WS connection to the channel's events.

Body (WatchChannelRequest, all optional):

{
  "connection_id": "conn_...",
  "data": { "name"?: "...", "members"?: ["..."], "custom"?: {} },
  "message_limit": 50
}
  • Lazy creation: when the channel doesn't exist and data is provided, it is created with those attributes (the caller is always included in members and becomes created_by_id) — lazy client-side channel creation. Without data, a missing channel is 404 code 16.
  • Membership is required: 403 code 17 when the caller is not a member of an existing channel (the self-heal trigger — fix with server-side addMembers).
  • message_limit: 1–200, default 50.
  • Without connection_id you get state but no event subscription.

Response: ChannelStateObject{ "channel", "messages", "members", "read" } (with client auth, read contains only the caller's read state).

POST /channels/:type/:id/stop-watching (client auth)

Body: { "connection_id": "conn_..." } (required). Unsubscribes that connection from the channel on every gateway instance.

Response: { "ok": true }


Messages

POST /channels/:type/:id/messages — send message (client auth)

Body (SendMessageRequest):

{ "message": { "id"?: "custom-ulid", "text": "hello!", "attachments"?: [ ... ], "custom"?: {} } }

Rules and effects:

  • Caller must be a member (403/17); frozen channels reject sends (403/17); text or attachments required (400/4); max_messages_month plan limit enforced (403/97).
  • The server assigns a ULID id unless one is provided; last_message_at is bumped.
  • Unread bookkeeping: every other member's unread_count +1; the sender's read state is set to this message.
  • Emits message.new — watchers receive it directly, connected non-watching members receive notification.message_new, each with their own unread_count injected.
  • Queues a push job (FCM data message to member devices, minus sender, minus online users when push_skip_online is set) and webhook deliveries.

Response: { "message": MessageObject } (the WS echo carries the same id — SDKs dedupe by id).

GET /channels/:type/:id/messages — history (client auth)

Membership required. Query: before (message ULID — return messages strictly older) and limit (default 50, capped at 200). Soft-deleted messages are excluded. Results are returned in ascending (chronological) order.

Response: { "messages": MessageObject[] }

PUT /channels/:type/:id/messages/:messageId — update message (client auth)

Only the author may update (403/17); 404 code 19 when the message doesn't exist (or was soft-deleted). Body — either { "message": {...} } or the bare object: { "text"?, "attachments"?, "custom"? }. Omitted fields are unchanged; custom is merged; updated_at is bumped. Emits message.updated.

Response: { "message": MessageObject }

DELETE /channels/:type/:id/messages/:messageId[?hard=true] (client auth)

Only the author may delete. Soft delete (default) sets deleted_at — the message is excluded from history and serialized with empty text/attachments. ?hard=true removes the row. Emits message.deleted.

Response: { "deleted": true }

POST /channels/:type/:id/read — mark read (client auth)

Membership required. Body (MarkReadRequest): { "message_id"?: "..." } — defaults to the newest message in the channel. Sets last_read_message_id and zeroes unread_count.

Response: { "ok": true }

GET /unread — unread summary (client auth)

The same summary embedded in the connect-time health.check me payload:

{
  "total_unread_count": 3,
  "unread_channels": 2,
  "channel_reads": [
    { "user_id": "ada", "cid": "messaging:general", "last_read_message_id": "01J...", "unread_count": 2 }
  ]
}

Devices

POST /devices (client auth)

Body (AddDeviceRequest): { "token": "<fcm token>", "provider": "firebase" } (provider defaults to firebase; it's the only provider in v1). Re-registering an existing token moves it to the calling user (token stealing across users is intentional — device handoff).

Response: { "ok": true }

DELETE /devices/:token (client auth)

Removes the registration (only if owned by the caller). URL-encode the token. Response: { "ok": true }

GET /devices (client auth)

Response: { "devices": [ { "token", "provider", "created_at" } ] }


See also