Send messages
Send messages from your own server: one-to-one or group messages as a given user, group notices as the system, or one message each to a list of users. Typical uses are system notifications, order updates and customer service messages.
Quickstart
Send a text message from alice to bob (see the server overview for setting IM_API, ORG, APP and APP_TOKEN):
curl -X POST "$IM_API/$ORG/$APP/messages" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"from": "alice",
"to_user": "bob",
"client_msg_id": "doc-msg-0001",
"type": "text",
"body": { "text": "你好,明天上午十点评审" }
}'You get 201 Created and the stored message. bob's online devices receive it immediately; offline devices get it when they next sign in and sync, and devices with the app in the background also get an offline push. The full response is under Response.
Choose the fields by recipient:
| Send to | Fields | Sender |
|---|---|---|
| One user (one-to-one) | from + to_user | from |
| A group | from + to_group | from, who must be a member |
| A group, as the system | to_group only | The system; shown as a system notice in the group |
| Many users, one message each | from + to_users, with batch sending | from |
Always include client_msg_id
After a timeout you can't tell whether the previous attempt succeeded. Give every message a unique client_msg_id (an order number or a UUID) and send the same value when you retry; the server stores the message only once, so there are no duplicates.
Clients can tell where a message came from by its via field: messages sent by your server have via set to openapi.
Common scenarios
Send a business card from a notification account
The body of a custom message is yours to define, and your clients render it as a card by your own convention. When a notification account messages many users, set sync_to_sender to false; the note below the request body explains why:
curl -X POST "$IM_API/$ORG/$APP/messages" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"from": "notice",
"to_user": "carol",
"client_msg_id": "order-20261003-8812",
"type": "custom",
"body": { "card": "order_shipped", "order_id": "8812", "title": "你的订单已发货" },
"sync_to_sender": false
}'Users can also send custom messages that look the same from a client, so for cards about orders or money, clients should only trust ones sent by your server (in the Web SDK, _local.trusted is true).
Send an image
Two steps: first upload the file with purpose attachment, then copy url, thumbnail_url, width, height and size from the returned file object into body:
curl -X POST "$IM_API/$ORG/$APP/messages" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"from": "alice",
"to_user": "bob",
"client_msg_id": "doc-img-0001",
"type": "image",
"body": {
"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"
}
}'Voice, video and file messages work the same way; see Message format for their body fields. The server doesn't check that the file actually exists: with a wrong address the message is still sent, and the error only shows up when the recipient downloads it. See Attachments.
Send a group notice as the system
Leave out from and the message is posted to the group by the system. This notice also mentions everyone:
curl -X POST "$IM_API/$ORG/$APP/messages" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to_group": "99582580415791104",
"client_msg_id": "maintain-20261003",
"type": "text",
"body": { "text": "本群将于今晚 22:00 维护" },
"mentions": { "all": true }
}'Other scenarios
| You want to | Do this |
|---|---|
| Send a notice to hundreds of users, one message each | Batch sending |
| Send a notice that doesn't count as unread or alert anyone | exclude_from_unread: true |
| Set the push title and text, or skip the push | The push field, see The push field when sending |
| Reply to a specific message | reply_to: { "seq": sequence } |
| Ask group members for read receipts | need_receipt: true; group read receipts must be enabled in Runtime policy |
| Send signaling to online devices only, without storing it | online_only: true, see Online-only messages |
| Recall, edit or pin a sent message | Recall, edit and pin |
| Mark a conversation as read for a user | Mark as read |
Send a message
/{org_name}/{app_name}/messagesGive exactly one of to_user and to_group. One-to-one messages need from; a group message without from is sent as the system (sender is null and sender_type is system).
Path parameters
For org_name and app_name, see the server overview.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
from | String | For one-to-one | The sender's username. Leave it out of a group message to send as the system |
to_user | String | One of two | The one-to-one recipient; can't be from |
to_group | String | One of two | The group ID (the group conversation ID) |
type | String | Yes | text, image, voice, video, file, location or custom; see Message types |
body | Object | Yes | The message content; its format depends on type |
client_msg_id | String | Recommended | Deduplication ID: 1 to 64 visible ASCII characters, no spaces, not starting with evt_ or sys_. If you leave it out the server generates one, but then retries create duplicate messages |
ext | Object | No | Your own extension fields, stored as is; see Extension fields |
mentions | Object | No | Mentions in group messages: usernames (up to 20) or all |
reply_to | Object | No | Reply to a message in the same conversation: { "seq": sequence } |
need_receipt | Boolean | No | Ask for group read receipts; default false. Only for group messages with a sender |
exclude_from_unread | Boolean | No | Don't count it as unread for recipients; default false. It also won't bring back a conversation the recipient deleted |
push | Object | No | Offline push for this message, see the table below |
online_only | Boolean | No | Online only; default false. When true, the type must be custom and mentions, reply_to, need_receipt, exclude_from_unread and push aren't allowed |
sync_to_sender | Boolean | No | Sync to the sender's devices; default true. Only applies to one-to-one and online-only messages, see below |
push fields (the whole object is at most 2 KB):
| Field | Description |
|---|---|
disabled | true to skip the push |
force | true to push even if the recipient has Do Not Disturb on; can't be combined with disabled: true |
title | Notification title, 1 to 64 characters |
body | Notification text, 1 to 256 characters |
ext | Custom data for your app, a JSON object of at most 512 bytes |
See The push field when sending.
When to set sync_to_sender to false: when one system account sends notices to many users. With false, the message isn't pushed to the sender's devices and doesn't appear in their conversation list right away, though it's still there in history. Otherwise every message updates that account's conversation list, and sending to thousands of people in a short time slows down noticeably.
Response
On success you get 201 Created and the stored message object. Usually you only need to keep conversation_id, seq and message_id:
{
"conversation_id": "99582604130385920",
"conversation_type": "single",
"seq": 1,
"message_id": "99582604155551744",
"client_msg_id": "doc-msg-0001",
"sender": "alice",
"sender_type": "user",
"recipient": "bob",
"type": "text",
"body": { "text": "你好,明天上午十点评审" },
"ext": null,
"mentions": null,
"reply_to": null,
"need_receipt": false,
"exclude_from_unread": false,
"reactions": null,
"pinned": null,
"edited": null,
"recalled": null,
"erased": false,
"via": "openapi",
"created_at": "2026-10-02T19:05:42.986Z",
"message_version": 0
}| Case | Result |
|---|---|
| The first message between two users | Creates the one-to-one conversation; conversation_id is its ID |
A retry with the same client_msg_id | 200 OK with the originally stored message, even if the content differs; no permission or rate-limit checks and no quota used |
| Content replaced by moderation or rewritten by the before-send callback | The rewritten content is stored and delivered; rely on body and ext in the response |
online_only is true | 200 OK with the short result below; the message isn't stored and has no conversation ID or sequence |
Online-only messages return:
| Field | Type | Description |
|---|---|---|
message_id | String | The message ID, for identification only; it can't be queried |
client_msg_id | String | The value from the request; the same as message_id if you didn't send one |
created_at | String | When it was sent |
online_only | Boolean | Always true |
{
"message_id": "99582734451605504",
"client_msg_id": "sig-001",
"created_at": "2026-10-02T19:06:14.051Z",
"online_only": true
}Errors
Invalid requests return 400 invalid_argument; tell them apart by details.reason:
details.reason | Cause |
|---|---|
invalid_recipient | Both or neither of to_user and to_group were given |
from_required | A one-to-one message without from |
self_conversation | to_user is the sender |
unknown_type | Unsupported message type, including tip and call, which only the system writes |
invalid_body | The content (including after a callback rewrite) is invalid, or mentions.usernames has more than 20 entries; details.field names the field |
invalid_client_msg_id | client_msg_id has the wrong format |
reserved_client_msg_id | client_msg_id starts with evt_ or sys_ |
mentions_not_allowed | A one-to-one message has mentions |
receipt_not_allowed | A one-to-one message, or a group message sent as the system, asked for read receipts |
invalid_reply | The replied-to message doesn't exist, was recalled, has expired, or the sender can't see it |
invalid_online_only | An online-only message isn't custom, or has fields that aren't allowed |
invalid_push | push isn't an object, is over 2 KB, or has invalid fields; details.field names the field |
Other errors:
| HTTP status | code | Cause |
|---|---|---|
| 403 | permission_denied | Read receipts were requested but group read receipts aren't enabled (details.reason is read_ack_disabled) |
| 403 | not_group_member | The sender isn't a member of the group |
| 403 | user_disabled | The sender is banned by the platform (details.disabled_by is platform) |
| 403 | group_disabled | The group is banned by the platform (details.disabled_by is platform) |
| 403 | message_rejected | The content failed moderation or the before-send callback rejected it, see the table below |
| 403 | app_unavailable | The app is read-only; see Rate limits and app status |
| 404 | not_found | The sender or recipient doesn't exist or was deleted; the group doesn't exist or was dissolved |
| 413 | payload_too_large | The message (including after a callback rewrite) is over the app's size limit; details.max_bytes is the limit, see Size limits |
| 429 | rate_limited | Over the sending rate limit; wait for Retry-After and retry, see Rate limits |
details.reason for message_rejected:
details.reason | Cause |
|---|---|
sensitive_content, provider_block | Blocked by moderation; see Errors callers see |
app_rejected | Your before-send callback rejected it; details.app_reason is your reason code and message is your text |
callback_unavailable | The callback failed and the "reject" policy applied; see Errors the caller receives |
Send in batches
Send one one-to-one message each to a list of users, from the same sender with the same content. Each recipient is handled separately, so one failure doesn't affect the others.
/{org_name}/{app_name}/messages/batchHow it differs from sending a single message:
| Single | Batch | |
|---|---|---|
| Recipients | One user or one group | Up to max_recipients_per_message users (default 600, configurable from 1 to 1000) |
client_msg_id | Recommended | Required; used once in each recipient's conversation |
sync_to_sender default | true | false |
| Mentions, replies, read receipts, online only | Supported | Not supported |
| Rate limits | Counts as 1 | Counts per recipient toward calls per second and messages per minute; if the quota runs out, the remaining recipients get rate_limited and the rest are sent |
After a timeout, retry with exactly the same parameters: messages already sent aren't sent again and come back as duplicate.
Path parameters
For org_name and app_name, see the server overview.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
from | String | Yes | The sender's username |
to_users | Array<String> | Yes | Recipients, 1 to max_recipients_per_message. Duplicates are sent once and each gets the same result |
client_msg_id | String | Yes | Deduplication ID, same rules as a single message |
type | String | Yes | Message type |
body | Object | Yes | Message content |
ext | Object | No | Extension fields |
exclude_from_unread | Boolean | No | Don't count as unread; default false |
push | Object | No | Offline push, same rules as a single message |
sync_to_sender | Boolean | No | Sync to the sender's devices; default false |
Request example
curl -X POST "$IM_API/$ORG/$APP/messages/batch" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"from": "notice",
"to_users": ["alice", "bob", "nobody", "notice"],
"client_msg_id": "notice-20261003-001",
"type": "text",
"body": { "text": "系统将于今晚 22:00 升级" }
}'Response
You get 200 OK, and results lists each recipient's result in the order of to_users. In this example alice and bob succeed, nobody doesn't exist, and notice is the sender:
{
"results": [
{ "username": "alice", "status": "sent", "conversation_id": "99583086802501633", "seq": 1, "message_id": "99583086898970624" },
{ "username": "bob", "status": "sent", "conversation_id": "99583086802501632", "seq": 1, "message_id": "99583086827667456" },
{ "username": "nobody", "code": "not_found", "message": "用户不存在", "details": null },
{ "username": "notice", "code": "invalid_argument", "message": "不能给自己发消息", "details": { "reason": "self_conversation" } }
]
}| Field | Type | Description |
|---|---|---|
username | String | The recipient, as in the request |
status | String | On success, sent (sent now) or duplicate (sent before; the original result is returned) |
conversation_id, seq, message_id | String, Number, String | On success, the conversation ID, sequence and message ID |
code, message, details | String, String, Object | On failure, the error code, text and details (may be null) |
Possible failures for a single recipient:
code | Cause |
|---|---|
not_found | The recipient doesn't exist or was deleted |
invalid_argument | The recipient is the sender (self_conversation) |
rate_limited | The app's per-minute message quota ran out (app_message_rate) |
app_unavailable | The app is read-only |
message_rejected | The content failed moderation or the before-send callback rejected it. Both run once for the whole batch, so a rejection applies to every recipient |
Errors
These errors apply to the whole request, and nothing is sent:
| HTTP status | code | Cause |
|---|---|---|
| 400 | invalid_argument | details.reason is from_required: no from |
| 400 | invalid_argument | details.reason is too_many_items: to_users is empty or over the limit; details.max is the limit |
| 400 | invalid_argument | details.reason is invalid_client_msg_id (missing or wrong format) or reserved_client_msg_id |
| 400 | invalid_argument | details.reason is unknown_type, invalid_body or invalid_push, as for a single message |
| 400 | invalid_argument | details.reason is invalid_reply: the batch included reply_to |
| 403 | user_disabled | The sender is banned by the platform |
| 404 | not_found | The sender doesn't exist or was deleted |
| 413 | payload_too_large | The message is over the app's size limit |
Rules for server-sent messages
When your server sends, your business decides who may message whom. The server only runs a few checks, and many limits that apply to clients don't apply here.
What's checked
| Check | If it fails |
|---|---|
| The sender exists and isn't deleted | 404 not_found |
| The sender isn't banned by the platform (users banned by your app can still be senders) | 403 user_disabled |
| A one-to-one recipient exists, isn't deleted and isn't the sender | 404 not_found, 400 invalid_argument |
| The group exists, isn't dissolved and isn't banned by the platform (groups banned by your app can still receive, for example to explain the ban) | 404 not_found, 403 group_disabled |
| For group messages with a sender, the sender is a member | 403 not_group_member |
| Type, content and size follow the message format | 400 invalid_argument, 413 payload_too_large |
| Sending rate limits | 429 rate_limited |
| Platform moderation rules; if "also check server-submitted content" is on in the console, your word lists and third-party moderation too, see Server-submitted content | 403 message_rejected |
The before-send callback, only called when its vias include openapi, after moderation. A rejection stops the send; rewritten content must still meet the format and size rules | 403 message_rejected, 400 invalid_argument, 413 payload_too_large |
What isn't checked
These limits only apply to messages users send from clients. If your business needs them, check before you call:
- Friendship (when the app requires friends to chat) and block lists: the server can still send as a user to someone who blocked them.
- Global mutes, group mutes and mute-all.
- Only owners and admins can mention everyone.
- Sending frequency per user, per conversation and per recipient.
- The
media_url_onlyrestriction on attachment addresses (url_not_allowed), see Attachment addresses.
Attachments
The server only checks the format of attachment addresses. In these cases the message is still sent, and the recipient gets an error (not_found, file_expired or file_blocked) when requesting a download address:
- The address isn't a file address from this service.
- The file doesn't exist, belongs to another app, or wasn't fully uploaded.
- The file has expired or been blocked.
Group file addresses can be sent too, but only group members can download them in clients.
Rate limits
These limits count client and server messages together. Over a limit you get 429 rate_limited; wait for the Retry-After response header and retry:
| Limit | Value | details.reason |
|---|---|---|
| Messages per group per second | 40 | group_send_rate |
| App messages per minute | The app's message quota, see Rate limits and app status. Batches count per recipient and online-only messages count too; group notices don't | app_message_rate |
Send requests also count toward the app's calls per second like any other OpenAPI request, with batches counted per recipient.
{
"error": {
"code": "rate_limited",
"message": "群消息过于频繁,请稍后再试",
"details": { "reason": "group_send_rate" },
"request_id": "X4OO37QZ2ERFSL6DY37HVNA2GV"
}
}The sender's unread count
Sending as a user doesn't mean they've seen the conversation:
| Before sending, the sender had | After sending |
|---|---|
| No unread messages in the conversation | Their read position moves to this message and their unread count stays 0 |
| Unread messages in the conversation | Their read position stays put, and this message also counts as unread for them |
Neither case changes the read status the other side sees or group read receipts. If needed, mark it as read for the user.
