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 roleowner; other members get rolemember. - 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) andmember.addedper 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 inPOST /channels/:type) instead of failing. - Emits
member.addedonly 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
datais provided, it is created with those attributes (the caller is always included inmembersand becomescreated_by_id) — lazy client-side channel creation. Withoutdata, 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_idyou 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_monthplan limit enforced (403/97). - The server assigns a ULID
idunless one is provided;last_message_atis 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 receivenotification.message_new, each with their ownunread_countinjected. - Queues a push job (FCM data message to member devices, minus sender, minus online users when
push_skip_onlineis 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
- API — WebSocket — how the events emitted by these endpoints are delivered.
- API — Push and Webhooks — the outbound side of
message.new. - SDK wrappers: JS Core, Node, Flutter.