Message format
The fields of a message and the body format of each type. Send messages, Query and export messages, Recall, edit and pin and the conversation APIs all use these definitions.
| You want to know | See |
|---|---|
How to write body for images, files and other messages | Message types |
| Where attachment addresses come from and what happens when they expire | Attachment addresses |
| What each field of the returned message object means | Message object |
The difference between conversation_id, seq and message_id | Conversation ID |
| Group notices like "Zhang San joined the group" | Group notices |
| How long messages are kept | Message retention |
Messages come from three kinds of sources, all in the same format, told apart by the via field:
| Source | via |
|---|---|
| A user sending from a client | client |
| Your server via OpenAPI (as a user or as the system) | openapi |
| The console | console |
| Group notices and call records written automatically by the service | system |
Conversation ID
A conversation is the stream of messages between two users (one-to-one) or in a group (group chat), identified by conversation_id, with conversation_type single or group. Three IDs that are easy to mix up:
| ID | Scope | Used for |
|---|---|---|
conversation_id | One conversation | Querying history, marking as read, deleting conversations |
seq | Within a conversation, counting up from 1 | Paging, detecting missed messages, replies |
message_id | Unique across the app | Querying by ID, recall, edit, pin, export |
How to get a conversation ID:
| Conversation | How |
|---|---|
| One-to-one | conversation_id in the Send messages response, or query the conversation between two usernames or a user's conversation list, see Conversations and history. The ID is generated by the server and can't be derived from the usernames; if the two users have never exchanged a message, there's no conversation and the query returns 404 not_found |
| Group | It's the group ID (group_id), usable as soon as the group exists |
If a user is deleted and the same username is registered again, the new user has a different conversation with the other person and can't see the old messages.
Sequence seq
Each message has a sequence seq in its conversation, counting up from 1 without gaps; both sides of a one-to-one chat see the same numbers. Group notices take sequences too; online-only messages aren't stored and have none.
- Sequences are only unique within a conversation. To identify a message across the app, use
message_id. - Clients use sequences to detect missed messages, and history is paged by sequence, see Conversations and history.
Message ID
message_id is a string ID unique across the app that roughly increases with send time. Server APIs for querying by ID, recall, edit and pin use it, and export follows its order.
Message types
type | Description | Who can send |
|---|---|---|
text | Text | Clients, server |
image | Image | Clients, server |
voice | Voice | Clients, server |
video | Video | Clients, server |
file | File | Clients, server |
location | Location | Clients, server |
custom | Custom message with your own structure, such as order cards, red packets or business notices | Clients, server |
tip | Group notice, written automatically when a group changes, see Group notices | System only |
call | Call record, written automatically after an audio or video call, see Call records | System only |
Sending tip, call or any unsupported type returns 400 invalid_argument with details.reason unknown_type. More types may be added later; clients should show unknown types as "This message isn't supported in this version" rather than dropping them, or sequences in the conversation will have gaps.
Every type's body is a JSON object with the fields below. Fields not listed aren't checked and are stored and returned as is, so you can use them to carry your own data. Files for image, voice, video and file messages are uploaded first and their addresses written into body, see Attachment addresses.
Text text
| Field | Type | Required | Description |
|---|---|---|---|
text | String | Yes | The text; can't be empty or only whitespace. Line feeds, carriage returns and tabs are allowed |
{ "text": "你好,明天上午十点评审" }The sender writes "@Zhang San" into the text themself; the server only uses mentions to decide who was mentioned.
Image image
| Field | Type | Required | Description |
|---|---|---|---|
url | String | Yes | Image address: the file address from the upload, the url of the file object |
thumbnail_url | String | No | Thumbnail address: the file object's thumbnail_url (long edge at most 480 pixels) |
width | Number | No | Width in pixels, a non-negative integer; take the file object's width |
height | Number | No | Height in pixels, a non-negative integer; take the file object's height |
size | Number | No | File size in bytes, a non-negative integer; take the file object's size |
format | String | No | Format, such as jpg or png; you can derive it from the file object's content_type |
{
"url": "https://im.example.com/media/v1/f/99606017553203200/5nOzK6E40hgYIKl80dYVLQ",
"thumbnail_url": "https://im.example.com/media/v1/f/99606017553203200/5nOzK6E40hgYIKl80dYVLQ/thumb",
"width": 1080,
"height": 720,
"size": 218935,
"format": "jpg"
}Use the dimensions and size from the file object: the server strips metadata such as capture information, so the stored size may differ from the original file.
Voice voice
| Field | Type | Required | Description |
|---|---|---|---|
url | String | Yes | The voice file's address |
duration_seconds | Number | Yes | Duration in seconds, an integer from 0 to 86400. The server doesn't parse audio; the sender fills this in |
size | Number | No | File size in bytes, a non-negative integer |
format | String | No | Format, such as amr or aac |
{
"url": "https://im.example.com/media/v1/f/99607894634266624/BW6oGMSauXheems_KKZ5HQ",
"duration_seconds": 3,
"size": 48044,
"format": "wav"
}Video video
| Field | Type | Required | Description |
|---|---|---|---|
url | String | Yes | The video file's address |
duration_seconds | Number | Yes | Duration in seconds, an integer from 0 to 86400. The server doesn't parse video; the sender fills this in |
thumbnail_url | String | No | Cover image address. The server doesn't capture covers: grab a frame on the client, upload it as an image, and use its file or thumbnail address |
width | Number | No | Width in pixels, a non-negative integer |
height | Number | No | Height in pixels, a non-negative integer |
size | Number | No | File size in bytes, a non-negative integer |
format | String | No | Format, such as mp4 |
{
"url": "https://im.example.com/media/v1/f/99607911269875712/q0Y8RkM2c1ZbJx4TnW6aPg",
"duration_seconds": 30,
"thumbnail_url": "https://im.example.com/media/v1/f/99607911324401664/Hk7dVw2LmQ9sTzR5yB1cNe/thumb",
"width": 1280,
"height": 720,
"size": 3048000,
"format": "mp4"
}File file
| Field | Type | Required | Description |
|---|---|---|---|
url | String | Yes | The file address |
name | String | Yes | File name: 1 to 255 characters, not only whitespace, without /, \, control characters or text-direction characters (U+202A to U+202E, U+2066 to U+2069). Same rules as name when uploading; usually the uploaded file's name |
size | Number | No | File size in bytes, a non-negative integer |
format | String | No | Format, such as pdf |
{
"url": "https://im.example.com/media/v1/f/99606363390345216/qKZTLgqgze82AN96YOCJzg",
"name": "季度报告.pdf",
"size": 1048576,
"format": "pdf"
}Location location
| Field | Type | Required | Description |
|---|---|---|---|
latitude | Number | Yes | Latitude, -90 to 90 |
longitude | Number | Yes | Longitude, -180 to 180 |
name | String | No | Place name |
address | String | No | Full address |
{ "latitude": 39.9087, "longitude": 116.3975, "name": "天安门", "address": "北京市东城区" }Custom custom
body can be any JSON object with a structure you define, limited only by the general rules below.
{ "card": "order_shipped", "order_id": "8812", "title": "你的订单已发货" }Trusting business cards
The sender decides what a custom message contains, and users can send cards that look like "transfers" or "orders" from a client. For cards about money or order status, clients should only trust messages whose via isn't client, meaning they were written by your server or the system.
Call record call
After an audio or video call ends, the service writes a call record into the conversation (via is system and client_msg_id starts with sys_). Neither clients nor your server can send this type.
| Call | Written to | Sender |
|---|---|---|
| One-to-one | The two users' one-to-one conversation | The caller |
| Group call | The group conversation | Whoever started it; if they've left the group, it's written as the system (sender is null, sender_type is system) |
For which calls are recorded, whether they count as unread and how to turn them off, see Call records.
| Field | Type | Description |
|---|---|---|
call_id | String | The call ID, usable to query call details |
call_type | String | single one-to-one / group group call |
media | String | audio / video |
result | String | Why the call ended, see End reasons |
duration_seconds | Number | Call duration in seconds; 0 if never connected |
started_at | String | When the call was started |
{
"call_id": "100313666666102784",
"call_type": "group",
"media": "video",
"result": "completed",
"duration_seconds": 32,
"started_at": "2026-10-04T19:30:41.871Z"
}General content rules
- Addresses:
urlandthumbnail_urlmust behttporhttpsaddresses with a host name, at most 2048 characters. The server doesn't fetch them or check that the file exists; withmedia_url_onlyenabled, addresses sent from clients have extra limits, see Attachment addresses. - JSON objects:
bodyandextmust both be JSON objects, nested at most 10 levels, with at most 256 keys in total and no duplicate keys. - Control characters: no string (keys included) may contain control characters; only
textin text messages allows line feeds, carriage returns and tabs. - Numbers: dimensions, sizes and durations must be non-negative integers without decimals.
Violations return 400 invalid_argument with details.reason invalid_body and the offending field in details.field:
{
"error": {
"code": "invalid_argument",
"message": "url 必须是 http 或 https 地址,最长 2048 个字符",
"details": { "field": "url", "reason": "invalid_body" },
"request_id": "JB2OCKPROHY2SZ2Q4VNURTDZWW"
}
}Attachment addresses
Sending image, voice, video and file messages takes two steps:
- Upload the file with purpose
attachment(from a client, or from your server with Upload files) to get a file address likehttps://im.example.com/media/v1/f/{file_id}/{secret}; an image's thumbnail address is the file address plus/thumb. - Put the addresses in
urlandthumbnail_urlinbody, then send the message.
Files aren't checked when sending: the server only checks the address format, not whether the file exists or finished uploading. Use the address returned after the upload completes; with a wrong address the message is still sent and the recipient's download fails.
Downloading: file addresses can't be downloaded directly. Clients exchange them for a short-lived download address with a User Token (or request the file address with the User Token and follow the 302); your server exchanges them via OpenAPI, see Download and manage files. Any user signed in to the app can exchange an address they have, regardless of conversation; only group files require group membership.
File states and what an exchange returns:
| State | When | Exchanging for a download address | Requesting the file address directly | What clients show |
|---|---|---|---|---|
| Expired | More than attachment_retention_days days after upload (runtime policy, 30 by default), or deleted by your server | file_expired; not_found about 30 days after deletion | 410 | "File expired" |
| Blocked | Blocked for violating rules | file_blocked | 403 | "File blocked" |
When a file expires, is deleted or blocked, the message itself and the address in body don't change; recall the message separately if needed. Recalling a message doesn't delete its attachments.
External addresses: unless the app enables media_url_only (off by default), url and thumbnail_url can also be any http or https address, stored as is and never fetched. Such files don't go through this service and aren't subject to the retention and blocking above.
Address restrictions: when the app enables media_url_only in its runtime policy, image, voice, video and file messages sent from clients may only use this service's file or thumbnail addresses, or https addresses on hosts in media_allowed_hosts (*.example.com matches any single-level subdomain). Otherwise you get 400 invalid_argument with details.reason url_not_allowed and the field in details.field. The check only looks at the address, not the file, and doesn't apply to messages sent by your server.
{
"error": {
"code": "invalid_argument",
"message": "附件地址不被允许",
"details": { "field": "thumbnail_url", "reason": "url_not_allowed" },
"request_id": "AK2OWVVKVS4JWVKUNOGDRTGPMR"
}
}Extension fields ext
Any message can carry ext: your own extension content (a JSON object), such as a business ID, a message source or a forwarding source. It follows the same rules as body and is stored and returned as is. Leave it out or pass null when not needed.
Mentions
Group messages can mention members or everyone with mentions; one-to-one messages can't (invalid_argument, mentions_not_allowed).
| Field | Type | Description |
|---|---|---|
usernames | Array<String> | Usernames of mentioned members, up to 20 |
all | Boolean | Whether everyone is mentioned; default false |
- Users who aren't members (including nonexistent usernames) are ignored without an error;
mentions.usernamesin the response only lists members actually mentioned. - A mentioned member's conversation shows "You were mentioned" until they read the message, see Conversations and history. If the message is recalled, the reminder falls back to the previous mention that's still valid.
- In clients only owners and admins can mention everyone; messages from your server aren't limited.
Replies
A message can reply to another message in the same conversation with reply_to; the request only needs the sequence:
"reply_to": { "seq": 3 }The replied-to message must exist, not be recalled or erased, and be within the retention period; when a sender is given, it must also be visible to the sender and not deleted by them. Otherwise you get invalid_argument with details.reason invalid_reply.
The server only stores the replied-to message's sequence and sender, returned as { "seq": 3, "sender": "alice" }. Clients fetch the original by sequence from local storage or the server; if it's recalled later, they show a notice without leaking the recalled content.
Size limits
A message's type, body, ext, mentions and reply_to together, measured in the server's JSON encoding (no whitespace, UTF-8 bytes, <, > and & unescaped), can't exceed max_message_body_bytes in the app's runtime policy: 5120 bytes (5 KB) by default, configurable from 1024 to 32768 bytes, see Runtime policy. Over the limit you get 413 payload_too_large with the current limit in details.max_bytes:
{
"error": {
"code": "payload_too_large",
"message": "消息超过大小上限",
"details": { "max_bytes": 5120 },
"request_id": "NVZEB2A22BM63WPDECDBUIZ4P5"
}
}The push field when sending is counted separately, see Send messages.
Message object
Messages returned by send, query, export, recall, edit, pin and other APIs share one structure. Server APIs return the server's view: every message in the conversation, unaffected by users deleting messages or clearing chat history.
| Field | Type | Description |
|---|---|---|
conversation_id | String | The conversation's ID, see Conversation ID |
conversation_type | String | single one-to-one / group group chat |
seq | Number | The sequence in the conversation |
message_id | String | Message ID, unique across the app |
client_msg_id | String | The deduplication ID from sending. If your server didn't give one, the server generates it (32 hex characters); group notices start with evt_ and call records with sys_ |
sender | String | The sender's username. May be null for messages sent as the system and group notices, or if the sender was deleted |
sender_type | String | user / system. sender_type user with sender null means the sender was deleted |
recipient | String | The one-to-one recipient's username, so the sender's other devices know who it went to. null in groups and when the recipient was deleted |
type | String | Message type |
body | Object | The message body in its type's format. null for recalled or erased messages |
ext | Object | Extension fields; may be null |
mentions | Object | Mentions: usernames (deleted users excluded) and all. null without mentions |
reply_to | Object | Reply: seq and sender (the replied-to message's sender; null if sent by the system or deleted). null when not a reply |
need_receipt | Boolean | Whether group read receipts were requested |
exclude_from_unread | Boolean | Whether it's excluded from recipients' unread counts. Always true for group notices |
reactions | Array<Object> | Reactions, one entry per kind; null when none. See below |
pinned | Object | Pin info { "by": ..., "at": ... }: by is the user who pinned it (null when pinned by the server or console), at is when. null when not pinned |
edited | Object | Edit info { "at": ..., "count": ... }: time of the latest edit and total number of edits. null if never edited |
recalled | Object | Recall info { "by": ..., "role": ..., "at": ... }; null if not recalled. See below |
erased | Boolean | Whether it was erased. When the app erases deleted users' messages, everything a deleted user sent is erased and its content cleared |
via | String | How it was written: client / openapi server / console / system (group notices, call records) |
created_at | String | Send time. Within a conversation, a higher sequence is never earlier |
message_version | Number | The conversation's change version at this message's latest change (recall, edit, erase, reaction, pin); 0 if never changed. Clients use it to fetch changed messages incrementally |
reactionsentries look like{ "key": "thumbs_up", "count": 12, "users": ["lisi", null, "zhaoliu"] }:keyidentifies the reaction and is defined by your clients;countis how many people reacted;userslists the first 3, withnullfor deleted users. Users add reactions from clients, and the app must enablemessage_reaction_enabledin its runtime policy.recalled.roleis who recalled it:senderthe sender /ownergroup owner /admingroup admin /serveryour server or the console /platformthe platform. Recalls by moderation under your rules, or by your reviewers confirming a violation, areserver; recalls under platform rules areplatform.recalled.byis the user who recalled it,nullforserverandplatform.- After a recall or erase,
body,ext,mentions,reply_to,reactionsandpinnedare allnull; other fields stay.
A group message that replies to another message, mentions a member and has extension fields:
{
"conversation_id": "99582580415791104",
"conversation_type": "group",
"seq": 9,
"message_id": "99584445836689408",
"client_msg_id": "5f0c2a7e-91b4-4d2e-a0c3-6b1e8f2d4c19",
"sender": "bob",
"sender_type": "user",
"recipient": null,
"type": "text",
"body": { "text": "@carol 文档已更新,请确认" },
"ext": { "biz_id": "PRD-2041" },
"mentions": { "usernames": ["carol"], "all": false },
"reply_to": { "seq": 3, "sender": "alice" },
"need_receipt": false,
"exclude_from_unread": false,
"reactions": null,
"pinned": null,
"edited": null,
"recalled": null,
"erased": false,
"via": "openapi",
"created_at": "2026-10-02T19:13:02.076Z",
"message_version": 0
}A one-to-one message recalled by the server:
{
"conversation_id": "99583031936811008",
"conversation_type": "single",
"seq": 3,
"message_id": "99583385822822400",
"client_msg_id": "d83eb893f17d57d202db27cb79f46ef0",
"sender": "carol",
"sender_type": "user",
"recipient": "dave",
"type": "text",
"body": null,
"ext": null,
"mentions": null,
"reply_to": null,
"need_receipt": false,
"exclude_from_unread": false,
"reactions": null,
"pinned": null,
"edited": null,
"recalled": { "by": null, "role": "server", "at": "2026-10-02T19:08:59.798Z" },
"erased": false,
"via": "openapi",
"created_at": "2026-10-02T19:08:49.350Z",
"message_version": 22
}System messages
Messages with sender_type system and sender null are system messages, of two kinds:
- Group messages sent as the system: your server sends a group message without
from; it shows as a system notice in the group, withviaopenapi. - Group notices: see below;
typeistipandviaissystem.
When the person who started a group call has left the group, the call record is also written as the system.
Group notices
When a group is created, members join or leave, its profile changes or it's dissolved, the service writes a group notice (type tip) into the group conversation. body is structured data, and clients build the display text from kind, which makes multiple languages easy (the Web SDK's renderTip() already does this).
- Members who were offline see them in the history when they come back; removed members see the notice that removed them, and new members see the one for their own joining.
- They don't count as unread (
exclude_from_unreadis alwaystrue) and don't count toward the group's sending rate limit. - You can't send group notices (
unknown_type), but you can recall them. - Notices are usually written a few hundred milliseconds after the change; very rarely two arrive out of order, so clients can sort by
body.occurred_at.
Every body has kind (the kind of notice) and occurred_at (when the change happened), plus the fields below. Users always appear as usernames, with null for deleted users:
kind | Meaning | Other fields |
|---|---|---|
group_created | The group was created | operator: the owner |
members_joined | Members joined | operator: who invited them, or null with no inviter (initial members of a server-created group, join requests, server additions); members: who joined, at most the first 20; joined_count: how many joined; joined_via: create group creation / invite invitation / apply request / link invite link or QR code / server added by the server |
members_left | Members left, or their accounts were deleted | operator: null; members: who left, at most the first 20; removed_count: how many left |
members_removed | Members were removed (including being added to the group block list) | operator: who did it, null when done by the server; members and removed_count as above |
group_renamed | The group name changed | operator; name: the new name |
announcement_updated | The announcement was updated | operator |
owner_changed | Ownership was transferred | operator; owner: the new owner |
mute_all_changed | Mute-all was turned on or off | operator; enabled: whether it's on |
group_status_changed | The group was banned or unbanned | operator; status: disabled banned / active unbanned |
group_dismissed | The group was dissolved | dismissed_by: owner / tenant your server or console / platform / system |
The two notices after the server creates a group:
[
{
"conversation_id": "99582580415791104",
"conversation_type": "group",
"seq": 1,
"message_id": "99582580659060736",
"client_msg_id": "evt_group_created",
"sender": null,
"sender_type": "system",
"recipient": null,
"type": "tip",
"body": { "kind": "group_created", "occurred_at": "2026-10-02T19:05:37.326Z", "operator": "alice" },
"ext": null,
"mentions": null,
"reply_to": null,
"need_receipt": false,
"exclude_from_unread": true,
"reactions": null,
"pinned": null,
"edited": null,
"recalled": null,
"erased": false,
"via": "system",
"created_at": "2026-10-02T19:05:37.384Z",
"message_version": 0
},
{
"conversation_id": "99582580415791104",
"conversation_type": "group",
"seq": 2,
"message_id": "99582580872970240",
"client_msg_id": "evt_99582580453539840",
"sender": null,
"sender_type": "system",
"recipient": null,
"type": "tip",
"body": {
"kind": "members_joined",
"occurred_at": "2026-10-02T19:05:37.326Z",
"operator": null,
"members": ["bob", "carol"],
"joined_count": 2,
"joined_via": "create"
},
"ext": null,
"mentions": null,
"reply_to": null,
"need_receipt": false,
"exclude_from_unread": true,
"reactions": null,
"pinned": null,
"edited": null,
"recalled": null,
"erased": false,
"via": "system",
"created_at": "2026-10-02T19:05:37.435Z",
"message_version": 0
}
]Online-only messages
Messages sent with online_only set to true go only to the recipients' devices online at that moment, for custom signaling that doesn't need storing, such as "recording a voice message" or whiteboard actions:
customtype only, withoutmentions,reply_to,need_receipt,exclude_from_unreadorpush.- One-to-one messages go to the recipient and group messages to current members; when your server sends,
sync_to_senderdecides whether the sender's online devices get it too. - Not stored, no sequence, not counted as unread, and no conversation is created; offline devices never get them, even later.
- They can't be queried by ID, recalled or edited, and aren't deduplicated: recipients should deduplicate by sender plus
client_msg_id. - Checks and rate limits before sending are the same as for normal messages.
A successful send returns a short result, see Send messages.
Message retention
The server keeps messages for message_retention_days days from the app's runtime policy: 90 by default, configurable from 1 to 3650, see Runtime policy; never more than the message retention of the app's plan, see Plans and billing.
- Messages past retention can no longer be fetched, queried or exported, and querying by ID returns
404 not_found; they can't be replied to, recalled, edited or pinned either, and pinned ones drop out of the pin list. A background job then deletes them. - The conversation itself, users' read positions and conversation settings aren't deleted with the messages; two users chatting much later are still in the same conversation.
- Attachments uploaded to this service are kept separately for
attachment_retention_days(30 days by default, from upload). If an attachment expires first, the message stays and downloading shows the file as expired, see Attachment addresses. - Shortening retention can't be undone: after shortening (including switching to a plan with shorter message retention), older messages become unreadable immediately and are then deleted; setting the value back doesn't restore them.
- A recalled message's content is cleared immediately, and even the server can't retrieve it. If you need records, save them before recalling with export or query.
