Sync callbacks
Sync callbacks ask your server before a user performs certain operations, and you decide whether to allow them: allow, reject, or rewrite the message content. For example, let only employees of the same company message each other, add business fields to messages, or let only paying users join a group.
| Sync callback | When called | You can | When rejected, the caller gets |
|---|---|---|---|
message.before_send before sending a message | Before sending one-to-one or group messages; optionally also for server-sent messages and server edits | Allow, reject, rewrite body and ext | 403 message_rejected |
chatroom.before_send before sending a chat room message | Before sending chat room messages | Allow, reject, rewrite body and ext | message_rejected |
friend.before_add before adding a friend | Before a user sends a friend request; optionally also when accepting one | Allow, reject | 403 permission_denied |
group.before_join before joining a group | Before a user creates a group with initial members, invites others, applies to a public group or joins through an invite link | Allow, reject all, or for invitations and group creation reject only some people | 403 permission_denied, per person for invitations and group creation |
Each sync callback is turned on separately in the console with a verified callback address, see Callback configuration. All are off by default. There are currently no sync callbacks before sign-in, registration, entering chat rooms or starting calls.
Sync callback time adds directly to users' operations
A user's message is only sent after your server responds, so your response time is added to every message's send time. Have your receiver respond within 200 milliseconds, and deploy close by for chat room callbacks; for receiver failures, see Handling failures.
Calling rules
- Client operations only: operations by your server (OpenAPI) and the console are your own decisions and don't call sync callbacks. The exception is
message.before_sendandchatroom.before_send, where addingopenapiandconsoleto theviascondition puts server and console messages through the check too (useful when several business systems send messages through OpenAPI and need one check). - Conditions: each sync callback can be limited to certain cases, such as group chats only,
custommessages only, or applications and invite links only. Operations not matching the conditions are allowed without a request. See each type below for its conditions. - Where it runs: sync callbacks run after the operation's own parameter validation, permission checks, rate limits and moderation: operations rejected by those never reach the sync callback; if moderation replaced text, you receive the replaced content; content you rewrite doesn't go through moderation again. Some friend and group rules (whether they're already friends, the other user's friend add mode, the group size limit) are checked after the sync callback, so operations you allow can still fail on them.
- Requested once: sync callbacks aren't retried. A client resubmitting the same operation after a timeout triggers another request; identify repeats by the message's
client_msg_id, the two users of a friend request, or the group and user of a join. - Not called for later steps: sync callbacks only run when the operation is started. For example, a join application calls it when applying, not when the owner approves; an invitation calls it when inviting, not when the invitee accepts.
Request
Sync callback requests have X-IM-Kind hook and the type in X-IM-Hook, signed like other callbacks, see Verify callback requests. The body:
| Field | Type | Description |
|---|---|---|
kind | String | Always hook |
hook | String | The sync callback type, such as message.before_send |
request_id | String | The request ID, different every time |
app | String | The app's AppKey |
endpoint_id | String | The callback address ID |
sent_at | String | When it was sent |
timeout_ms | Number | How many milliseconds this request actually waits for your response before it's treated as a failure, see Wait time. If you can't make it, you can give up processing |
test | Boolean | Whether it's a test request from the console, which uses sample data |
data | Object | The operation's content, with fields depending on the type, see each section below |
POST /im/hooks HTTP/1.1
Host: your-server.example.com
User-Agent: IM-Callback/1
Content-Type: application/json; charset=utf-8
X-IM-Kind: hook
X-IM-Hook: message.before_send
X-IM-Request-Id: 100312172998950912
X-IM-Endpoint-Id: 100311572261371904
X-IM-Timestamp: 1791141885
X-IM-Nonce: 8ef375653bb4e8789852845175ef5e07
X-IM-Signature: v1=af0170256ec0cc4761d702973722e2272984d1fe517e...{
"kind": "hook",
"hook": "message.before_send",
"request_id": "100312172998950912",
"app": "6195295143#demo",
"endpoint_id": "100311572261371904",
"sent_at": "2026-10-04T19:24:45.753Z",
"timeout_ms": 800,
"test": false,
"data": {
"via": "client",
"client_msg_id": "c-0001",
"conversation_type": "single",
"sender_username": "alice",
"recipient_username": "carol",
"recipient_usernames": null,
"group_id": null,
"type": "text",
"body": { "text": "明天上午十点评审" },
"ext": null,
"mention_usernames": [],
"mention_all": false,
"reply_seq": null,
"need_receipt": false,
"online_only": false,
"edit": null
}
}Response
Return HTTP 2xx within the wait time with a JSON object body of at most 64 KB. action decides the result:
action | Other fields | Applies to |
|---|---|---|
allow | — | All |
reject | reason: optional, your reason code, 1 to 64 lowercase letters, digits, _, ., -; message: optional, text for the user, up to 256 characters without line breaks or other control characters | All |
modify rewrite | body: the new message body, a JSON object; ext: new extension fields, a JSON object or null (removes them). At least one; whichever is omitted stays unchanged | message.before_send, chatroom.before_send |
partial partial rejection | rejected: the people rejected, at least one, each with username (who must be in the request's targets) and optional reason and message, same rules as reject | Invitations and group creation in group.before_join |
{ "action": "allow" }{ "action": "reject", "reason": "forbidden_word", "message": "消息中有不允许的内容,请修改后再发" }- Other unknown fields are ignored.
- A non-
2xx, or a2xxwhose body isn't a JSON object, whoseactionis unknown or doesn't apply to this sync callback, or whose fields don't follow the table above, is treated as a failure, see below. For example,modifyforfriend.before_addorpartialfor an application both count as failures. - Responses don't need signing; HTTPS guarantees their authenticity.
Wait time
Each sync callback can set a wait time from 100 to 3000 milliseconds, 1000 by default. The actual wait is also bounded by the caller's overall time limit:
- Messages: all pre-send checks (moderation and the before-send callback) take at most 3 seconds together.
- Chat room messages: chat room messages must reach every viewer within a few hundred milliseconds, so all checks take at most 500 milliseconds together.
Moderation runs first and uses part of the time; the sync callback actually waits the smaller of your setting and the remaining time minus 50 milliseconds, which is timeout_ms in the request body. With less than 100 milliseconds left, no request is sent and it's treated as a failure.
Handling failures
These count as failures:
- Timeouts, connection failures, TLS handshake failures, non-
2xx(including3xx), response bodies over 64 KB or not meeting the response requirements. - The bound callback address is "Pending verification" (for example just after changing its URL) or "Disabled by the platform", so no request is sent.
- The circuit breaker is open or concurrency is full, so no request is sent (see below).
- Less than 100 milliseconds remain, so no request is sent.
On failure, the sync callback's failure policy (on_failure) applies, set separately per sync callback:
| Failure policy | What happens | Suited to |
|---|---|---|
allow (default) | The operation proceeds as if the sync callback weren't on | Sync callbacks that improve the experience, where receiver failures shouldn't stop users sending messages or adding friends |
reject | The operation is rejected with details.reason callback_unavailable, distinct from your own rejection (app_rejected) | Operations that must be checked before running. The cost is that when the receiver fails, every such operation in the app fails |
Start with allow
Turn it on with "allow" first, watch latency and failures in the console's sync callback statistics for a while, and switch to "reject" only if needed once the receiver is stable. Changing the bound address's URL sends it back to "Pending verification", and until it passes, sync callbacks with a "reject" policy reject every matching operation; rebind to another address or switch to allow temporarily first.
Circuit breaker: each service instance tracks each address's sync callbacks separately; with at least 20 requests in the last 10 seconds and more than half failing, or 5 timeouts in a row, it opens for 10 seconds, during which no requests are sent and everything is treated as failure; after 10 seconds one request is let through as a probe, and success closes it. So when a receiver is down, operations don't each wait out the full timeout.
Concurrency: each service instance sends at most 200 simultaneous sync callback requests to one address; beyond that requests don't wait but are treated as failures.
Address status: when the bound address is paused by you or automatically disabled, sync callbacks are still called; those states only affect event callbacks. Deleting an address turns off the sync callbacks bound to it.
The console's "Request records" show each sync callback's result (allow, reject, modify, partial, or fallback_allow and fallback_reject when the failure policy applied), latency and failure reason, for example for an invalid response:
{
"request_id": "100313774824620032",
"created_at": "2026-10-04T19:31:07.660Z",
"kind": "hook",
"name": "message.before_send",
"outcome": "failed",
"verdict": "fallback_reject",
"http_status": 200,
"error_kind": "invalid_response",
"error_detail": "不认识的 action",
"latency_ms": 1,
"response_excerpt": "{\"action\": \"maybe\"}"
}Errors the caller receives
When you reject, or a failure is handled with the "reject" policy, the client starting the operation (or your server calling OpenAPI) gets an error:
| Sync callback | Error | message | details |
|---|---|---|---|
message.before_send, chatroom.before_send | 403 message_rejected | Your message; when you gave none, and on failure rejections, "Message failed to send" | reason: app_rejected you rejected, callback_unavailable rejected by policy after a failure; app_reason: your reason, or null |
friend.before_add, group.before_join | 403 permission_denied | Your message; when you gave none, and on failure rejections, "Operation rejected" | As above |
When you reject, the client gets:
{
"error": {
"code": "message_rejected",
"message": "消息中有不允许的内容,请修改后再发",
"details": {
"app_reason": "forbidden_word",
"reason": "app_rejected"
},
"request_id": "IOUGZD4RGKHRMUJ2PYOPHH3DBM"
}
}When the receiver times out with a "reject" policy:
{
"error": {
"code": "message_rejected",
"message": "消息发送失败",
"details": {
"app_reason": null,
"reason": "callback_unavailable"
},
"request_id": "KX2HPS7QFS7ZBSUDKVBG7FZWHE"
}
}Clients can tell them apart by details.reason: show message directly for app_rejected; show "Service busy, please try again later" for callback_unavailable. Messages rejected by moderation also return message_rejected, with details.reason such as sensitive_content, see Moderation.
In batch operations, rejection is returned as each item's result and the whole request still succeeds: when your server sends messages in batches, the sync callback runs once and its result applies to every recipient; when clients invite several people or create a group with initial members, rejected people come back one by one as permission_denied in results and the rest join as usual, see group.before_join.
message.before_send
Called before sending one-to-one and group messages. Rewritten messages are validated again for format and size under the normal rules, and invalid ones aren't sent, with the sender getting 400 invalid_argument or 413 payload_too_large; the message type can't change.
Conditions:
| Condition | Default | Description |
|---|---|---|
vias | ["client"] | Which entry points to call for: client, openapi your server, console |
conversation_types | ["single", "group"] | Conversation types: single, group |
message_types | All | Only these message types, such as ["text", "custom"], up to 20 |
include_online_only | false | Whether to call for online-only messages |
include_edits | false | Whether to call for message edits. Edits can only come from the server and console, so vias also needs openapi or console |
Fields of data:
| Field | Type | Description |
|---|---|---|
via | String | Entry point: client, openapi, console |
client_msg_id | String | The message's deduplication ID; for server sends without one, the server-generated ID that will be written |
conversation_type | String | single or group |
sender_username | String | The sender; null for group messages sent as the system |
recipient_username | String | The one-to-one recipient; null for groups and for server batch sends to several people |
recipient_usernames | Array<String> | Every recipient when your server sends in batches to several people (up to 1000); null otherwise |
group_id | String | The group ID for groups; null for one-to-one |
type | String | The message type |
body | Object | The message body, after moderation replacement |
ext | Object | Extension fields; null if none |
mention_usernames | Array<String> | Mentioned members; empty if none |
mention_all | Boolean | Whether everyone is mentioned |
reply_seq | Number | The sequence of the replied-to message; null if not a reply |
need_receipt | Boolean | Whether group read receipts were requested |
online_only | Boolean | Whether it's an online-only message |
edit | Object | For edits, the edited message's message_id and seq; null when sending |
For a request example, see Request above. Replace a word in the message and add an extension field:
{
"action": "modify",
"body": { "text": "请**这个词 @卡罗尔" },
"ext": { "checked": true }
}The sender and recipient both get the rewritten content, and so does message delivery.
chatroom.before_send
Called before sending chat room messages, with all checks limited to 500 milliseconds together, see Wait time. You can rewrite body and ext, but not the message type or priority.
Conditions:
| Condition | Default | Description |
|---|---|---|
vias | ["client"] | As in message.before_send |
message_types | All | As in message.before_send |
priorities | All | Only these priorities: high, normal, low |
Fields of data:
| Field | Type | Description |
|---|---|---|
via | String | Entry point: client, openapi, console |
client_msg_id | String | The message's deduplication ID |
room_id | String | The chat room ID |
sender | String | The sender; null when sent as the system |
type | String | The message type |
body | Object | The message body, after moderation replacement |
ext | Object | Extension fields; null if none |
priority | String | high, normal or low |
{
"kind": "hook",
"hook": "chatroom.before_send",
"request_id": "100312464121397248",
"app": "6195295143#demo",
"endpoint_id": "100311572261371904",
"sent_at": "2026-10-04T19:25:55.162Z",
"timeout_ms": 300,
"test": false,
"data": {
"via": "client",
"client_msg_id": "r-0001",
"room_id": "100312409813549056",
"sender": "bob",
"type": "text",
"body": { "text": "大家好" },
"ext": null,
"priority": "normal"
}
}When rejected, the client gets this reply to chatroom.send on its connection:
{
"type": "reply",
"id": "s1",
"error": {
"code": "message_rejected",
"message": "消息中有不允许的内容,请修改后再发",
"details": { "app_reason": "forbidden_word", "reason": "app_rejected" },
"request_id": "QODIXCVEJUAUFLC5ZHRCGBFYIO"
}
}When rewritten, the reply carries the rewritten body and ext, so the client can update its local copy.
friend.before_add
Called before a user sends a friend request from a client, including requests that would be accepted automatically (when the other user lets anyone add them, this is the only chance to check). Can also be called when accepting a request, for conditions that may change after the request was sent (such as the two no longer being in the same organization). Only allow or reject.
Conditions:
| Condition | Default | Description |
|---|---|---|
stages | ["request"] | When to call: request when sending, accept when accepting |
Fields of data:
| Field | Type | Description |
|---|---|---|
stage | String | request sending, accept accepting |
from_username | String | The requester. Still the requester when accepting, not the person accepting |
to_username | String | The person requested |
message | String | The message: after moderation when sending, as stored in the request when accepting |
add_source | String | How they met; empty string if none |
{
"kind": "hook",
"hook": "friend.before_add",
"request_id": "100311656000651264",
"app": "6195295143#demo",
"endpoint_id": "100311572261371904",
"sent_at": "2026-10-04T19:22:42.491Z",
"timeout_ms": 1000,
"test": false,
"data": {
"stage": "request",
"from_username": "carol",
"to_username": "vip",
"message": "你好",
"add_source": "qrcode"
}
}Rejecting:
{ "action": "reject", "reason": "vip_protected", "message": "对方暂不接受好友申请" }The requester gets:
{
"error": {
"code": "permission_denied",
"message": "对方暂不接受好友申请",
"details": {
"app_reason": "vip_protected",
"reason": "app_rejected"
},
"request_id": "FM4SQXYNG6EU4LS3OVDM736SBN"
}
}Rejected requests aren't written and the other user never gets them. When rejected at acceptance, the request stays pending.
group.before_join
Called before a user, from a client, creates a group with initial members, invites others, applies to a public group or joins through an invite link. Not called for group creation without initial members, or for cases the group's own rules reject (such as applying to a private group or an invalid link).
Conditions:
| Condition | Default | Description |
|---|---|---|
joined_vias | All | Which ways of joining: create group creation with initial members, invite, apply, link invite link |
group_types | All | Which groups: private, public |
Fields of data:
| Field | Type | Description |
|---|---|---|
joined_via | String | How they're joining: create, invite, apply, link, the same values as members' joined_via in group.members_added |
group | Object | The group: group_id (null when creating), type, name |
operator | String | Who started it: the creator, inviter, applicant, or the person using the invite link |
targets | Array<String> | Who's joining. The starter themself for applications and invite links; invitees for invitations and group creation, up to 100 |
message | String | The application reason or invitation message, after moderation; empty string if none |
link_creator | String | For invite links, who created the link; null otherwise |
Creating a group with two initial members:
{
"kind": "hook",
"hook": "group.before_join",
"request_id": "100311903112265728",
"app": "6195295143#demo",
"endpoint_id": "100311572261371904",
"sent_at": "2026-10-04T19:23:41.407Z",
"timeout_ms": 1000,
"test": false,
"data": {
"joined_via": "create",
"group": { "group_id": null, "name": "项目组", "type": "private" },
"operator": "alice",
"targets": ["carol", "dave"],
"message": "一起来讨论项目",
"link_creator": null
}
}Rejecting only one of them:
{
"action": "partial",
"rejected": [
{ "username": "dave", "reason": "not_verified", "message": "对方还没有完成实名认证" }
]
}The group is created and carol joins, while dave's result in the creation response is permission_denied:
{
"group_id": "100311903108071424",
"...": "...",
"results": [
{ "username": "carol", "status": "joined" },
{
"username": "dave",
"code": "permission_denied",
"message": "对方还没有完成实名认证",
"details": { "app_reason": "not_verified", "reason": "app_rejected" }
}
]
}For invitations and group creation, reject rejects every invitee, and the group is still created (with only the owner). For applications and invite links only allow or reject are possible, and a rejection fails the whole request with 403 permission_denied:
{
"error": {
"code": "permission_denied",
"message": "只有本公司员工可以加入",
"details": {
"app_reason": "not_member_of_org",
"reason": "app_rejected"
},
"request_id": "VIAXZQTJITEAQHMNA6Z3M6OKYV"
}
}Testing sync callbacks
In the console's "Sync callbacks", click "Test" on a sync callback to send one request with sample data (test true) to its bound address, without requiring URL verification, and see the parsed result or what's wrong with the response. Test requests share the limit of 20 per app per minute with URL verification and event test sends. A test result:
{
"ok": true,
"request_id": "100313736555790336",
"http_status": 200,
"latency_ms": 1,
"remote_ip": "203.0.113.10",
"error_kind": null,
"error_detail": null,
"response_excerpt": "{\"action\": \"allow\"}",
"status": "active",
"verdict": "allow"
}When the response doesn't meet the requirements, ok is false, error_kind is invalid_response, and invalid_reason says why, such as "unknown action" or "rejected contains someone not in targets".
