Messaging
Messages have a kind field that specifies the content type.
Every message mutation (message.send, message.forward, message.edit,
message.delete, message.react, and message.clear)
requires an operation_id. It is an opaque client-generated identifier, up to
128 characters, and is unique per user. Retrying the same command with the same
operation_id returns the same result identity and original seq without
repeating the mutation or event; its message is the current snapshot. Reusing
the identifier for another command returns conflict. The server remembers an
operation_id for at least 30 days; a retry after that is a new operation.
Message Kinds
| Kind | Description |
|---|---|
text |
Text message |
file |
Stored file attachment |
transfer |
Record of a direct transfer, written by the server |
Message Structure
All messages use a unified structure with payload containing kind-specific
content. A message goes to a conversation of any kind, named by its ID; a
direct conversation's ID is the server's to give (see Conversations, Groups
and Channels):
{
"type": "message.send",
"data": {
"operation_id": "0195d1a0-...",
"conversation_id": "direct:12345:67890",
"kind": "<message_kind>",
"payload": { ... },
"reply_to": "12344"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
operation_id |
string | Yes | Idempotency key generated once for this logical operation |
conversation_id |
string | Yes | The conversation written to: direct, group or channel |
kind |
string | Yes | Message kind: text, file, etc. |
payload |
object | Yes | Kind-specific content object |
reply_to |
string | No | Message in the same conversation being replied to |
keyboard |
array | No | A bot's buttons under a text message |
A message in a direct conversation nobody has written in yet makes the
conversation: it becomes both participants', who are told so by
conversation.created. Returned messages carry their conversation_id and
from. Every group member may send. In a channel, only owner, admin, and
editor may send.
Send Text Message
Request:
{
"id": "10",
"type": "message.send",
"data": {
"operation_id": "0195d1a0-send-text",
"conversation_id": "direct:12345:67890",
"kind": "text",
"payload": {
"text": "Hello!"
}
}
}
Text Payload:
| Field | Required | Description |
|---|---|---|
text |
Yes | Message text content |
mentions |
No | Ordered individual mention ranges {user_id, offset, length} |
smileys |
No | Ordered smiley ranges {pack_id, smiley_id, offset, length} |
Mention and smiley offsets and lengths are measured in Unicode code points. This keeps the contract independent of UTF-8 byte length and UTF-16 implementation details.
Success:
{
"id": "10",
"type": "message.send",
"conversation_id": "direct:12345:67890",
"seq": "41",
"ok": true,
"data": {
"message": {
"msg_id": "12345",
"conversation_id": "direct:12345:67890",
"from": 67890,
"kind": "text",
"payload": {"text": "Hello!"},
"time": "2024-01-15T14:30:00Z",
"revision": 1,
"reactions": []
}
}
}
Failure:
{
"id": "10",
"type": "message.send",
"ok": false,
"error": {
"code": "user_not_found",
"message": "Recipient does not exist"
}
}
Send File Message
Attaches a previously completed stored file. Upload the bytes with
file.create, HTTP part uploads and file.complete first. The database claims
the ready file and creates its message atomically, so a file cannot be attached
twice and an incomplete upload never appears in history.
Request:
{
"id": "11",
"type": "message.send",
"data": {
"operation_id": "0195d1a0-send-file",
"conversation_id": "direct:12345:67890",
"kind": "file",
"payload": {
"file_id": "4dbf74d3bb384ca0a86b29479a2a45ea"
}
}
}
Request file payload:
| Field | Required | Description |
|---|---|---|
file_id |
Yes | Completed file owned by the sender |
voice |
No | Makes it a voice message |
Success:
{
"id": "11",
"type": "message.send",
"conversation_id": "direct:12345:67890",
"seq": "42",
"ok": true,
"data": {
"message": {
"msg_id": "12346",
"conversation_id": "direct:12345:67890",
"from": 67890,
"kind": "file",
"payload": {
"file_id": "4dbf74d3bb384ca0a86b29479a2a45ea",
"name": "photo.jpg",
"size": 2048000,
"content_type": "image/jpeg",
"available": true,
"media": {
"kind": "image",
"state": "ready",
"width": 1920,
"height": 1080,
"preview": {
"variant": "preview",
"content_type": "image/jpeg",
"width": 960,
"height": 540,
"size": 84217
}
}
},
"time": "2024-01-15T14:31:00Z",
"revision": 1,
"reactions": []
}
}
}
The response and all history/event representations expose server-authoritative
metadata. available is false for a deleted or legacy metadata-only file. The
optional media block is derived by the server from the stored bytes; it does
not change the message kind from file.
Voice Messages
A voice message is a file message whose file is a recording of its sender's.
It is sent like any file, with voice in the payload: what the sender's
client measured of the recording as it was made.
{"id":"12","type":"message.send","data":{"operation_id":"0195d1a0-send-voice","conversation_id":"direct:12345:67890","kind":"file","payload":{"file_id":"9c0e4b2a7f3d4e8a9b1c2d3e4f5a6b7c","voice":{"duration_ms":12400,"waveform":[2,5,9,14,20,26,31,24,17,11,6,3]}}}}
| Field | Description |
|---|---|
duration_ms |
How long the recording lasts: from 1 ms to an hour |
waveform |
How loud it was along the way: 1 to 64 levels of 0 to 31, in order |
The file must have been found a sound, or a container a sound may be in
(media.kind audio or video), else invalid_state. Clients record AAC in
MP4 where they can and Opus in WebM elsewhere; the server keeps the bytes as
they come. Every snapshot of the message carries voice in its payload as it
was sent, and so does file.updated for its file; a forwarded voice message
is one too. A voice message has no words of its own and cannot be edited.
A voice message's snapshot also says whether it was played, as its viewer sees
it: played is true once they played it, and, for its sender, once anybody
did. A client shows the ones its user has not played yet as such.
When its user starts playing somebody else's voice message, a client says so:
{"id":"13","type":"message.play","data":{"operation_id":"0195d1a0-play","msg_id":"12347"}}
The answer is the message as the caller now sees it, played true:
{"id":"13","type":"message.play","seq":"57","ok":true,"data":{"message":{"msg_id":"12347","conversation_id":"direct:12345:67890","from":12345,"kind":"file","payload":{"file_id":"9c0e4b2a7f3d4e8a9b1c2d3e4f5a6b7c","name":"Voice message.m4a","size":49152,"content_type":"audio/mp4","available":true,"media":{"kind":"audio","state":"ready","duration_ms":12400},"voice":{"duration_ms":12400,"waveform":[2,5,9,14,20,26,31,24,17,11,6,3]}},"time":"2024-01-15T14:32:00Z","revision":1,"reactions":[],"played":true}}}
Only a member who can see the message plays it, and only somebody else's:
playing one's own, or anything but a voice message, is invalid_state.
Playing it again changes nothing. The caller's devices are told by
message.played on their account stream, and so are the sender's, while they
are still in the conversation and see the message, the first time anybody's
play shows. A play made while invisible is held from the sender, as a read
is: it shows, and the sender is told, once its listener is seen again.
{"type":"message.played","seq":"57","data":{"conversation_id":"direct:12345:67890","msg_id":"12347"}}
Pushes and waiting reactions show a voice message as Voice message, and
shared content lists voice messages apart from other files.
Message Created (Push Event)
Server pushes a created message to the devices of every member of its
conversation — for a direct message, the recipient and the sender's other
devices. The initiating device receives the same snapshot in the command
response. Durable pushes carry conversation_id and seq, but no id or ok.
Text message:
{
"type": "message.created", "conversation_id": "direct:12345:67890", "seq": "41",
"data": {
"msg_id": "12345",
"conversation_id": "direct:12345:67890",
"from": 67890,
"kind": "text",
"payload": {
"text": "Hello!"
},
"time": "2024-01-15T14:30:00Z",
"revision": 1,
"reactions": []
}
}
File message:
{
"type": "message.created", "conversation_id": "direct:12345:67890", "seq": "42",
"data": {
"msg_id": "12346",
"conversation_id": "direct:12345:67890",
"from": 67890,
"kind": "file",
"payload": {
"file_id": "4dbf74d3bb384ca0a86b29479a2a45ea",
"name": "photo.jpg",
"size": 2048000,
"content_type": "image/jpeg",
"available": true,
"media": {"kind": "image", "state": "processing"}
},
"time": "2024-01-15T14:30:00Z",
"revision": 1,
"reactions": []
}
}
The msg_id is a unique identifier assigned by the server and used for message
lifecycle operations. Stored-file operations use file_id instead. A message
of Mandarin's that tells a code to sign in with says
"sign_in_code": true.
Delivery
Delivery is kept as a cursor, as reading is: how far each participant of a direct conversation has its messages on a device. A device reports that it has every message through one — once a page of history or a pull has reached it, whatever chat is open — and one report covers everything before it:
Request:
{
"id": "delivered-1",
"type": "delivery.set",
"data": {
"operation_id": "0195d1a0-delivered",
"through": "12345"
}
}
The message names its conversation, which must be direct: a group or channel
returns invalid_state, since nobody is told who has a group's messages. The
cursor never moves backwards; a report of a message at or before it changes
nothing and its answer carries no seq.
{
"id": "delivered-1", "type": "delivery.set", "conversation_id": "direct:12345:67890", "seq": "43", "ok": true,
"data": {
"conversation": {"conversation_id": "direct:12345:67890", "kind": "direct", "seq": "43", "shared": {...}, "membership": {...},
"read": {"unread_count": 1, "unread_mention_count": 0, "read_through": "12344", "read_at": "2026-08-05T09:00:00Z",
"delivered_through": "12345", "peer_delivered_through": "12344"},
"personal": {...}},
"delivery": {"conversation_id": "direct:12345:67890", "user_id": 12345, "through": "12345", "delivered_at": "2026-08-05T09:20:00Z"}
}
}
Both participants' devices receive the change as delivery.updated in the
conversation's stream, carrying the read group as each of them sees it:
{"type": "delivery.updated", "conversation_id": "direct:12345:67890", "seq": "43",
"data": {"conversation": {"conversation_id": "direct:12345:67890", "kind": "direct", "seq": "43",
"read": {"unread_count": 0, "unread_mention_count": 0, "read_through": "12345", "read_at": "2026-08-05T09:10:00Z",
"delivered_through": "12344", "peer_delivered_through": "12345"}},
"delivery": {"conversation_id": "direct:12345:67890", "user_id": 12345, "through": "12345", "delivered_at": "2026-08-05T09:20:00Z"}}}
A sender shows a message of theirs delivered when it is at or before the
peer's peer_delivered_through, and read when it is at or before
peer_read_through; reading covers delivery, so a message read is delivered
too. A participant who is invisible is answered with the
cursor where it was, and nobody is told anything: the delivery would give them
away, and their reading tells the other side as much once they are seen again.
Reply
Set reply_to on message.send. The referenced message must be visible to the
sender and belong to the same conversation. Message snapshots then include a
compact reply_to object with msg_id, from, kind, and payload. If the
viewer deleted the source, the reference contains deleted: true instead of
its payload.
Mentions
Mentions are structured text entities in groups and channels. The server never infers identity from mutable display names:
{
"kind": "text",
"payload": {
"text": "Проверь, @Alice",
"mentions": [{"user_id": 23456, "offset": 9, "length": 6}]
}
}
At most 32 ranges are accepted. Each range must start with @, be inside the
text and not overlap the preceding range. Mentioned users must be current
members when the message is created or edited. Existing mentions remain
readable if a member later leaves. Individual mentions are not accepted in
direct messages because the recipient is already addressed. Forwarding
preserves the visible text but removes its mention entities, preventing a
forward from notifying users in another conversation.
message.edit replaces the complete mention set atomically with the text and
revision. Global deletion removes mention entities; self-hidden messages do not
contribute to that viewer's mention count or search results. No separate push
type exists: normal message.created and message.edited snapshots carry the
current entities.
In a group or a channel, a reply addresses the author of the message it
replies to as a mention does: somebody else's reply to a message of the
viewer's that they can still see — not deleted for everybody, nor hidden by
them — counts in their
unread_mention_count, is found by mentioned_me, and is pushed to them where
their notifications are set to mentions. A message both replying to the
viewer and mentioning them counts once.
Smileys
A text marks the smileys in it as it marks mentions: by ranges over it, in
smileys, each naming a smiley of a pack. The text stays
readable on its own:
{
"kind": "text",
"payload": {
"text": "Привет :-) как дела",
"smileys": [{"pack_id": "kolobok", "smiley_id": "smile", "offset": 7, "length": 3}]
}
}
A range covers exactly its smiley's fallback or one of its codes, and
names a smiley of a pack of kind smileys, whether or not the pack still has
it. Ranges come in order and overlap neither each other nor a mention; a
message marks at most 100 of them. Smileys go in any conversation, direct ones
included. Whoever cannot draw a smiley shows the text it covers: an ICQ
client, a push, search, a client without the pack. Clients do not turn typed
text into smileys: a smiley is a picture only where its sender chose it, so
two packs may share a code, and a typed :-) stays text.
message.edit replaces the smileys with the text. A forwarded text and a
quoted one keep theirs; deleting a message for everybody removes them.
An ICQ client types its koloboks as text codes. The server marks the codes the
kolobok pack has in what such a client sends, so its smileys reach everybody
else as pictures; what goes to it is the text alone.
Forward
message.forward creates an independent copy in another conversation:
{"id":"forward-1","type":"message.forward","data":{"operation_id":"0195d1a0-forward","msg_id":"12345","conversation_id":"direct:12345:77777"}}
The response and message.created event use the normal message snapshot and
include forwarded_from. Text and stored-file messages are forwarded. A
forwarded file shows the same stored bytes in an attachment of the forwarder's
own: the copy has a file_id of its own, which those who see the copy may
fetch, and its bytes are not copied. A direct transfer's record cannot be
forwarded: its bytes were never stored.
Edit
Only the sender can edit a non-deleted text message. revision is optimistic
concurrency control: it must equal the snapshot being edited.
{"id":"edit-1","type":"message.edit","data":{"operation_id":"0195d1a0-edit","msg_id":"12345","text":"Corrected text","mentions":[],"revision":1}}
The edit carries the text's mentions and smileys as message.send does,
replacing the ones it had. Success returns revision 2 with edited_at and
emits message.edited. A stale
revision returns conflict so the client can reload before retrying. A bot's
edit carries the buttons the message is to have.
Delete
{"id":"delete-1","type":"message.delete","data":{"operation_id":"0195d1a0-delete","msg_id":"12345","scope":"self"}}
scope is:
self— hide the message for this user on all their devices; the other party keeps it. The user's devices receivemessage.deletedthrough the account stream, withmsg_id,conversation_id,scope, andactor_id.all— sender-only tombstone. Content and reactions are removed, revision is incremented, and every member receivesmessage.deletedwith the tombstone inmessage.
Reactions
message.react sets the caller's reaction to the requested state. Repeating
the same state is a no-op.
{"id":"react-1","type":"message.react","data":{"operation_id":"0195d1a0-react","msg_id":"12345","reaction":"👍","active":true}}
The response and message.reacted event carry the full current message.
reactions is an array of {reaction, user_ids} aggregates.
Waiting Reactions
Somebody else's reaction to a message of the user's waits for them to see it, in a direct chat or a group; in a channel reactions are the audience's and wait for nobody. A bot has no screen to see them on: reactions to its messages wait for nothing, and reach it in its queue. A reaction waits only while its author is a member and can see the message: none starts waiting on a message they hid, and none put while they were away waits once they are back.
What waits is the user's own, in the personal part of their snapshot of the
conversation:
"personal": {
"notification": {"level": "all"},
"draft": null,
"waiting_reactions": [
{"msg_id": "12345", "user_id": 23456, "reaction": "👍", "reacted_at": "2026-08-05T09:20:00Z", "preview": "See you at seven"},
{"msg_id": "12345", "user_id": 34567, "reaction": "❤️", "reacted_at": "2026-08-05T09:21:00Z", "preview": "See you at seven"}
],
"waiting_reaction_count": 1
}
| Field | Type | Description |
|---|---|---|
waiting_reactions |
array | The newest 100 waiting reactions, listed by message and on one message by when they came: who reacted, with what, when, and the start of what the message says, as a push previews it |
waiting_reaction_count |
number | How many messages have any reaction waiting |
Both are left out while nothing waits. A client takes the user to the messages in this order and shows what is newest among them where it lists the conversation.
Once a message with waiting reactions has been on the user's screen, the client reports them seen:
{"id":"seen-1","type":"reactions.see","data":{"operation_id":"0195d1a0-seen","msg_id":"12345"}}
{"id":"seen-1","type":"reactions.see","seq":"43","ok":true,"data":{"conversation":{...}}}
The message names the conversation. Every reaction waiting on it stops waiting,
and the answer carries the user's snapshot of the conversation. Seeing them
again, or reporting a message of somebody else's, changes nothing; a message
in no conversation of the user's returns not_found.
A reaction also stops waiting when it is taken back, when the message is
deleted for everybody, when its author hides it or clears it from their
history, and when they leave or are removed or banned. Whenever what waits
changes but by the user leaving, their devices receive durable
reactions.updated through the account stream, with what that stream carries
of the conversation as it stands; a muted conversation changes how the user is
pushed, not what waits.
Message History
Request:
{
"id": "20",
"type": "message.list",
"data": {
"conversation_id": "direct:12345:67890",
"limit": 50,
"before": "12344"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
conversation_id |
string | Yes | The conversation whose history to read: direct, group or channel |
limit |
number | No | Max messages to return (default: 50, maximum: 100) |
before |
string | No | Positive decimal msg_id; load messages older than this message |
after |
string | No | Non-negative decimal msg_id; load messages newer than this one, 0 from the start of the conversation |
around |
string | No | Positive decimal msg_id; load this visible message and nearby context |
Success:
{
"id": "20",
"type": "message.list",
"ok": true,
"data": {
"conversation_id": "direct:12345:67890",
"has_more": true,
"messages": [
{
"msg_id": "12344",
"conversation_id": "direct:12345:67890",
"from": 12345,
"kind": "text",
"payload": {
"text": "Check this photo!"
},
"time": "2024-01-15T14:00:00Z",
"revision": 1,
"reactions": []
},
{
"msg_id": "12345",
"conversation_id": "direct:12345:67890",
"from": 12345,
"kind": "file",
"payload": {
"name": "photo.jpg",
"size": 2048000
},
"time": "2024-01-15T14:01:00Z",
"revision": 1,
"reactions": []
},
{
"msg_id": "12346",
"conversation_id": "direct:12345:67890",
"from": 67890,
"kind": "text",
"payload": {
"text": "Nice!"
},
"time": "2024-01-15T14:02:00Z",
"revision": 1,
"reactions": []
}
]
}
}
| Field | Type | Description |
|---|---|---|
has_more |
boolean | true if more messages remain in the direction being walked: older ones by default, newer ones with after |
messages |
array | Messages ordered by time (oldest first) |
Pagination: To load older messages, pass the msg_id of the oldest message
you have as before. The server returns the next batch of older messages. An
invalid, zero, or negative cursor is rejected with invalid_format.
To walk a conversation the other way — towards its newest message rather than
away from it — pass after instead. The page still arrives oldest first, and
has_more then reports that newer messages remain, so the walk continues by
passing the msg_id of the last message received. after: "0" starts at the
beginning of the conversation; no message ever carries that id. This is how a
client that knows how far it has read collects what follows without reading the
history in front of it. A negative or malformed cursor is rejected with
invalid_format.
To open a search result in context, pass its msg_id as around. The server
returns the target and up to limit - 1 visible messages around it, in
chronological order. has_more retains its normal meaning: more older messages
exist before the returned window. before, after and around are mutually
exclusive. The target must belong to the selected direct or group/channel
conversation and be visible to the caller; otherwise the request fails with
not_found.
History uses exactly the same message structure as mutation responses and
durable events. File payloads contain file_id, server-authoritative metadata,
available and the optional derived media descriptor. A direct transfer
appears only as its record. The history of a direct
conversation nobody has written in yet is an empty page.
Message Search
message.search finds messages visible to the caller. Without
conversation_id it searches all of the caller's conversations; with it, that
one conversation of any kind.
{
"id": "search-messages-1",
"type": "message.search",
"data": {
"query": "roadmap",
"conversation_id": "c_38a2f37d9a17e9230d113bb0f8931b2a",
"sender_id": 12345,
"kind": "text",
"limit": 20
}
}
| Field | Type | Required | Description |
|---|---|---|---|
query |
string | Conditional | Case-insensitive substring, 2–64 characters after trimming |
conversation_id |
string | No | Restrict to this conversation |
sender_id |
number | No | Restrict to messages sent by this user |
kind |
string | No | text, file or transfer; omit to search all |
mentioned_me |
boolean | No | Return only messages that structurally mention the caller |
shared |
string | No | media, files, voice or links: list what the conversation shares |
cursor |
string | No | Opaque cursor returned by the preceding identical search |
limit |
number | No | Page size, default 20, maximum 50 |
At least query, mentioned_me: true or shared is required. This permits a global or
conversation-scoped mentions view without parsing display text:
{"id":"my-mentions","type":"message.search","data":{"conversation_id":"c_38a2…","mentioned_me":true}}
shared lists what a conversation shares, as a messenger's chat info shows it,
and needs conversation_id: media is the pictures and videos
sent as files, voice the voice messages, files every other file, and links the messages whose text
has a link — an address with a scheme or www., or a domain name under a
familiar top-level domain, as clients make links of. Files whose bytes are gone
are left out. A query narrows the listing, such as files by name:
{"id":"chat-media","type":"message.search","data":{"conversation_id":"direct:12345:23456","shared":"media","limit":30}}
Text messages are matched by text, and file messages and transfer records by file name. Results are newest first.
{
"id": "search-messages-1",
"type": "message.search",
"ok": true,
"data": {
"hits": [
{
"conversation": {
"conversation_id": "c_38a2f37d9a17e9230d113bb0f8931b2a",
"kind": "group",
"seq": "125",
"shared": {"title": "Design team", "description": "", "avatar_hash": "", "primary_invite": "", "created_by": 12345,
"member_count": 4, "slow_mode_seconds": 0, "created_at": "2026-08-04T09:00:00Z", "updated_at": "2026-08-05T10:00:00Z"},
"membership": {"role": "member", "capabilities": ["conversation.get", "member.list", "message.list", "message.send"],
"posting_restriction": null, "next_send_at": ""},
"read": {"unread_count": 0, "unread_mention_count": 0, "read_through": "12345", "read_at": "2026-08-05T10:00:00Z"},
"personal": {"notification": {"level": "all"}, "draft": null}
},
"message": {
"msg_id": "12345",
"conversation_id": "c_38a2f37d9a17e9230d113bb0f8931b2a",
"from": 12345,
"kind": "text",
"payload": {"text": "Roadmap review"},
"time": "2026-08-05T10:00:00Z",
"revision": 1,
"reactions": []
}
}
],
"next_cursor": "eyJzY29wZSI6Im1lc3NhZ2UiLCJpZCI6IjEyMzQ1In0"
}
}
Every hit includes the normal recipient-specific conversation snapshot and the
normal message snapshot, so clients need no secondary lookup before rendering.
next_cursor is omitted after the last page. A cursor is opaque and must only
be reused with the same query and filters.
Search applies the same authorization and visibility rules as history: removed
members cannot search a conversation, self-hidden or cleared messages are
excluded, and globally deleted messages have no searchable content. Edits are
reflected immediately. Existing direct history remains visible after a block,
while the conversation snapshot does not expose newly changed blocked profile
data. Search is read-only: its response and message.list {around} carry no
seq and produce no durable event.
Clear History
Request:
{
"id": "21",
"type": "message.clear",
"data": {
"operation_id": "0195d1a0-clear",
"conversation_id": "direct:12345:67890"
}
}
Success:
{"id":"21","type":"message.clear","seq":"25","ok":true,"data":{"conversation_id":"direct:12345:67890","through":"12346"}}
Clearing is a per-user soft delete: it does not remove the other party's
history. Other devices of the same user receive
{"type":"message.cleared","seq":"25","data":{"conversation_id":"direct:12345:67890","through":"12346"}}
through the account stream, and the answer carries the same. through is the
last message the clear hid: a device hides every message up to it, including
ones it learns about later. In a group or channel, too, only that member's view
is cleared.
Read State and Unread Counts
Read state belongs to a conversation member and is represented by a monotonic, inclusive message cursor. To mark everything through a visible message as read, send:
{
"id": "22",
"type": "read.set", "delivery.set",
"data": {
"operation_id": "0195d1a0-read",
"through": "12346"
}
}
The message identifies its conversation, so the request needs no separate
conversation_id. The cursor never moves backwards. Retrying the
same operation_id is idempotent; a different operation with an older
through returns the current state without emitting another event.
{
"id": "22",
"type": "read.set", "delivery.set",
"conversation_id": "direct:12345:67890",
"seq": "45",
"ok": true,
"data": {
"conversation": {
"conversation_id": "direct:12345:67890",
"kind": "direct",
"seq": "45",
"shared": {"title": "Bob", "description": "", "avatar_hash": "", "primary_invite": "", "created_by": 12345,
"member_count": 2, "peer_id": 67890, "slow_mode_seconds": 0,
"created_at": "2026-08-01T10:00:00Z", "updated_at": "2026-08-05T09:18:00Z"},
"membership": {"role": "member", "capabilities": ["conversation.get", "member.list", "message.list", "message.send"],
"posting_restriction": null, "next_send_at": ""},
"read": {"unread_count": 0, "unread_mention_count": 0, "read_through": "12346", "peer_read_through": "12344",
"read_at": "2026-08-05T09:20:00Z"},
"personal": {"notification": {"level": "all"}, "draft": null}
},
"read": {
"conversation_id": "direct:12345:67890",
"user_id": 12345,
"through": "12346",
"read_at": "2026-08-05T09:20:00Z"
}
}
}
The caller's other devices receive the same change as durable read.updated.
In a direct conversation both cursors are shared state: the event belongs to
the conversation stream, and the peer takes it as a read receipt. In a group or
channel a member's cursor is theirs alone, and the event goes to the reader's
account stream. So does a direct conversation's while the reader is invisible:
the peer's peer_read_through stays where it was until they are visible
again, when read.updated goes to the conversation stream with the cursor as
it then stands, dated then:
{"type":"read.updated","conversation_id":"direct:12345:67890","seq":"45","data":{"conversation":<the conversation, as the stream changes it>,"read":{"conversation_id":"direct:12345:67890","user_id":12345,"through":"12346","read_at":"2026-08-05T09:20:00Z"}}}
conversation.list and conversation.get return authoritative unread counts,
and the events that change them carry them as of their seq
(Streams). unread_count counts visible incoming messages after
read_through; own messages, globally deleted messages and messages hidden by
this user are excluded. Clearing history does not move the cursor: the messages
it hides stop counting because they are hidden, and a peer is not told they
were read. New group/channel members start at the current end and do not
inherit an artificial unread backlog.
Delivery and reading are deliberately separate. delivery.set
moves a cursor of its own; it does not change read_through or
unread_count.
Pinned Messages
A pin is shared conversation state. Direct participants may pin and unpin; groups and channels restrict those actions to owners and admins. Every member may list pins. A conversation holds at most 50 pins.
{"id":"p1","type":"pin.set","data":{"operation_id":"pin-12346","msg_id":"12346"}}
The message must be visible, available and belong to a conversation the caller may manage. Success returns:
{"id":"p1","type":"pin.set","conversation_id":"direct:12345:67890","seq":"46","ok":true,"data":{"pin":{"conversation_id":"direct:12345:67890","msg_id":"12346","pinned_by":12345,"pinned_at":"2026-08-05T09:22:00Z"}}}
Use the same message ID to remove it:
{"id":"p2","type":"pin.remove","data":{"operation_id":"unpin-12346","msg_id":"12346"}}
List pins by the canonical conversation ID returned in conversation and message snapshots:
{"id":"p3","type":"pin.list","data":{"conversation_id":"direct:12345:67890"}}
The response is {"pins":[...]} in newest-pin-first order. Each pin includes
the current visible message snapshot in addition to its metadata. Hidden or
globally deleted messages are omitted. Global message deletion atomically
removes its pin; clients also discard a pin when applying the corresponding
message.deleted event.
Members receive durable pin.added and pin.removed events containing the
pin metadata. Pin mutations use
the standard operation_id retry semantics.
Conversation Notification Settings
Notification settings are personal conversation state and synchronize across the user's devices. They do not suppress message delivery or durable sync; clients use them only to decide whether to surface an OS/browser notification.
level is all, mentions, or none. mentions is valid only for groups and
channels and surfaces new messages only when their structured entity targets
the current user. Direct chats use all or none.
Mute indefinitely:
{"id":"n1","type":"notification.set","data":{"operation_id":"mute-team","conversation_id":"c_38a2…","level":"none"}}
Mute temporarily by adding a future UTC timestamp, at most one year ahead:
{"id":"n2","type":"notification.set","data":{"operation_id":"mute-team-8h","conversation_id":"c_38a2…","level":"none","mute_until":"2026-08-05T17:30:00Z"}}
mute_until may accompany mentions or none; after it expires clients treat
the setting as all. Unmute explicitly with level: "all", for which
mute_until must be omitted. Success returns
the recipient-specific conversation snapshot and a notification object
containing conversation_id, user_id, level, and optional mute_until.
Other devices of the same account receive durable notification.updated
through the account stream; no other conversation member receives the setting
or event. Repeating an
identical setting is a valid no-op and emits no new event.
Synchronized Drafts
A draft is private per-user state attached to a conversation and synchronized across that user's devices:
{"id":"d1","type":"draft.update","data":{"operation_id":"draft-team-1","conversation_id":"c_38a2…","text":"I will finish this later","reply_to":"12346"}}
conversation_id is required. text may hold up to 12288 characters,
three messages' worth: a draft is what is still being written, which goes as
several messages once sent (see Field Limits). reply_to,
when present, must identify a visible message in the same conversation. Empty
text without reply_to clears the draft:
A draft carries the text's smileys as a message does.
Group and channel drafts may also carry the same mentions ranges as a text
message. They are synchronized privately so moving to another device does not
turn selected users back into ambiguous plain text. Direct drafts cannot carry
mentions. When a target leaves or is banned, its draft entities are removed
while their visible text remains unchanged.
{"id":"d2","type":"draft.update","data":{"operation_id":"clear-draft-team-1","conversation_id":"c_38a2…","text":""}}
Success returns the recipient-specific conversation snapshot and a draft
object with conversation_id, user_id, text, optional reply_to, and
updated_at. A clear response additionally has cleared: true, while the
conversation snapshot's personal.draft is null. Other sessions of the same account
receive durable draft.updated through the account stream; no other member can
read the draft or receive its event. Identical content is a no-op and creates no event.
A draft in a direct chat nobody has written in yet makes the chat the
drafter's for as long as the draft lasts: after draft.updated, their account
stream announces it with conversation.created, and clearing that draft
withdraws it with conversation.deleted.