Recall, edit and pin
Change messages that have already been sent, from your server. Messages are identified by message_id, and everyone who can see the message gets the change: online devices immediately, offline devices on their next sync.
| You want to | API | Typical use |
|---|---|---|
| Recall a message | POST /messages/{message_id}/recall | Handle violating content, take back a notice sent by mistake |
| Recall in batches | POST /messages/batch-recall | Clean up spam from one user |
| Edit a message | PATCH /messages/{message_id} | Update order or approval cards, correct a notice |
| Pin or unpin a message | PUT, DELETE /messages/{message_id}/pin | Group announcements, important notices |
| List pinned messages | GET /conversations/{conversation_id}/pins | Show the pin list in your admin tools |
For example, recall a message:
curl -X POST "$IM_API/$ORG/$APP/messages/99583385822822400/recall" \
-H "Authorization: Bearer $APP_TOKEN"Safe to retry: these APIs return the updated message object and changed. Repeating an operation (recalling a recalled message, pinning a pinned one, editing to the same content) isn't an error: changed is false and the message is unchanged.
Where message IDs come from: the Send messages response, message queries, or message callbacks.
Recall a message
Recall a message. Your server can recall any message from anyone, including messages sent as the system and group notices, with no time limit: recall_window_seconds in the runtime policy (120 seconds by default) only limits users recalling their own messages from clients.
/{org_name}/{app_name}/messages/{message_id}/recallRecalled content can't be restored
After a recall, even the server can't retrieve the original content. If you need records, export before recalling.
After a recall:
| What | Result |
|---|---|
| Message content | body, ext, mentions and reply_to become null; recalled is { "by": null, "role": "server", "at": "..." } |
| Reactions, pin | Cleared, unpinned |
| "You were mentioned" reminder | Falls back to the previous mention that's still valid |
| Unread counts | Unchanged |
| Conversation participants | All get the recall (including the sender, and people who left the group but can still see the message). Clients can show different text by recalled.role, such as "This message was recalled" for server |
| Offline push already delivered | Replaced with "A message was recalled" (not supported by every push channel); pushes not yet sent are dropped, see Updating notifications after a recall |
| Messages replying to it | Keep the replied-to sequence and show "Original message recalled"; it can no longer be replied to, edited or pinned |
| Attachments in the message | Not deleted; still downloadable within retention. To remove a violating image or file completely, note its file address before recalling, then delete the file |
Recalling also works while the app is read-only.
Path parameters
For org_name and app_name, see the server overview.
| Parameter | Type | Description |
|---|---|---|
message_id | String | The message to recall |
Request body
The body is optional.
| Field | Type | Required | Description |
|---|---|---|---|
reason | String | No | Why it was recalled; recorded in the operation log, not shown to users |
Request example
curl -X POST "$IM_API/$ORG/$APP/messages/99583385822822400/recall" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"reason": "违规内容"
}'Response
On success you get 200 OK:
| Field | Type | Description |
|---|---|---|
message | Object | The message object after the recall |
changed | Boolean | Whether this call recalled it; false if it was already recalled |
{
"message": {
"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
},
"changed": true
}Errors
| HTTP status | code | Description |
|---|---|---|
| 404 | not_found | The message doesn't exist, is past retention, or message_id is malformed |
Recall in batches
Recall up to 100 messages at once, for example spam from one user (find them first with Query a user's sent messages). Each message is handled separately under the same rules as Recall a message, so one failure doesn't affect the rest.
/{org_name}/{app_name}/messages/batch-recallEach call counts toward the app's calls per second once per message.
Path parameters
For org_name and app_name, see the server overview.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
message_ids | Array<String> | Yes | Messages to recall, 1 to 100, each a string |
reason | String | No | Why they were recalled; recorded in the operation log |
Request example
curl -X POST "$IM_API/$ORG/$APP/messages/batch-recall" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"message_ids": ["99583386074480640", "99583385822822400", "123", "99583386288390144"],
"reason": "批量清理"
}'Response
On success you get 200 OK, and results lists each message's result in the order of message_ids:
| Field | Type | Description |
|---|---|---|
message_id | String | The message ID |
changed | Boolean | On success: whether this call recalled it; false if already recalled |
code | String | On failure: the error code, such as not_found |
message | String | On failure: the error text |
details | Object | On failure: details; may be null |
{
"results": [
{ "message_id": "99583386074480640", "changed": true },
{ "message_id": "99583385822822400", "changed": false },
{ "message_id": "123", "code": "not_found", "message": "消息不存在", "details": null },
{ "message_id": "99583386288390144", "changed": true }
]
}Errors
| HTTP status | code | Description |
|---|---|---|
| 400 | invalid_argument | message_ids is empty or has more than 100 entries (details.reason too_many_items, details.max 100), or contains an ID that isn't an integer string |
Edit a message
Change a message's content, to update the status of order or approval cards or correct a notice your server sent. Only text (text) and custom (custom) messages can be edited, whoever sent them; other types and group notices can't.
/{org_name}/{app_name}/messages/{message_id}- Whole replacement: give at least one of
bodyandext; each one you give replaces the old value entirely, and the other stays.ext: nullclears it.mentions,reply_toandneed_receiptcan't be changed. - Limit: up to 100 edits per message, with no time limit. Content identical to the current content doesn't count, and
changedisfalse. - Display:
editedrecords the latest edit time and total count; clients usually show "Edited". - Read-only apps: editing isn't allowed while the app is read-only (
403 app_unavailable).
New content goes through the same checks as when sending; if a check fails, the message is unchanged:
| Check | Result |
|---|---|
| Type, format and size limit | 400 invalid_argument, 413 payload_too_large |
| Moderation (platform rules only by default, see Server-submitted content) | Failing returns 403 message_rejected; text matching replacement rules becomes *, so rely on the message in the response |
The before-send callback, only when it has "include edits" (include_edits) on and its vias include openapi; data.edit carries the edited message's message_id and seq | A rejection returns 403 message_rejected; rewritten content is saved as rewritten |
Path parameters
For org_name and app_name, see the server overview.
| Parameter | Type | Description |
|---|---|---|
message_id | String | The message to edit |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
body | Object | No | The new body, replacing body entirely; see Message types |
ext | Object | No | New extension fields, replacing ext entirely; null clears it |
reason | String | No | Why it was edited; recorded in the operation log |
Request example
curl -X PATCH "$IM_API/$ORG/$APP/messages/99582642919309312" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"body": { "card": "order_shipped", "order_id": "8812", "status": "delivered" },
"reason": "订单状态更新"
}'Response
On success you get 200 OK, with the edited message object in message and whether the content changed in changed:
{
"message": {
"conversation_id": "99582604130385920",
"conversation_type": "single",
"seq": 7,
"message_id": "99582642919309312",
"client_msg_id": "2ec322dc5f952fd2d05bc6df94a44d9a",
"sender": "bob",
"sender_type": "user",
"recipient": "alice",
"type": "custom",
"body": { "card": "order_shipped", "order_id": "8812", "status": "delivered" },
"ext": { "source": "order-system" },
"mentions": null,
"reply_to": null,
"need_receipt": false,
"exclude_from_unread": false,
"reactions": null,
"pinned": null,
"edited": { "at": "2026-10-02T19:08:27.350Z", "count": 1 },
"recalled": null,
"erased": false,
"via": "openapi",
"created_at": "2026-10-02T19:05:52.228Z",
"message_version": 1
},
"changed": true
}Errors
| HTTP status | code | Description |
|---|---|---|
| 400 | invalid_argument | details.reason empty_edit: neither body nor ext was given |
| 400 | invalid_argument | details.reason invalid_body: the new content is invalid; details.field names the field |
| 403 | permission_denied | details.reason not_editable: not a text or custom message |
| 403 | permission_denied | details.reason message_recalled: the message was recalled or erased |
| 403 | message_rejected | Failed moderation or rejected by the before-send callback; details.reason values are the same as in Send messages |
| 404 | not_found | The message doesn't exist or is past retention |
| 409 | limit_exceeded | The message has already been edited 100 times; details.reason is edit_limit |
| 413 | payload_too_large | The new content is over the app's size limit; details.max_bytes is the limit |
Pin a message
Pin a message to the top of the conversation where every participant can see it, for announcements and important notices. Pinning from the server doesn't check roles (in clients, only both sides of a one-to-one chat, group owners and admins can pin), and pinned.by is null.
/{org_name}/{app_name}/messages/{message_id}/pin- Limit: up to
max_pinned_messages_per_conversationper conversation (runtime policy; 20 by default, 1 to 100, see Runtime policy). When full, unpin one first. - Removed automatically: recalled and erased messages can't be pinned; pinned messages that are recalled, erased or pass retention drop out of the pin list.
- New members: can't see messages from before they joined, so they don't see those pins either.
- Read-only apps: pinning isn't allowed while the app is read-only (
403 app_unavailable).
Path parameters
For org_name and app_name, see the server overview.
| Parameter | Type | Description |
|---|---|---|
message_id | String | The message to pin |
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
reason | String | No | Why it was pinned; recorded in the operation log |
Request example
curl -X PUT "$IM_API/$ORG/$APP/messages/99582672556261376/pin" \
-H "Authorization: Bearer $APP_TOKEN"Response
On success you get 200 OK, with the pinned message object in message and whether this call pinned it in changed (false if already pinned):
{
"message": {
"conversation_id": "99582580415791104",
"conversation_type": "group",
"seq": 3,
"message_id": "99582672556261376",
"client_msg_id": "doc-grp-0001",
"sender": "alice",
"sender_type": "user",
"recipient": null,
"type": "text",
"body": { "text": "@bob 请看一下需求文档" },
"ext": null,
"mentions": { "usernames": ["bob"], "all": false },
"reply_to": null,
"need_receipt": false,
"exclude_from_unread": false,
"reactions": null,
"pinned": { "by": null, "at": "2026-10-02T19:08:36.382Z" },
"edited": null,
"recalled": null,
"erased": false,
"via": "openapi",
"created_at": "2026-10-02T19:05:59.294Z",
"message_version": 1
},
"changed": true
}Errors
| HTTP status | code | Description |
|---|---|---|
| 403 | permission_denied | The message was recalled or erased; details.reason is message_recalled |
| 404 | not_found | The message doesn't exist or is past retention |
| 409 | limit_exceeded | The conversation has reached its pin limit; details.reason is pinned_message_limit |
Unpin a message
Unpin a message, whoever pinned it. changed is false if it wasn't pinned. Unpinning also works while the app is read-only.
/{org_name}/{app_name}/messages/{message_id}/pinPath parameters
For org_name and app_name, see the server overview.
| Parameter | Type | Description |
|---|---|---|
message_id | String | The message to unpin |
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
reason | String | No | Why it was unpinned; recorded in the operation log |
Request example
curl -X DELETE "$IM_API/$ORG/$APP/messages/99583385617301504/pin" \
-H "Authorization: Bearer $APP_TOKEN"Response
On success you get 200 OK in the same format as Pin a message, with message.pinned set to null:
{
"message": {
"conversation_id": "99583031936811008",
"conversation_type": "single",
"seq": 2,
"message_id": "99583385617301504",
"client_msg_id": "7109113ff74e7b0e1e98fc0d29f56f4e",
"sender": "carol",
"sender_type": "user",
"recipient": "dave",
"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:08:49.301Z",
"message_version": 21
},
"changed": true
}Errors
| HTTP status | code | Description |
|---|---|---|
| 404 | not_found | The message doesn't exist or is past retention |
List pinned messages in a conversation
List every pinned message in a conversation, most recently pinned first. Returns the server's view and works for one-to-one and group conversations.
/{org_name}/{app_name}/conversations/{conversation_id}/pinsThere are at most 100 pinned messages, usually returned in one page. If the messages in a response exceed 1 MB, they're paged: when next_cursor isn't null, use it to get the next page.
Path parameters
For org_name and app_name, see the server overview.
| Parameter | Type | Description |
|---|---|---|
conversation_id | String | The conversation ID; for groups, the group ID |
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
cursor | String | No | The cursor for the next page; omit for the first page |
Request example
curl "$IM_API/$ORG/$APP/conversations/99582580415791104/pins" \
-H "Authorization: Bearer $APP_TOKEN"Response
On success you get 200 OK, with the pinned message objects in items and the next page's cursor in next_cursor (null when there's no next page):
{
"items": [
{
"conversation_id": "99582580415791104",
"conversation_type": "group",
"seq": 5,
"message_id": "99582672988274688",
"client_msg_id": "doc-grp-0003",
"sender": null,
"sender_type": "system",
"recipient": null,
"type": "text",
"body": { "text": "本群将于今晚 22:00 维护" },
"ext": null,
"mentions": { "usernames": [], "all": true },
"reply_to": null,
"need_receipt": false,
"exclude_from_unread": false,
"reactions": null,
"pinned": { "by": null, "at": "2026-10-02T19:08:36.455Z" },
"edited": null,
"recalled": null,
"erased": false,
"via": "openapi",
"created_at": "2026-10-02T19:05:59.397Z",
"message_version": 2
},
{
"conversation_id": "99582580415791104",
"conversation_type": "group",
"seq": 3,
"message_id": "99582672556261376",
"client_msg_id": "doc-grp-0001",
"sender": "alice",
"sender_type": "user",
"recipient": null,
"type": "text",
"body": { "text": "@bob 请看一下需求文档" },
"ext": null,
"mentions": { "usernames": ["bob"], "all": false },
"reply_to": null,
"need_receipt": false,
"exclude_from_unread": false,
"reactions": null,
"pinned": { "by": null, "at": "2026-10-02T19:08:36.382Z" },
"edited": null,
"recalled": null,
"erased": false,
"via": "openapi",
"created_at": "2026-10-02T19:05:59.294Z",
"message_version": 1
}
],
"next_cursor": null
}Errors
| HTTP status | code | Description |
|---|---|---|
| 400 | invalid_argument | cursor is invalid |
| 404 | not_found | The conversation doesn't exist |
