This document is the integrator-facing reference for the chat backend. It covers every API a client (web, mobile, third-party) can call:
- HTTP — the single
POST /authendpoint that exchanges an SSO token for a NATS user JWT. - NATS request/reply — RPC-style methods exposed by
room-service,history-service, andsearch-service. - NATS publish + async reply — the message-send flow handled by
message-gatekeeper.
For each method, this doc lists the subject, the request body schema and example, the success response schema and example, the error response, and the server-pushed events the client will receive on the success and error paths.
This doc covers the public client-facing API surface only.
Out of scope (documented elsewhere or backend-internal):
- Backend-only JetStream subjects (MESSAGES, MESSAGES_CANONICAL, FANOUT, OUTBOX, INBOX, ROOMS streams). See
docs/nats-subject-naming.md. - Server-pushed events not triggered by a specific client RPC (federation arrivals, presence, room-key rotation, cross-site member events).
- Server-to-server subjects (
chat.server.request.…).
Subjects in this doc use these placeholders:
| Placeholder | Meaning |
|---|---|
{account} |
The user's NATS account (preferred username from SSO claims, e.g. alice). |
{roomID} |
A room ID (see "ID formats" below). |
{siteID} |
The site that owns the room (each site runs its own NATS). |
{requestID} |
A 36-char hyphenated UUIDv7 generated by the client for the message-send async-reply pattern. |
All NATS payloads are JSON. All HTTP request/response bodies are JSON.
| ID kind | Format | Length | Notes |
|---|---|---|---|
| Account | Lowercase string | variable | SSO-derived; appears as {account} in subjects. |
| Channel room ID | base62 | 17 chars | Generated server-side at room creation. |
| DM room ID | sorted concat of two accounts | ~len(a)+3+len(b) |
Deterministic; same two users always produce the same ID. |
| Message ID | base62 | 17 or 20 chars | New messages are 20-char; 17-char accepted for legacy/federated messages. |
| Request ID | hyphenated UUIDv7 | 36 chars | Both inbound X-Request-ID headers and the requestId payload field for msg.send. |
Clients may include an X-Request-ID NATS message header on outbound requests. If present, the server uses it for log correlation; if absent, the server generates one. The header value must be a valid hyphenated UUID (v4 or v7, case-insensitive).
The msg.send flow is different — see §4: the client puts the request ID in the JSON payload (requestId field), and the server replies on chat.user.{account}.response.{requestID}.
- Standard NATS request/reply — the NATS client library auto-generates a reply subject under
_INBOX.>and routes the reply back to the caller. Used by every method in §3. - Async reply on
chat.user.{account}.response.{requestID}— used only bymsg.send(§4). The client publishes (no synchronous reply expected on_INBOX.>); the server readsrequestIdfrom the payload and publishes the reply tochat.user.{account}.response.{requestID}. The client must already be subscribed tochat.user.{account}.>(the user wildcard) to receive it.
All event payloads carry a top-level timestamp field that is milliseconds since the Unix epoch in UTC. Domain timestamps inside payloads (e.g. Message.createdAt) are RFC 3339 strings.
A client connects to NATS using a user NKey pair plus a signed JWT obtained from the auth-service (§2.2). The JWT scopes the client's permissions to:
| Permission | Subject pattern | Why |
|---|---|---|
| Publish | chat.user.{account}.> |
The client may publish only under its own user namespace. All RPC requests, the message-send subject, and any client-emitted event fall here. |
| Publish | _INBOX.> |
Required for the standard NATS request/reply pattern (the auto-generated reply inbox). |
| Subscribe | chat.user.{account}.> |
Receives all responses, notifications, and per-user events. |
| Subscribe | chat.room.> |
Subscribes to per-room message streams and room events for any room the user belongs to. |
| Subscribe | _INBOX.> |
Required to receive replies to client-issued requests. |
Recommended baseline subscriptions on connect:
chat.user.{account}.>— captures every personal event including async replies, notifications, and subscription updates.chat.room.{roomID}.stream.msgfor each room in the user's sidebar — receives new messages.
The exact event subjects a client may receive as a result of an RPC are listed under each method's "Triggered events" sections in §2.2, §3, and §4.
Endpoint: POST /auth
Reply: synchronous HTTP response
Exchanges an SSO token for a signed NATS user JWT. The returned JWT is what the client uses to connect to NATS (see §2.1).
| Field | Type | Required | Notes |
|---|---|---|---|
ssoToken |
string | yes | OIDC-issued SSO token. |
natsPublicKey |
string | yes | The client's NATS user public NKey (must pass nkeys.IsValidPublicUserKey). |
{
"ssoToken": "<sso-token>",
"natsPublicKey": "UDXU4RCSJNZOIQHZNWXHXORDPRTGNJAHAHFRGZNEEJCPQTT2M7NLCNF4"
}HTTP 200
| Field | Type | Notes |
|---|---|---|
natsJwt |
string | Signed NATS user JWT. Use as the user JWT when connecting to NATS. |
user.email |
string | OIDC email claim. |
user.account |
string | The {account} value used in every NATS subject. Derived from preferred_username (falls back to name). |
user.employeeId |
string | Parsed from the SSO description claim. |
user.engName |
string | Parsed from the SSO description claim. |
user.chineseName |
string | Parsed from the SSO description claim. |
user.deptName |
string | OIDC dept-name claim. |
user.deptId |
string | OIDC dept-id claim. |
{
"natsJwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ...",
"user": {
"email": "alice@example.com",
"account": "alice",
"employeeId": "E12345",
"engName": "Alice",
"chineseName": "愛麗絲",
"deptName": "Engineering",
"deptId": "ENG"
}
}See Error envelope. HTTP statuses:
| Status | Meaning | Example body |
|---|---|---|
| 400 | Missing or malformed fields, or invalid natsPublicKey. |
{ "error": "ssoToken and natsPublicKey are required" } |
| 401 | SSO token expired or invalid. | { "error": "SSO token has expired, please re-login" } |
| 500 | Server-side JWT signing failure. | { "error": "failed to generate NATS token" } |
None — HTTP-only.
None.
When the auth-service is started with DEV_MODE=true, the request body schema is { "account": string, "natsPublicKey": string } (no SSO token; the supplied account is trusted). This is local-development only and is not part of the production contract.
Subject: chat.user.{account}.request.rooms.create
Reply subject: auto-generated _INBOX.> (NATS request/reply)
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Room name. |
type |
string | yes | One of channel, dm, botDM, discussion. |
createdBy |
string | yes | Internal user ID of the creator. |
createdByAccount |
string | yes | Account name of the creator. Used for the owner subscription. |
siteId |
string | yes | The site that will own this room. |
members |
string[] | no | Required exactly one entry when type=dm (the other user's ID); ignored otherwise. |
{
"name": "engineering-announcements",
"type": "channel",
"createdBy": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"createdByAccount": "alice",
"siteId": "siteA"
}The created Room object.
| Field | Type | Notes |
|---|---|---|
id |
string | Room ID. 17-char base62 for channels; sorted concat of two accounts for DMs. |
name |
string | |
type |
string | Same values as request. |
createdBy |
string | |
siteId |
string | |
userCount |
number | 1 immediately after creation (the owner). |
lastMsgAt |
string | Optional. RFC 3339 timestamp; absent until first message. |
lastMsgId |
string | Empty until first message. |
lastMentionAllAt |
string | Optional. RFC 3339 timestamp. |
minUserLastSeenAt |
string | Optional. RFC 3339 timestamp. |
createdAt |
string | RFC 3339 timestamp. |
updatedAt |
string | RFC 3339 timestamp. |
restricted |
boolean | Optional. |
{
"id": "01970a4f8c2d7c9aQ",
"name": "engineering-announcements",
"type": "channel",
"createdBy": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"siteId": "siteA",
"userCount": 1,
"lastMsgId": "",
"createdAt": "2026-05-06T08:00:00Z",
"updatedAt": "2026-05-06T08:00:00Z"
}See Error envelope.
{ "error": "DM requires exactly one other member, got 0" }None — reply only. Member additions are a separate RPC (Add Members); creating a room only enrolls the owner.
None — error returned only via the reply subject.
Subject: chat.user.{account}.request.rooms.list
Reply subject: auto-generated _INBOX.> (NATS request/reply)
Empty. Send {} or no payload.
{}| Field | Type | Notes |
|---|---|---|
rooms |
array | All rooms the requester is subscribed to. See Create Room for the Room schema. |
{
"rooms": [
{
"id": "01970a4f8c2d7c9aQ",
"name": "engineering-announcements",
"type": "channel",
"createdBy": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"siteId": "siteA",
"userCount": 12,
"lastMsgAt": "2026-05-06T07:55:00Z",
"lastMsgId": "01970a4f8c2d7c9aQRST",
"createdAt": "2026-05-01T10:00:00Z",
"updatedAt": "2026-05-06T07:55:00Z"
}
]
}See Error envelope.
None — reply only.
None — error returned only via the reply subject.
Subject: chat.user.{account}.request.rooms.get.{roomID}
Reply subject: auto-generated _INBOX.> (NATS request/reply)
The room ID is the last subject segment — there is no request body.
Empty. Send {} or no payload.
{}A single Room object. See Create Room for the Room schema.
{
"id": "01970a4f8c2d7c9aQ",
"name": "engineering-announcements",
"type": "channel",
"createdBy": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"siteId": "siteA",
"userCount": 12,
"lastMsgAt": "2026-05-06T07:55:00Z",
"lastMsgId": "01970a4f8c2d7c9aQRST",
"createdAt": "2026-05-01T10:00:00Z",
"updatedAt": "2026-05-06T07:55:00Z"
}See Error envelope.
{ "error": "room not found" }None — reply only.
None — error returned only via the reply subject.
Subject: chat.user.{account}.request.room.{roomID}.{siteID}.member.add
Reply subject: auto-generated _INBOX.> (NATS request/reply)
This is an async-job RPC: the synchronous reply only confirms acceptance. The actual member adds run asynchronously in room-worker, which publishes the events listed under "Triggered events" below. To receive the AsyncJobResult event, the client must set an X-Request-ID NATS header on the original request (see Request-ID propagation).
| Field | Type | Required | Notes |
|---|---|---|---|
roomId |
string | no | Optional; the server derives the room ID from the subject and ignores any non-matching value. |
users |
string[] | no | Internal user IDs (or accounts) to add directly. |
orgs |
string[] | no | Org IDs to add (expanded server-side to all org members). |
channels |
array | no | Other channels to add as bulk sources. Each entry is { "roomId": string, "siteId": string }. |
history.mode |
string | no | "none" (default) or "all" — controls whether new members see history before they joined. |
The fields requesterId, requesterAccount, and timestamp on the Go AddMembersRequest are server-set — the client should omit them.
{
"users": ["bob"],
"orgs": [],
"channels": [
{ "roomId": "01970a4f8c2d7c9aP", "siteId": "siteA" }
],
"history": { "mode": "all" }
}| Field | Type | Notes |
|---|---|---|
status |
string | Always "accepted". Confirms the request passed authorization and was queued for processing. |
{ "status": "accepted" }See Error envelope. Returned synchronously when validation or authorization fails (e.g. requester not in room, room is full, room is restricted and requester is not owner).
{ "error": "room is at maximum capacity (200): cannot add 5 members to room with 198 existing" }1. chat.user.{requesterAccount}.response.{requestID} — async job result delivered to the requester when the bulk add finishes.
Recipients: the original requester. Only published if the client set X-Request-ID on the original request.
| Field | Type | Notes |
|---|---|---|
requestId |
string | Echoes the X-Request-ID value from the original request. |
job |
string | "add_members". |
success |
boolean | true if all members were added; false if the worker hit an error. |
error |
string | Optional. Sanitized message; present only when success=false. |
timestamp |
number | Milliseconds since Unix epoch (UTC). |
{
"requestId": "01970a4f-8c2d-7c9a-abcd-e0123456789f",
"job": "add_members",
"success": true,
"timestamp": 1746518400123
}2. chat.user.{newMember}.event.subscription.update — one event per newly added member.
Recipients: each new member (the freshly added user — not the requester, not existing members).
| Field | Type | Notes |
|---|---|---|
userId |
string | The new member's internal user ID. |
subscription |
object | The full Subscription record (room ID, room type, roles, joinedAt, etc.). |
action |
string | "added". |
timestamp |
number | Milliseconds since Unix epoch (UTC). |
{
"userId": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"subscription": {
"_id": "01970a4f8c2d7c9a01970a4f8c2d7c9b",
"user": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a", "account": "bob" },
"roomId": "01970a4f8c2d7c9aQ",
"roomType": "channel",
"siteId": "siteA",
"roles": ["member"],
"joinedAt": "2026-05-06T08:01:23Z"
},
"action": "added",
"timestamp": 1746518483000
}When the synchronous reply is an error envelope, the request was rejected before publishing to the worker — no events follow.
When the asynchronous job fails after acceptance, the requester receives the AsyncJobResult above with success: false. No subscription.update events are published in that case.
Subject: chat.user.{account}.request.room.{roomID}.{siteID}.member.remove
Reply subject: auto-generated _INBOX.> (NATS request/reply)
This is an async-job RPC: the synchronous reply only confirms acceptance. The actual member removal runs asynchronously in room-worker, which publishes the events listed under "Triggered events" below. To receive the AsyncJobResult event, the client must set an X-Request-ID NATS header on the original request (see Request-ID propagation).
| Field | Type | Required | Notes |
|---|---|---|---|
account |
string | no | Remove a single user. Mutually exclusive with orgId. |
orgId |
string | no | Remove all users in this org. Mutually exclusive with account. |
roomId |
string | no | Server derives from subject; non-matching values are rejected. |
Exactly one of account or orgId must be set. The fields requester and timestamp on the Go RemoveMemberRequest are server-set — the client should omit them.
{ "account": "bob" }| Field | Type | Notes |
|---|---|---|
status |
string | Always "accepted". Confirms the request passed authorization and was queued for processing. |
{ "status": "accepted" }See Error envelope. Returned synchronously when validation or authorization fails (e.g. neither or both of account/orgId set, requester is not an owner, target is the last member, or org member cannot leave individually).
{ "error": "exactly one of account or orgId must be set" }1. chat.user.{requesterAccount}.response.{requestID} — async job result delivered to the requester when the removal finishes.
Recipients: the original requester. Only published if the client set X-Request-ID on the original request.
| Field | Type | Notes |
|---|---|---|
requestId |
string | Echoes the X-Request-ID value from the original request. |
job |
string | "remove_member" for single-account removal; "remove_org" for org removal. |
success |
boolean | true if removal succeeded; false if the worker hit an error. |
error |
string | Optional. Sanitized message; present only when success=false. |
timestamp |
number | Milliseconds since Unix epoch (UTC). |
{
"requestId": "01970a4f-8c2d-7c9a-abcd-e0123456789f",
"job": "remove_member",
"success": true,
"timestamp": 1746518400123
}2. chat.user.{removedAccount}.event.subscription.update — one event per removed account.
Recipients: each removed account (the user whose subscription was deleted).
| Field | Type | Notes |
|---|---|---|
userId |
string | The removed user's internal user ID. |
subscription |
object | The Subscription record at the time of removal (room ID, room type, user info). |
action |
string | "removed". |
timestamp |
number | Milliseconds since Unix epoch (UTC). |
{
"userId": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"subscription": {
"user": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a", "account": "bob" },
"roomId": "01970a4f8c2d7c9aQ",
"roomType": "channel"
},
"action": "removed",
"timestamp": 1746518483000
}When the synchronous reply is an error envelope, the request was rejected before publishing to the worker — no events follow.
When the asynchronous job fails after acceptance, the requester receives the AsyncJobResult above with success: false. No subscription.update events are published in that case.
Subject: chat.user.{account}.request.room.{roomID}.{siteID}.member.role-update
Reply subject: auto-generated _INBOX.> (NATS request/reply)
This is an async-job RPC. The synchronous reply confirms acceptance; the actual role change runs in room-worker and emits the event below. Unlike Add Members and Remove Member, room-worker does not publish an AsyncJobResult event for role updates — there is no chat.user.{requesterAccount}.response.{requestID} event for this RPC.
| Field | Type | Required | Notes |
|---|---|---|---|
roomId |
string | no | Server derives from subject; non-matching values are rejected. |
account |
string | yes | The account of the user whose role is being changed. |
newRole |
string | yes | Either "owner" (promote) or "member" (demote). |
The timestamp field on the Go UpdateRoleRequest is server-set — the client should omit it.
{ "account": "bob", "newRole": "owner" }| Field | Type | Notes |
|---|---|---|
status |
string | Always "accepted". Confirms the request passed authorization and was queued for processing. |
{ "status": "accepted" }See Error envelope. Returned synchronously when validation or authorization fails. Common errors include:
- Requester is not an owner of the room.
- Target account is not a member of the room.
newRoleis neither"owner"nor"member".- Promote attempt when the target is already an owner.
- Demote attempt when the target is not an owner.
- Last-owner guard: an owner cannot demote themselves if they are the only owner.
- Promote attempt on an org-only member (individual subscription required).
{ "error": "only owners can update roles" }chat.user.{targetAccount}.event.subscription.update — emitted once for the user whose role changed.
Recipients: the target user (the account whose role was updated) only — not the requester, not other room members.
| Field | Type | Notes |
|---|---|---|
userId |
string | The target user's internal user ID. |
subscription |
object | The full Subscription record reflecting the updated roles array. |
action |
string | "role_updated". |
timestamp |
number | Milliseconds since Unix epoch (UTC). |
{
"userId": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"subscription": {
"_id": "01970a4f8c2d7c9a01970a4f8c2d7c9b",
"u": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a", "account": "bob" },
"roomId": "01970a4f8c2d7c9aQ",
"roomType": "channel",
"siteId": "siteA",
"roles": ["owner", "member"],
"joinedAt": "2026-05-06T08:01:23Z"
},
"action": "role_updated",
"timestamp": 1746518483000
}When the synchronous reply is an error envelope, no events follow. The async job has no separate failure event for role updates — failures inside the worker are logged but no client-visible signal is emitted.
Subject: chat.user.{account}.request.room.{roomID}.{siteID}.member.list
Reply subject: auto-generated _INBOX.> (NATS request/reply)
| Field | Type | Required | Notes |
|---|---|---|---|
limit |
number | no | If set, must be > 0. Caps the number of members returned. |
offset |
number | no | If set, must be >= 0. For pagination. |
enrich |
boolean | no | When true, populates the display fields (engName, chineseName, isOwner, sectName, memberCount) on each entry. Omitted-or-false returns the lean record only. |
{ "limit": 50, "enrich": true }| Field | Type | Notes |
|---|---|---|
members |
array | One entry per individual or org membership. |
RoomMember:
| Field | Type | Notes |
|---|---|---|
id |
string | Membership record ID (UUIDv7 hex). |
rid |
string | Room ID (note JSON key is rid, not roomId). |
ts |
string | RFC 3339 timestamp of when the membership was created. |
member |
object | See RoomMemberEntry below. |
RoomMemberEntry:
| Field | Type | Notes |
|---|---|---|
id |
string | The user account or org ID. |
type |
string | "individual" or "org". |
account |
string | Optional. Account name (set for individuals). |
engName |
string | Optional. Populated only when request had enrich: true. |
chineseName |
string | Optional. Populated only when enrich: true. |
isOwner |
boolean | Optional. Populated only when enrich: true. |
sectName |
string | Optional. Populated only when enrich: true and entry is an org. |
memberCount |
number | Optional. Populated only when enrich: true and entry is an org. |
{
"members": [
{
"id": "01970a4f8c2d7c9a01970a4f8c2d7c9b",
"rid": "01970a4f8c2d7c9aQ",
"ts": "2026-05-01T10:00:00Z",
"member": {
"id": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"type": "individual",
"account": "alice",
"engName": "Alice",
"chineseName": "愛麗絲",
"isOwner": true
}
}
]
}See Error envelope. Common errors: "not a member of this room", "limit must be > 0", "offset must be >= 0".
None — reply only.
None — error returned only via the reply subject.
Subject: chat.user.{account}.request.room.{roomID}.{siteID}.message.read
Reply subject: auto-generated _INBOX.> (NATS request/reply)
This is a synchronous RPC — room-service performs all writes inline before replying. The handler validates room membership, recomputes the per-subscription alert flag, persists the new lastSeenAt and alert on the user's Subscription, optionally recomputes Room.MinUserLastSeenAt, and (for cross-site users) publishes a subscription_read event to the user's home-site outbox so the destination inbox-worker can update its local subscription cache.
| Field | Type | Required | Notes |
|---|---|---|---|
roomId |
string | no | Server derives from subject. If supplied, must match the subject's roomID; mismatches are rejected. |
{ "roomId": "01970a4f8c2d7c9aQ" }| Field | Type | Notes |
|---|---|---|
status |
string | Always "accepted". Confirms the read receipt was applied. |
{ "status": "accepted" }See Error envelope. Common errors:
"only room members can list members"— the user has no subscription in the room (sentinel reused across membership-gated RPCs)."room ID mismatch"— the body'sroomIddoesn't match the subject."invalid message-read subject: …"— the subject is malformed.
{ "error": "only room members can list members" }- Alert recomputation: new
alert = oldSub.alert && len(oldSub.threadUnread) > 0. Reading the room clears the alert when there are no unread thread mentions; it stays set when thread-level unreads remain. originalLastSeenresolution: the handler usessubscription.lastSeenAtif present, otherwise falls back tosubscription.joinedAt(newly-joined rooms have never been read).- Room-floor recompute (
Room.MinUserLastSeenAt): skipped whenroom.lastMsgAtisnullor whenoriginalLastSeen > room.lastMsgAt(the user was already up-to-date — the floor cannot have moved). Otherwise the handler computes the new floor asMIN(lastSeenAt OR joinedAt)across the room's subscriptions and writes it torooms.minUserLastSeenAt(or unsets the field if the aggregate is empty). - Cross-site federation: if the user's home site (
users.siteId) differs from the handler's site, asubscription_readevent is published tooutbox.{handlerSite}.to.{userSite}.subscription_readwith payload{account, roomId, lastSeenAt, alert, timestamp}(timestamps asint64UnixMilli). The destinationinbox-workerapplies the write with an$ltorder-safety guard so out-of-order delivery cannot regresslastSeenAt. The outbox publish happens before the room-floor recompute so the user's home site receives every read receipt — even ones that don't move the room floor. - No system message, no fan-out events: read receipts are silent; only the requester receives the
acceptedreply.
None — reply only. (Cross-site users may observe a delayed subscription.update on their home site driven by the outbox/inbox flow above; this is treated as cache convergence rather than a client-visible event for this RPC.)
None — error returned only via the reply subject.
Subject: chat.user.{account}.request.orgs.{orgID}.members
Reply subject: auto-generated _INBOX.> (NATS request/reply)
The org ID is the second-to-last subject segment — there is no request body.
Empty. Send {} or no payload.
{}| Field | Type | Notes |
|---|---|---|
members |
array | All individuals in the org. |
OrgMember:
| Field | Type | Notes |
|---|---|---|
id |
string | Internal user ID. |
account |
string | Account name. |
engName |
string | |
chineseName |
string | |
siteId |
string | The user's home site. |
{
"members": [
{
"id": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"account": "alice",
"engName": "Alice",
"chineseName": "愛麗絲",
"siteId": "siteA"
}
]
}See Error envelope.
{ "error": "invalid org" }None — reply only.
None — error returned only via the reply subject.
Used by every history-service method that returns messages. Mirrors the Cassandra Message row.
| Field | Type | Notes |
|---|---|---|
roomId |
string | |
createdAt |
string | RFC 3339 timestamp. |
messageId |
string | 17- or 20-char base62. |
sender |
object | A Participant — see below. |
msg |
string | The message body. |
targetUser |
object | Optional. Participant — set for direct/system messages addressed to a specific user. |
mentions |
array | Optional. |
attachments |
string[] | Optional. Each entry is base64-encoded bytes. |
file |
object | Optional. {id, name, type}. |
card |
object | Optional. {template, data?}. data is base64. |
cardAction |
object | Optional. {verb, text?, cardId?, displayText?, hideExecLog?, cardTmId?, data?}. |
tshow |
boolean | Optional. Whether a thread reply is also shown in the parent room. |
tcount |
number | Optional. Number of replies on a thread parent. |
threadParentId |
string | Optional. Set when this message is a thread reply. |
threadParentCreatedAt |
string | Optional. RFC 3339. |
quotedParentMessage |
object | Optional. Embedded snapshot — see below. |
visibleTo |
string | Optional. Visibility scope. |
reactions |
object | Optional. Map of emoji → Participant[]. |
deleted |
boolean | Optional. true for tombstoned messages. |
type |
string | Optional. System-message type when set (e.g. "member_added"); regular messages omit it. |
sysMsgData |
string | Optional. Base64-encoded raw JSON payload for system messages. |
siteId |
string | Optional. The site that owns the message. |
editedAt |
string | Optional. RFC 3339. Set after an edit. |
updatedAt |
string | Optional. RFC 3339. Mirrors editedAt for edits, set on delete to record the deletion time. |
threadRoomId |
string | Optional. The thread room ID when this is a thread message. |
pinnedAt |
string | Optional. RFC 3339. |
pinnedBy |
object | Optional. Participant. |
Participant:
| Field | Type | Notes |
|---|---|---|
id |
string | Internal user (or app) ID. |
engName |
string | Optional. |
companyName |
string | Optional. |
appId |
string | Optional. Set for bot messages. |
appName |
string | Optional. |
isBot |
boolean | Optional. |
account |
string | Optional. |
QuotedParentMessage (embedded snapshot of the quoted message at the time of quoting):
| Field | Type | Notes |
|---|---|---|
messageId |
string | |
roomId |
string | |
sender |
object | Participant. |
createdAt |
string | RFC 3339. |
msg |
string | Optional. Body snapshot. |
mentions |
array | Optional. |
attachments |
string[] | Optional. |
messageLink |
string | Optional. |
threadParentId |
string | Optional. Set if the quoted message itself is a thread reply. |
threadParentCreatedAt |
string | Optional. RFC 3339. |
Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.history
Reply subject: auto-generated _INBOX.> (NATS request/reply)
| Field | Type | Required | Notes |
|---|---|---|---|
before |
number | no | Milliseconds since Unix epoch (UTC). Returns messages with createdAt < before. Omit (or null) for "now". |
limit |
number | yes | Maximum number of messages to return. |
{
"before": 1746518400000,
"limit": 50
}| Field | Type | Notes |
|---|---|---|
messages |
array | Most-recent first. See Message schema. |
{
"messages": [
{
"roomId": "01970a4f8c2d7c9aQ",
"createdAt": "2026-05-06T07:55:00Z",
"messageId": "01970a4f8c2d7c9aQRST",
"sender": {
"id": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"account": "alice",
"engName": "Alice"
},
"msg": "morning team"
}
]
}See Error envelope.
{ "error": "not subscribed to room" }None — reply only.
None — error returned only via the reply subject.
Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.next
Reply subject: auto-generated _INBOX.> (NATS request/reply)
Fetches messages newer than a cursor — the forward-pagination counterpart to Load History.
| Field | Type | Required | Notes |
|---|---|---|---|
after |
number | no | Milliseconds since Unix epoch (UTC). Returns messages with createdAt > after. Omit for "no lower bound". |
limit |
number | yes | Maximum number of messages to return. |
cursor |
string | yes | Pagination cursor returned by a previous response. Use empty string for the first page. |
{
"after": 1746518400000,
"limit": 50,
"cursor": ""
}| Field | Type | Notes |
|---|---|---|
messages |
array | Oldest-first within the page. See Message schema. |
nextCursor |
string | Optional. Opaque cursor to pass to the next call. Empty when hasNext=false. |
hasNext |
boolean | true if more messages exist beyond this page. |
{
"messages": [
{
"roomId": "01970a4f8c2d7c9aQ",
"createdAt": "2026-05-06T07:55:00Z",
"messageId": "01970a4f8c2d7c9aQRST",
"sender": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a", "account": "alice" },
"msg": "morning team"
}
],
"nextCursor": "eyJ0cyI6MTc0NjUxODQwMDAwMH0=",
"hasNext": true
}See Error envelope.
None — reply only.
None — error returned only via the reply subject.
Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.surrounding
Reply subject: auto-generated _INBOX.> (NATS request/reply)
Fetches messages around a target message — useful for "jump to this message" navigation. Returns up to limit messages total, centered on messageId.
| Field | Type | Required | Notes |
|---|---|---|---|
messageId |
string | yes | The central message to center the window on. |
limit |
number | yes | Total number of messages to return (including the central one). |
{
"messageId": "01970a4f8c2d7c9aQRST",
"limit": 50
}| Field | Type | Notes |
|---|---|---|
messages |
array | Window of messages centered on messageId, oldest-first. See Message schema. |
moreBefore |
boolean | true if more messages exist before the window. |
moreAfter |
boolean | true if more messages exist after the window. |
{
"messages": [
{
"roomId": "01970a4f8c2d7c9aQ",
"createdAt": "2026-05-06T07:55:00Z",
"messageId": "01970a4f8c2d7c9aQRST",
"sender": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a", "account": "alice" },
"msg": "morning team"
}
],
"moreBefore": true,
"moreAfter": false
}See Error envelope.
None — reply only.
None — error returned only via the reply subject.
Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.get
Reply subject: auto-generated _INBOX.> (NATS request/reply)
| Field | Type | Required | Notes |
|---|---|---|---|
messageId |
string | yes | The message to fetch. |
{ "messageId": "01970a4f8c2d7c9aQRST" }A single Message object. See Message schema.
{
"roomId": "01970a4f8c2d7c9aQ",
"createdAt": "2026-05-06T07:55:00Z",
"messageId": "01970a4f8c2d7c9aQRST",
"sender": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a", "account": "alice" },
"msg": "morning team"
}See Error envelope.
{ "error": "message not found" }None — reply only.
None — error returned only via the reply subject.
Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.edit
Reply subject: auto-generated _INBOX.> (NATS request/reply)
Only the original sender may edit a message.
| Field | Type | Required | Notes |
|---|---|---|---|
messageId |
string | yes | The message to edit. |
newMsg |
string | yes | The new content. Must be non-empty and within the size limit. |
{
"messageId": "01970a4f8c2d7c9aQRST",
"newMsg": "morning team — updated"
}| Field | Type | Notes |
|---|---|---|
messageId |
string | Echoes the input. |
editedAt |
number | Milliseconds since Unix epoch (UTC). |
{
"messageId": "01970a4f8c2d7c9aQRST",
"editedAt": 1746518700000
}See Error envelope. Common errors: "only the sender can edit", "message not found", "newMsg must not be empty", "newMsg exceeds maximum size", "failed to edit message".
chat.room.{roomID}.event — MessageEditedEvent. Recipients: every client subscribed to the room.
| Field | Type | Notes |
|---|---|---|
type |
string | Always "message_edited". |
timestamp |
number | Milliseconds since Unix epoch (UTC). Event publish time. |
roomId |
string | |
messageId |
string | The edited message's ID. |
newMsg |
string | Optional. New plaintext content. Set for rooms without a key (DMs). Empty for encrypted rooms — see encryptedNewMsg. |
encryptedNewMsg |
object | Optional. The roomcrypto.EncryptedMessage JSON object for encrypted (channel) rooms. Empty for DMs. |
editedBy |
string | The sender's account. |
editedAt |
number | Milliseconds since Unix epoch (UTC). Domain time of the edit. |
{
"type": "message_edited",
"timestamp": 1746518700123,
"roomId": "01970a4f8c2d7c9aQ",
"messageId": "01970a4f8c2d7c9aQRST",
"newMsg": "morning team — updated",
"editedBy": "alice",
"editedAt": 1746518700000
}None — error returned only via the reply subject.
Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.delete
Reply subject: auto-generated _INBOX.> (NATS request/reply)
Soft-deletes a message (sets deleted=true on the row; row is preserved for audit). Only the original sender may delete. Idempotent — re-deleting an already-deleted message returns success without re-publishing the event.
| Field | Type | Required | Notes |
|---|---|---|---|
messageId |
string | yes | The message to delete. |
{ "messageId": "01970a4f8c2d7c9aQRST" }| Field | Type | Notes |
|---|---|---|
messageId |
string | Echoes the input. |
deletedAt |
number | Milliseconds since Unix epoch (UTC). For an already-deleted message, this is the original deletion time. |
{
"messageId": "01970a4f8c2d7c9aQRST",
"deletedAt": 1746518800000
}See Error envelope. Common errors: "only the sender can delete", "message not found", "failed to delete message".
chat.room.{roomID}.event — MessageDeletedEvent. Recipients: every client subscribed to the room. Not published when the request hits an already-deleted message or loses a concurrent-delete CAS.
| Field | Type | Notes |
|---|---|---|
type |
string | Always "message_deleted". |
timestamp |
number | Milliseconds since Unix epoch (UTC). Event publish time. |
roomId |
string | |
messageId |
string | The deleted message's ID. |
deletedBy |
string | The sender's account. |
deletedAt |
number | Milliseconds since Unix epoch (UTC). Domain time of the delete. |
{
"type": "message_deleted",
"timestamp": 1746518800123,
"roomId": "01970a4f8c2d7c9aQ",
"messageId": "01970a4f8c2d7c9aQRST",
"deletedBy": "alice",
"deletedAt": 1746518800000
}None — error returned only via the reply subject.
Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.thread
Reply subject: auto-generated _INBOX.> (NATS request/reply)
Returns the replies in a thread. The thread parent's messageId is supplied in the request.
| Field | Type | Required | Notes |
|---|---|---|---|
threadMessageId |
string | yes | The top-level thread message ID. Must be a thread parent — not a reply. |
cursor |
string | no | Pagination cursor returned by a previous response. Omit for the first page. |
limit |
number | yes | Maximum number of replies to return. |
{
"threadMessageId": "01970a4f8c2d7c9aQRST",
"limit": 50
}| Field | Type | Notes |
|---|---|---|
messages |
array | Replies in the thread, oldest-first within the page. See Message schema. |
nextCursor |
string | Optional. Opaque cursor for the next page. |
hasNext |
boolean | true if more replies exist beyond this page. |
{
"messages": [
{
"roomId": "01970a4f8c2d7c9aQ",
"createdAt": "2026-05-06T08:00:00Z",
"messageId": "01970a4f8c2d7c9aQUVW",
"sender": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9b", "account": "bob" },
"msg": "good morning",
"threadParentId": "01970a4f8c2d7c9aQRST",
"threadParentCreatedAt": "2026-05-06T07:55:00Z"
}
],
"hasNext": false
}See Error envelope.
None — reply only.
None — error returned only via the reply subject.
Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.thread.parent
Reply subject: auto-generated _INBOX.> (NATS request/reply)
Lists the parent messages of threads the user has subscribed to (or all threads, depending on filter). Use this to drive a "Threads" tab in the client.
| Field | Type | Required | Notes |
|---|---|---|---|
filter |
string | yes | One of "all", "following" (only threads the user is subscribed to), or "unread" (only threads with unread replies). |
offset |
number | yes | For pagination. 0 for the first page. |
limit |
number | yes | Maximum number of thread parents to return. |
{
"filter": "following",
"offset": 0,
"limit": 50
}| Field | Type | Notes |
|---|---|---|
parentMessages |
array | Thread parent messages, ordered by most-recent reply activity. See Message schema. |
total |
number | Raw count before access filtering. Use for pagination math only — parentMessages.length may be smaller. |
{
"parentMessages": [
{
"roomId": "01970a4f8c2d7c9aQ",
"createdAt": "2026-05-06T07:55:00Z",
"messageId": "01970a4f8c2d7c9aQRST",
"sender": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a", "account": "alice" },
"msg": "let's discuss the rollout",
"tcount": 3
}
],
"total": 42
}See Error envelope.
None — reply only.
None — error returned only via the reply subject.
Subject: chat.user.{account}.request.search.messages
Reply subject: auto-generated _INBOX.> (NATS request/reply)
Full-text search across messages the requester has access to. When roomIds is omitted (or empty), the search runs across all rooms the user can see; when set, it scopes to those rooms (the service still enforces per-user access).
| Field | Type | Required | Notes |
|---|---|---|---|
searchText |
string | yes | The query. |
roomIds |
string[] | no | Optional scope. Empty means "search every room the user has access to". |
size |
number | no | Page size. |
offset |
number | no | Pagination offset. |
{
"searchText": "rollout plan",
"size": 20
}| Field | Type | Notes |
|---|---|---|
total |
number | Total hits matching the query. |
results |
array | Page of message hits. |
MessageSearchHit:
| Field | Type | Notes |
|---|---|---|
messageId |
string | |
roomId |
string | |
siteId |
string | |
userId |
string | Sender's internal user ID. |
userAccount |
string | Sender's account. |
content |
string | The message body. |
createdAt |
string | RFC 3339. |
threadParentMessageId |
string | Optional. |
threadParentMessageCreatedAt |
string | Optional. RFC 3339. |
{
"total": 42,
"results": [
{
"messageId": "01970a4f8c2d7c9aQRST",
"roomId": "01970a4f8c2d7c9aQ",
"siteId": "siteA",
"userId": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"userAccount": "alice",
"content": "let's discuss the rollout plan tomorrow",
"createdAt": "2026-05-06T07:55:00Z"
}
]
}See Error envelope.
None — reply only.
None — error returned only via the reply subject.
Subject: chat.user.{account}.request.search.rooms
Reply subject: auto-generated _INBOX.> (NATS request/reply)
Full-text search across rooms the requester is a member of (spotlight search).
| Field | Type | Required | Notes |
|---|---|---|---|
searchText |
string | yes | The query. |
scope |
string | no | "all" (default), "channel", or "dm". The value "app" is reserved and currently rejected. |
size |
number | no | Page size. |
offset |
number | no | Pagination offset. |
{
"searchText": "engineering",
"scope": "channel",
"size": 20
}| Field | Type | Notes |
|---|---|---|
total |
number | Total hits. |
results |
array | Page of room hits. |
RoomSearchHit:
| Field | Type | Notes |
|---|---|---|
roomId |
string | |
roomName |
string | |
roomType |
string | The internal letter code: p (channel), d (DM), etc. — different from the request scope values. |
userAccount |
string | The requester's account (echoed). |
siteId |
string | |
joinedAt |
string | RFC 3339. When the requester joined this room. |
{
"total": 3,
"results": [
{
"roomId": "01970a4f8c2d7c9aQ",
"roomName": "engineering-announcements",
"roomType": "p",
"userAccount": "alice",
"siteId": "siteA",
"joinedAt": "2026-05-01T10:00:00Z"
}
]
}See Error envelope. Returns an error when scope=app is used.
None — reply only.
None — error returned only via the reply subject.
Subject: chat.user.{account}.room.{roomID}.{siteID}.msg.send
Reply subject: chat.user.{account}.response.{requestID} — the client must subscribe to chat.user.{account}.> (the user wildcard) to receive it. The {requestID} value is the requestId field from the request body.
This RPC uses the publish + async-reply pattern, not the standard NATS request/reply. The client publishes to the msg.send subject (no _INBOX.> reply expected). message-gatekeeper validates the request, publishes the canonical message to MESSAGES_CANONICAL, and replies to chat.user.{account}.response.{requestID} with the persisted Message (or an error envelope on failure).
The same subject and request body cover three send variants: plain message, thread reply, and quoted message. The variant is determined by which optional fields are set.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | The message's ID. Must be 20-char base62. The client generates this and uses it for client-side optimistic rendering. |
content |
string | yes | The message body. Must be non-empty and ≤ 20 KiB. |
requestId |
string | yes | A 36-char hyphenated UUIDv7 the client generates. The async reply will be published to chat.user.{account}.response.{requestId}. |
threadParentMessageId |
string | no | Set when posting a thread reply. Must be a valid 20-char base62 message ID. Pair with threadParentMessageCreatedAt. |
threadParentMessageCreatedAt |
number | no | Required when threadParentMessageId is set. Milliseconds since Unix epoch (UTC). |
quotedParentMessageId |
string | no | Set when posting a quoted message. The gatekeeper fetches the parent and embeds a snapshot in the persisted message; the client does not send the snapshot itself. |
{
"id": "01970a4f8c2d7c9aQRST",
"content": "morning team",
"requestId": "01970a4f-8c2d-7c9a-abcd-e0123456789f"
}{
"id": "01970a4f8c2d7c9aQUVW",
"content": "good morning",
"requestId": "01970a4f-8c2d-7c9a-abcd-e0123456789a",
"threadParentMessageId": "01970a4f8c2d7c9aQRST",
"threadParentMessageCreatedAt": 1746518100000
}{
"id": "01970a4f8c2d7c9aQXYZ",
"content": "agreed — adding context",
"requestId": "01970a4f-8c2d-7c9a-abcd-e0123456789b",
"quotedParentMessageId": "01970a4f8c2d7c9aQRST"
}Delivered on chat.user.{account}.response.{requestId}. The body is the persisted Message.
| Field | Type | Notes |
|---|---|---|
id |
string | |
roomId |
string | |
userId |
string | Sender's internal user ID. |
userAccount |
string | Sender's account. |
content |
string | The message body. |
mentions |
array | Optional. |
createdAt |
string | RFC 3339. |
threadParentMessageId |
string | Optional. Set for thread replies. |
threadParentMessageCreatedAt |
string | Optional. RFC 3339. |
tshow |
boolean | Optional. |
type |
string | Optional. |
sysMsgData |
string | Optional. Base64. |
quotedParentMessage |
object | Optional. Snapshot of the quoted message. See Message schema's QuotedParentMessage table. |
{
"id": "01970a4f8c2d7c9aQRST",
"roomId": "01970a4f8c2d7c9aQ",
"userId": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"userAccount": "alice",
"content": "morning team",
"createdAt": "2026-05-06T07:55:00Z"
}Delivered on chat.user.{account}.response.{requestId}. See Error envelope. Common errors: "invalid message ID \"…\": must be a 20-char base62 string", "content must not be empty", "content exceeds maximum size of 20480 bytes", "user alice is not subscribed to room …", "validate thread parent fields: threadParentMessageCreatedAt is required when threadParentMessageId is set".
{ "error": "content must not be empty" }After a successful send, two downstream services fan out events. The set you receive depends on whether the room is a channel or a DM, and whether you're a mentioned user.
1. For channel rooms — chat.room.{roomID}.event
A RoomEvent published by broadcast-worker. Recipients: every client subscribed to the room (which includes the sender, since the sender is also a member).
| Field | Type | Notes |
|---|---|---|
type |
string | Always "new_message". |
roomId |
string | |
timestamp |
number | Milliseconds since Unix epoch (UTC). Event publish time. |
roomName |
string | |
roomType |
string | channel, dm, etc. |
siteId |
string | |
userCount |
number | |
lastMsgAt |
string | RFC 3339. |
lastMsgId |
string | The new message's ID. |
mentions |
array | Optional. |
mentionAll |
boolean | Optional. true if the message mentioned @all or @here. |
hasMention |
boolean | Optional. Per-recipient flag (DM event only). Always absent on channel events. |
message |
object | Optional. The ClientMessage (see Message schema plus a sender Participant). Set for unencrypted rooms. |
encryptedMessage |
object | Optional. Raw roomcrypto.EncryptedMessage JSON. Set for encrypted (channel) rooms. Use the room's current key to decrypt. |
{
"type": "new_message",
"roomId": "01970a4f8c2d7c9aQ",
"timestamp": 1746518100123,
"roomName": "engineering-announcements",
"roomType": "channel",
"siteId": "siteA",
"userCount": 12,
"lastMsgAt": "2026-05-06T07:55:00Z",
"lastMsgId": "01970a4f8c2d7c9aQRST",
"encryptedMessage": {
"v": 3,
"ciphertext": "<base64>",
"nonce": "<base64>"
}
}2. For DM rooms — chat.user.{recipient}.event.room
A RoomEvent (same struct as above) published by broadcast-worker per DM participant. Recipients: each user subscribed to the DM. The hasMention field is set per-recipient. DM events use message (plaintext); they do not use encryptedMessage.
{
"type": "new_message",
"roomId": "alice___bob",
"timestamp": 1746518100123,
"roomName": "alice, bob",
"roomType": "dm",
"siteId": "siteA",
"userCount": 2,
"lastMsgAt": "2026-05-06T07:55:00Z",
"lastMsgId": "01970a4f8c2d7c9aQRST",
"hasMention": false,
"message": {
"id": "01970a4f8c2d7c9aQRST",
"roomId": "alice___bob",
"userId": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"userAccount": "alice",
"content": "morning team",
"createdAt": "2026-05-06T07:55:00Z",
"sender": {
"id": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"account": "alice",
"engName": "Alice"
}
}
}3. chat.user.{recipient}.notification — for desktop banner notifications.
A NotificationEvent published by notification-worker. Recipients: per the worker's policy — typically the DM partner for DMs, and any @-mentioned user for channel messages.
| Field | Type | Notes |
|---|---|---|
type |
string | "new_message". |
roomId |
string | |
message |
object | The full Message. See Message schema. |
timestamp |
number | Milliseconds since Unix epoch (UTC). |
{
"type": "new_message",
"roomId": "alice___bob",
"message": {
"id": "01970a4f8c2d7c9aQRST",
"roomId": "alice___bob",
"userId": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"userAccount": "alice",
"content": "morning team",
"createdAt": "2026-05-06T07:55:00Z"
},
"timestamp": 1746518100123
}When validation fails, the gatekeeper publishes the error envelope to chat.user.{account}.response.{requestId} and no downstream events are emitted. The client should display the error and offer a retry.
{ "error": "content must not be empty" }Every error response — over NATS reply subjects and HTTP — uses the same envelope:
{ "error": "<human-readable reason>" }| Field | Type | Notes |
|---|---|---|
error |
string | Human-readable, sanitized at the service boundary. Do not parse or pattern-match against the text. |
NATS errors are sent on the standard reply subject (_INBOX.> for §3 methods, chat.user.{account}.response.{requestID} for §4) via natsutil.ReplyError. The reply body is the JSON object above.
HTTP errors (auth-service §2.2) use the same shape with an HTTP status code in the response line.
Clients should rely on the presence/absence of the error field — and on context (HTTP status, or whether a reply parses as a success-shape) — rather than on the error text.