Client Integration Guide
1. Overview
Tip: for JS/TS, Dart, Swift, or Kotlin, prefer the official typed SDK (see “SDK Overview”) instead of hand-rolling the protocol below.
This document describes how Android, iOS, and Web clients integrate with the IM service in this repository.
The current IM stack includes:
- WebSocket long connection
- JWT-based connection authentication
- 1-to-1 chat
- Room chat
- Conversation list / unread count / mute
- Typing status
- Edit / recall / delete / reaction
- Emoji reaction / nudge
- Reply / quote / forward / search
- Friend request / block list / DM policy
- OSS-based media upload
- Inline image / video rendering
- Voice messages / press-to-talk
- Speech-to-text for voice messages
- In-page PDF preview
- Offline push device registration
Core server entry points:
- WebSocket:
/acc - Upload:
POST /user/chat/upload - Audio transcription:
POST /user/chat/audio/transcribe - File access:
GET /user/chat/upload/access - Profile lookup:
GET /user/chat/profile - User search:
GET /user/chat/search - Presence:
GET /user/online
2. Connection Prerequisites
2.1 JWT
The IM WebSocket now requires JWT at handshake time.
JWT validation rules:
- signing algorithm:
HS256 - secret:
auth.jwt_secret - issuer:
auth.jwt_issuer - audience:
auth.jwt_audience - user id claim: one of
user_id,uid,sub
2.2 Origin
WebSocket handshake also validates Origin.
Config:
auth:
jwt_required: true
allow_empty_origin: false
allowed_origins:
- "http://127.0.0.1:8080"
- "http://localhost:8080"
Notes:
- Web clients must use an origin in
auth.allowed_origins - Native clients should set
Originexplicitly if the WebSocket library allows it - If a native SDK cannot set
Origin, you must setauth.allow_empty_origin=true
3. Endpoints
3.1 WebSocket
Example:
ws://<your-host>/acc?token=<JWT>
If the service is exposed over HTTPS, use:
wss://<your-host>/acc?token=<JWT>
Recommendations:
- Web clients should connect to
/accon the current site - Browsers should not directly connect to
:8089in production - If Nginx / Ingress / CDN is used, proxy
/accto the WebSocket backend
Token may also be passed with headers:
Authorization: Bearer <JWT>X-Auth-Token: <JWT>
3.2 HTTP
- Upload:
POST /user/chat/upload - Audio transcription:
POST /user/chat/audio/transcribe - Access signed file URL:
GET /user/chat/upload/access - Load profile:
GET /user/chat/profile?userID=<uid> - Search users:
GET /user/chat/search?keyword=<kw>&limit=<n> - Query online status:
GET /user/online?userID=<uid>
Both require JWT in one of:
Authorization: Bearer <JWT>X-Auth-Token: <JWT>- query param
token
4. WebSocket Message Format
4.1 Client request
{
"seq": "10001",
"cmd": "chat_dm_send",
"data": {
"targetUserID": "1002",
"text": "hello"
}
}
4.2 Server response / push
{
"seq": "10001",
"cmd": "chat_dm_send",
"response": {
"code": 200,
"codeMsg": "Success",
"data": {}
}
}
Push events use the same envelope, but cmd is an event command such as:
chat_messagechat_typingchat_dm_conversation_updatechat_room_conversation_updatechat_group_conversation_updatechat_dm_read_receiptchat_group_read_receiptchat_dm_delivery_receiptchat_message_mutationchat_friend_request_update
5. Connection Lifecycle
5.1 Step 1: connect WebSocket with JWT
The connection is rejected if JWT is missing or invalid.
5.2 Step 2: send login
Even after handshake succeeds, the client should still send login to initialize presence, profile snapshot, and session state.
Request:
{
"seq": "1",
"cmd": "login",
"data": {
"userID": "1001",
"userName": "alice",
"avatar": "https://cdn.example.com/a.png"
}
}
Notes:
serviceTokenis no longer required when handshake JWT is already useduserIDmust match the JWT claim- Prefer storing only
userIDon the client and resolvinguserName/avatarfrom/user/chat/profile userNameandavatarcan still be sent inloginfor first-frame rendering and profile backfill
5.3 Step 3: heartbeat
The client should send heartbeat periodically.
Recommended interval:
- every
30sto60s
Request:
{
"seq": "2",
"cmd": "heartbeat",
"data": {}
}
There is also a lightweight ping:
{
"seq": "3",
"cmd": "ping",
"data": {}
}
6. Common Commands
6.1 Room chat
Before sending room chat, the user must join a room.
Join room:
{
"seq": "10",
"cmd": "room_join",
"data": {
"roomID": 123,
"userID": "1001",
"userName": "alice"
}
}
Send room chat:
{
"seq": "11",
"cmd": "chat_room_send",
"data": {
"text": "hello room"
}
}
Load room chat history:
{
"seq": "12",
"cmd": "chat_room_history",
"data": {
"limit": 20,
"beforeID": 0,
"afterID": 0
}
}
Search room chat:
{
"seq": "13",
"cmd": "chat_room_search",
"data": {
"keyword": "hello",
"limit": 20
}
}
Room conversation list:
{
"seq": "14",
"cmd": "chat_room_conversations",
"data": {
"limit": 50
}
}
Mark room as read:
{
"seq": "15",
"cmd": "chat_room_mark_read",
"data": {
"roomID": 123
}
}
Set room mute:
{
"seq": "16",
"cmd": "chat_room_mute_set",
"data": {
"roomID": 123,
"muted": true
}
}
Room typing:
{
"seq": "17",
"cmd": "chat_room_typing",
"data": {
"isTyping": true
}
}
Transient status message:
{
"seq": "18",
"cmd": "chat_status_send",
"data": {
"scene": "room",
"statusType": "recording",
"text": "recording audio",
"data": {
"durationMs": 1200
},
"expiresAt": 1779292800
}
}
Transient status messages also support scene=dm and scene=group:
scene=dm: sendtargetUserIDorconversationIDscene=group: sendgroupID, or omit it to use the current connectiongroupIDscene=room: sendroomID, or omit it to use the current connectionroomID
Status messages are delivered only to online sessions. They are not persisted, not returned in history, do not update conversation lists, do not increase unread counts, and do not create offline push tasks.
Advanced chatroom governance:
{ "seq": "room-info-1", "cmd": "chat_room_info", "data": { "roomID": 123 } }
{ "seq": "room-members-1", "cmd": "chat_room_members", "data": { "roomID": 123, "limit": 500 } }
{ "seq": "room-global-mute-1", "cmd": "chat_room_global_mute_set", "data": { "targetUserID": "1002", "muted": true } }
{ "seq": "room-ban-1", "cmd": "chat_room_ban_set", "data": { "roomID": 123, "targetUserID": "1002", "banned": true, "reason": "spam" } }
{ "seq": "room-priority-1", "cmd": "chat_room_priority_set", "data": { "roomID": 123, "lowPriorityTypes": ["gift", "emoji"], "protectedTypes": ["text"] } }
{ "seq": "room-protect-1", "cmd": "chat_room_user_protect_set", "data": { "roomID": 123, "targetUserID": "1002", "protected": true } }
{ "seq": "room-keepalive-1", "cmd": "chat_room_keepalive_set", "data": { "roomID": 123, "keepAlive": true } }
{ "seq": "room-attr-1", "cmd": "chat_room_attribute_set", "data": { "roomID": 123, "attributes": { "topic": "final", "mode": "ranked" } } }
{ "seq": "room-attr-del-1", "cmd": "chat_room_attribute_delete", "data": { "roomID": 123, "keys": ["topic"] } }
Notes:
chat_room_membersreturns at most 500 members.- Room owners can set room bans, all-muted state, member mute, protected users, low-priority/protected message types, keepalive, and room attributes.
- Global chatroom mute prevents a user from sending messages in every chatroom.
- Banned users are forced out and future
room_joincalls fail. - Low-priority/protected message types are reflected in
chat_message.priority:loworprotected. - Low-priority messages are actively degraded under load. After a room reaches
chat.room_priority.low_drop.min_members, the server allows at mostchat.room_priority.low_drop.per_room_per_secondmessages per room and message type per second. Excess low-priority messages are acknowledged withdropped=true,priority=dropped, anddropReason, and are not persisted, broadcast, routed, callbacked, or used for offline push. - Protected message types are never dropped by the low-priority policy.
- Default server config:
enabled=true,min_members=500,per_room_per_second=20. - Room attributes are limited to 100 keys.
- Governance changes are pushed to online room users through
chat_room_state_update.
6.2 Group chat
Send group chat:
{
"seq": "18",
"cmd": "chat_group_send",
"data": {
"groupID": 3000000001,
"text": "please check",
"mentionUserIDs": ["1002", "1003"]
}
}
Directed group messages are delivered only to targetUserIDs and the sender. Group history, search results, and conversation summaries are visible only to those users:
{
"seq": "19",
"cmd": "chat_group_send",
"data": {
"groupID": 3000000001,
"text": "only selected members can read this",
"targetUserIDs": ["1002", "1003"]
}
}
Group owners can send @all messages with mentionAll: true.
Marking a group conversation as read emits chat_group_read_receipt:
{
"seq": "20",
"cmd": "chat_group_mark_read",
"data": {
"groupID": 3000000001
}
}
Group owners / admins can control whether invites sent by regular members require approval:
{
"seq": "20-1",
"cmd": "chat_group_invite_approval_set",
"data": {
"groupID": 3000000001,
"memberInviteRequiresApproval": true
}
}
Notes:
chat_group_create/chat_group_updatealso acceptmemberInviteRequiresApprovalfalse: regular member invites continue through the direct invite flowtrue: invites sent by regular members require owner / admin approval- invites sent by owners / admins are not gated by this setting
6.3 DM chat
Send DM:
{
"seq": "20",
"cmd": "chat_dm_send",
"data": {
"targetUserID": "1002",
"text": "hello"
}
}
Load DM history:
{
"seq": "21",
"cmd": "chat_dm_history",
"data": {
"targetUserID": "1002",
"limit": 20
}
}
Cursor pagination:
{
"seq": "22",
"cmd": "chat_dm_history",
"data": {
"conversationID": "dm:1001:1002",
"beforeID": 12345,
"limit": 20
}
}
Search DM:
{
"seq": "23",
"cmd": "chat_dm_search",
"data": {
"targetUserID": "1002",
"keyword": "hello",
"limit": 20
}
}
Conversation list:
{
"seq": "24",
"cmd": "chat_dm_conversations",
"data": {
"limit": 50
}
}
Filter any conversation list by labels with label or labels:
{
"seq": "24-label",
"cmd": "chat_dm_conversations",
"data": {
"label": "work",
"limit": 50
}
}
Generic conversation labels:
{ "seq": "label-set-1", "cmd": "chat_conversation_labels_set", "data": { "scene": "dm", "targetUserID": "1002", "labels": ["work", "vip"] } }
{ "seq": "label-list-1", "cmd": "chat_conversation_labels_list", "data": { "scene": "dm" } }
{ "seq": "label-query-1", "cmd": "chat_conversations_by_label", "data": { "scene": "dm", "labels": ["work"], "limit": 50 } }
Labels are per-user conversation state. Supported scenes are dm, room, and group. Setting labels to an empty array clears the labels for that conversation.
Mark DM read:
{
"seq": "25",
"cmd": "chat_dm_mark_read",
"data": {
"targetUserID": "1002"
}
}
Remove a conversation from the recent list (WeChat-style “delete chat”, soft-remove):
{ "seq": "rm-1", "cmd": "chat_dm_conversation_remove", "data": { "targetUserID": "1002" } }
{ "seq": "rm-2", "cmd": "chat_room_conversation_remove", "data": { "roomID": 12 } }
{ "seq": "rm-3", "cmd": "chat_group_conversation_remove", "data": { "groupID": 66 } }
Semantics (identical for DM / room / group):
- Soft remove: the conversation disappears from the recent list, but message history is fully preserved on the server.
- Clears unread on remove: the read cursor is pushed to the latest message, so the unread badge is cleared.
- Reappears on a new message: when the peer/group has a new message (or you send again in that conversation), it returns to the list with unread starting from 1.
- Per user: only affects the caller (and all their online devices); the other side’s list is untouched and no messages are deleted. Removing a group conversation is not the same as leaving the group.
- Removal is delivered via
chat_dm_conversation_update/chat_room_conversation_update/chat_group_conversation_update(payload contains"removed": true) to all of the user’s online connections, hiding it across devices. For DM you may passconversationIDinstead oftargetUserID.
DM typing:
{
"seq": "26",
"cmd": "chat_dm_typing",
"data": {
"targetUserID": "1002",
"isTyping": true
}
}
Send a nudge / strong reminder:
{
"seq": "27",
"cmd": "chat_dm_send",
"data": {
"targetUserID": "1002",
"segments": [
{
"type": "nudge",
"text": "nudged you"
}
]
}
}
Send a gift:
{
"seq": "28",
"cmd": "chat_dm_send",
"data": {
"targetUserID": "1002",
"segments": [
{
"type": "gift",
"giftID": "rose",
"key": "rose",
"count": 1,
"receiverID": "1002"
}
]
}
}
Room and group chats use the same segments shape:
- room:
chat_room_send - group:
chat_group_send - DM:
chat_dm_send
Gift fields:
type: fixed togiftgiftID: required gift ID;keywith the same value is also accepted for compatibilitykey: should matchgiftIDso older clients can render a fallbackcount: gift quantity; missing or non-positive values are normalized to1; current maximum is99receiverID/receiverName: optional; recommended for DM receiver display and targeted room/group UI
The server validates the gift against the built-in catalog and fills these fields. Clients should not trust local price data:
giftNamegiftIcon: built-in gifts now return/assets/gift-*.svgimage paths; clients should still tolerate legacy emoji stringsgiftAnimationunitPricetotalPricecurrencyType
Current built-in gift catalog:
| giftID | Name | Icon | Simulated unit price | Animation |
|---|---|---|---|---|
rose | Rose | /assets/gift-rose.svg | 1 coin | float |
coffee | Coffee | /assets/gift-coffee.svg | 3 coin | pop |
star | Star | /assets/gift-star.svg | 5 coin | sparkle |
heart | Heart | /assets/gift-heart.svg | 10 coin | pulse |
rocket | Rocket | /assets/gift-rocket.svg | 20 coin | launch |
crown | Crown | /assets/gift-crown.svg | 50 coin | shine |
This version only displays simulated coins and does not deduct a real balance. If real wallet/payment support is added later, implement server-side balance check, debit transaction, and idempotent ledger records in the send path.
6.4 Message edit / recall / delete / reaction
DM edit:
{
"seq": "30",
"cmd": "chat_dm_edit",
"data": {
"targetUserID": "1002",
"messageID": "msg-1",
"text": "edited"
}
}
DM recall:
{
"seq": "31",
"cmd": "chat_dm_recall",
"data": {
"conversationID": "dm:1001:1002",
"messageID": "msg-1"
}
}
DM delete:
{
"seq": "32",
"cmd": "chat_dm_delete",
"data": {
"conversationID": "dm:1001:1002",
"messageID": "msg-1"
}
}
DM reaction:
{
"seq": "33",
"cmd": "chat_dm_react",
"data": {
"targetUserID": "1002",
"messageID": "msg-1",
"emoji": "👍",
"action": "toggle"
}
}
Room commands are the same shape:
chat_room_editchat_room_recallchat_room_deletechat_room_react
Notes:
- edit and recall are time-window limited on server side
- delete is currently not time-window limited
6.5 Reply / quote / forward
Send with reference:
{
"seq": "40",
"cmd": "chat_dm_send",
"data": {
"targetUserID": "1002",
"text": "reply message",
"reference": {
"type": "reply",
"messageID": "msg-1"
}
}
}
Forward:
{
"seq": "41",
"cmd": "chat_room_send",
"data": {
"reference": {
"type": "forward",
"scene": "dm",
"conversationID": "dm:1001:1002",
"messageID": "msg-1"
}
}
}
6.6 Friend / block / DM policy
Create friend request:
{
"seq": "50",
"cmd": "chat_friend_add",
"data": {
"targetUserID": "1002",
"requestMessage": "hi, let's connect"
}
}
Friend request list:
{
"seq": "51",
"cmd": "chat_friend_request_list",
"data": {
"processedLimit": 20
}
}
Accept / reject:
{
"seq": "52",
"cmd": "chat_friend_accept",
"data": {
"targetUserID": "1002"
}
}
{
"seq": "53",
"cmd": "chat_friend_reject",
"data": {
"targetUserID": "1002"
}
}
Friend list:
{
"seq": "54",
"cmd": "chat_friend_list",
"data": {}
}
Set remark:
{
"seq": "55",
"cmd": "chat_friend_remark_set",
"data": {
"targetUserID": "1002",
"remark": "Product"
}
}
Block / unblock:
{
"seq": "56",
"cmd": "chat_block_add",
"data": {
"targetUserID": "1002"
}
}
{
"seq": "57",
"cmd": "chat_block_remove",
"data": {
"targetUserID": "1002"
}
}
DM whitelist add / remove / list:
{
"seq": "58",
"cmd": "chat_dm_whitelist_add",
"data": {
"targetUserID": "1002"
}
}
{
"seq": "59",
"cmd": "chat_dm_whitelist_remove",
"data": {
"targetUserID": "1002"
}
}
{
"seq": "60",
"cmd": "chat_dm_whitelist_list",
"data": {}
}
DM policy:
{
"seq": "61",
"cmd": "chat_dm_policy_set",
"data": {
"policy": "white_list"
}
}
Valid policy values:
allfriends_onlywhite_listno_one
Notes:
white_listmeans only users in the current user’s DM whitelist can send DMs to that user.- Blocks take precedence over the whitelist. A DM is still rejected if either side blocks the other.
7. Push Events That Clients Must Handle
7.1 chat_message
Main incoming room / DM message event.
Payload shape:
messageIDsceneroomIDconversationIDtargetUserIDsenderIDsenderNamesenderAvatarcontentTypetextsegmentsreferencereactionssentAt- mutation flags such as
isEdited,isRecalled,isDeleted
Common segments types:
textimagevideoaudiofileemojinudgegift
Example gift segment pushed by the server:
{
"type": "gift",
"giftID": "rose",
"key": "rose",
"giftName": "Rose",
"giftIcon": "/assets/gift-rose.svg",
"giftAnimation": "float",
"count": 1,
"unitPrice": 1,
"totalPrice": 1,
"currencyType": "coin",
"receiverID": "1002"
}
Recommended client behavior:
- render conversation preview as
[Gift] Rose x1 - render an in-bubble gift card with at least
giftIcon,giftName, andcount - when a gift arrives in the current conversation, or after the local send succeeds, play a non-blocking 1-2 second animation based on
giftAnimation - fall back to a generic gift card /
popanimation for unknowngiftIDor animation names - do not replay animations when rendering historical messages; only play them for realtime pushes or successful local sends
7.2 chat_typing
Payload:
sceneroomIDconversationIDtargetUserIDuserIDuserNameavatarisTypingexpiresAt
7.3 chat_status
Transient status event. It uses the same basic shape as chat_message, with:
contentType:statusstatusTypestatusDataexpiresAt
Clients should treat it as temporary UI state and should not write it into local message history.
7.4 chat_presence_*
Presence subscriptions are per-user. A user receives chat_presence_update when a subscribed target user goes online or offline.
Subscribe / unsubscribe:
{ "seq": "presence-sub-1", "cmd": "chat_presence_subscribe", "data": { "targetUserIDs": ["1002", "1003"] } }
{ "seq": "presence-unsub-1", "cmd": "chat_presence_unsubscribe", "data": { "targetUserID": "1003" } }
List subscriptions and query current state:
{ "seq": "presence-list-1", "cmd": "chat_presence_subscriptions", "data": {} }
{ "seq": "presence-query-1", "cmd": "chat_presence_query", "data": { "userIDs": ["1002", "1003"] } }
chat_presence_update payload:
userIDonlineupdatedAt
7.5 chat_device_sync
Multi-device sync is per account. When one session changes local conversation state or mutates a message, other online sessions of the same user receive chat_device_sync.
Snapshot after reconnect:
{
"seq": "device-sync-state-1",
"cmd": "chat_device_sync_state",
"data": {
"scenes": ["dm", "room", "group"],
"limit": 100
}
}
chat_device_sync payload:
event:conversation_read,conversation_muted,conversation_pinned,conversation_labels, ormessage_mutationsceneconversationID,roomID,groupIDsourceSessionIDconversation: latest conversation summary when the event changes conversation statemutation: message mutation payload whenevent=message_mutation
7.6 chat_dm_conversation_update / chat_room_conversation_update / chat_group_conversation_update
Use these to update conversation list without polling. When the payload contains "removed": true, remove that conversation from the list (a WeChat-style soft remove; it returns on the next message).
7.7 chat_dm_read_receipt
Payload:
conversationIDreaderUserIDreadAt
7.7.1 chat_group_read_receipt
Payload:
scene:groupconversationIDgroupIDreaderUserIDlastReadMessageIDreadAt
7.8 chat_dm_delivery_receipt
Payload:
conversationIDmessageIDreceiverUserIDdeliveredAt
7.9 chat_receipts
Use this after reconnect to reload persisted message receipt state. Example:
{
"cmd": "chat_receipts",
"seq": "receipt-1",
"data": {
"scene": "dm",
"conversationID": "dm:1001:1002",
"messageIDs": ["msg-1", "msg-2"]
}
}
Each response item contains:
messageIDdeliveredCountreadCountusers:userID,deliveredAt,readAt
For scene=group, pass groupID; the server filters directed group messages that are not visible to the current user. For scene=room, only the currently joined room can be queried.
When chat_dm_mark_read or chat_group_mark_read succeeds, the server persists read receipts for messages up to lastReadMessageID in that conversation, capped by the server batch limit. This lets chat_receipts return read state for older messages after reconnect.
7.8 chat_message_mutation
Use this to patch messages after:
- edit
- recall
- delete
- reaction
7.9 chat_friend_request_update
Use this to update the friend request center in real time.
8. Media Upload Integration
8.1 Upload endpoint
POST /user/chat/upload
Content-Type: multipart/form-data
Authorization: Bearer <JWT>
Form fields:
scene:room/group/dmroomID: required whenscene=roomgroupID: required whenscene=groupconversationID: optional whenscene=dmtargetUserID: optional fallback forscene=dmtype:image/video/audio/filefile: binary file
Example:
curl -H "Authorization: Bearer <JWT>" \
-F "scene=dm" \
-F "targetUserID=1002" \
-F "type=image" \
-F "file=@/tmp/demo.png" \
http://127.0.0.1:8080/user/chat/upload
Response contains:
assetsegment
The client should put segment into the chat send request.
Recommended client rendering:
image: render inline thumbnail and open in an in-page viewervideo: render inline with<video controls>or a native playeraudio: render with audio controlsfile: render as a file cardpdf: uploaded asfile, but should be previewed in-page when possible
Voice file notes:
- Upload voice messages with
type=audio - Common accepted extensions:
mp3/wav/aac/m4a/ogg/opus/webm - Web
MediaRecorderusually producesaudio/webm;codecs=opus - Some browsers label recorded audio as
video/webm; if the uploadtypeisaudioand the extension is.webm, the server accepts it as a voice file
8.2 Access signed file URL
GET /user/chat/upload/access?scene=dm&conversationID=dm:1001:1002&objectKey=...
Authorization: Bearer <JWT>
If redirect=1 is set, server responds with 302 to OSS signed URL.
8.3 Voice messages / press-to-talk
Recommended client interaction:
- Place a voice button in the composer
- Start recording on
pointerdown/ long press - Stop recording and send immediately on
pointerup - Cancel on
pointercancelor permission failure - Web clients need HTTPS or localhost to request microphone permission
Send flow:
- Record audio on the client. Web clients can use
navigator.mediaDevices.getUserMedia({ audio: true })andMediaRecorder - Create an audio file after recording, for example
voice-20260512.webm - Call
POST /user/chat/uploadwithscene,roomID/groupID/conversationID/targetUserID,type=audio, andfile - Put the returned
segmentintochat_dm_send/chat_group_send/chat_room_send
DM voice message example:
{
"seq": "audio-1",
"cmd": "chat_dm_send",
"data": {
"targetUserID": "1002",
"segments": [
{
"type": "audio",
"url": "https://cdn.example.com/chat/dm/dm:1001:1002/audio/voice.webm",
"objectKey": "chat/dm/dm:1001:1002/audio/voice.webm",
"fileName": "voice.webm",
"mimeType": "audio/webm",
"durationMs": 8200
}
]
}
}
Clients should include durationMs in the segment so receivers can display the voice duration.
8.4 Chatroom broadcast messages
POST /user/chat/room/broadcast
Authorization: Bearer <admin token>
Content-Type: application/json
{
"fromUserID": "system",
"broadcastID": "room-broadcast-20260521-1",
"objectName": "BALALA:ChatroomBroadcast",
"content": "{\"content\":\"maintenance in 5 minutes\"}",
"text": "maintenance in 5 minutes",
"data": { "level": "notice" },
"isIncludeSender": false
}
The server delivers chat_room_broadcast only to users currently joined to any chatroom. Users who join later do not receive it. Broadcast messages are not written to chatroom history, do not update conversation summaries, do not create unread counts, and do not trigger offline push. By default the sender’s online clients are excluded; pass isIncludeSender=true to include them. objectName defaults to BALALA:ChatroomBroadcast, and content is capped at 128 KB.
8.5 End-to-end encrypted messages
chat_dm_send, chat_group_send, and chat_room_send accept an encrypted payload. The server stores, routes, callbacks, and pushes the message as contentType=encrypted, but never decrypts the ciphertext.
{
"seq": "enc-1",
"cmd": "chat_dm_send",
"data": {
"targetUserID": "1002",
"encrypted": {
"algorithm": "x3dh-aes-gcm",
"ciphertext": "BASE64_CIPHERTEXT",
"keyID": "session-key-1",
"senderKeyID": "device-key-1001",
"recipientKeyIDs": ["device-key-1002"],
"nonce": "BASE64_NONCE",
"signature": "BASE64_SIGNATURE",
"digest": "sha256:...",
"version": 1
}
}
}
Encrypted sends must not include text, segments, or reference. Conversation summaries and offline push use the generic preview [加密消息]. Local text moderation is skipped because plaintext is unavailable; send permissions, rate limits, persistence, callbacks, routing, and delivery still apply.
8.6 Speech-to-text
POST /user/chat/audio/transcribe
Content-Type: application/json
Authorization: Bearer <JWT>
Request example:
{
"scene": "dm",
"conversationID": "dm:1001:1002",
"messageID": "msg-1",
"objectKey": "chat/dm/dm:1001:1002/audio/voice.webm",
"language": "zh",
"mimeType": "audio/webm",
"fileName": "voice.webm",
"durationMs": 8200
}
Fields:
scene:dm/group/roomconversationIDortargetUserID: required forscene=dmgroupID: required forscene=grouproomID: required forscene=roomobjectKey: audio object key returned by upload; it must point to a file under an/audio/pathlanguage: optional; pass the user’s current language or the expected speech language when available
Response example:
{
"code": 0,
"msg": "",
"data": {
"result": {
"text": "Hello, let's meet later.",
"language": "en",
"provider": "webhook",
"durationMs": 8200
},
"text": "Hello, let's meet later.",
"language": "en",
"provider": "webhook",
"durationMs": 8200
}
}
The server verifies that the JWT user can access the DM / group / room, generates a short-lived OSS signed URL, and calls the configured transcription provider. If speech.transcribe.enabled=true is not configured, the endpoint returns “语音转文字未启用”.
8.7 Message Callback Service
The server can call a pre-send message callback for external moderation, bots, or content rewriting. It runs for chat_dm_send / chat_group_send / chat_room_send after permission checks and local moderation, but before history persistence and delivery.
Config:
chat:
message_callback:
enabled: true
url: "https://example.com/im/message-callback"
app_key: "your-app-key"
token: ""
secret: ""
timeout_seconds: 5
fail_open: true
Request method: POST application/x-www-form-urlencoded. Key fields:
fromUserIdtargetIdtoUserIdsmsgTypecontent: this project’sChatMessageJSONchannelType:PERSON/GROUP/TEMPGROUPmessageIdmsgTimeStamp
Allow:
{ "pass": 1 }
Block:
{ "pass": 0, "extra": "message rejected" }
Rewrite text:
{ "pass": 1, "replaceContent": "{\"content\":\"rewritten text\"}" }
The callback may also return { "allow": true, "text": "rewritten text" }. When secret is configured, the server sends X-Chat-Callback-Timestamp, X-Chat-Callback-Nonce, and X-Chat-Callback-Signature; the signature is HMAC-SHA256 over timestamp + "\n" + nonce + "\n" + body.
8.8 Full Message Route
The server can route a copy of every successfully sent chat_dm_send / chat_group_send / chat_room_send message to a business server asynchronously. Route failures do not affect persistence, realtime delivery, conversation updates, or offline push.
Config:
chat:
message_route:
enabled: true
url: "https://example.com/im/message-route"
app_key: "your-app-key"
app_secret: "your-app-secret"
token: ""
timeout_seconds: 5
Request method: POST application/x-www-form-urlencoded. Key fields:
event: currently alwaysmessagescene:dm/group/roomfromUserIdtargetId: DM target user ID, group ID, or chatroom room IDtoUserIds: group direct-message audiencemsgTypecontent: this project’sChatMessageJSONchannelType:PERSON/GROUP/TEMPGROUPmessageId/msgUIDmsgTimeStampconversationID/roomID/groupID
When app_key / app_secret are configured, the server appends appKey, nonce, timestamp, and signature to the callback URL query. signature is sha1(app_secret + nonce + timestamp).
8.9 Full User Notification Service
The business server can send a system-conversation notification to all users through the admin HTTP API. Online users receive chat_system_notification; clients should render it as a non-replyable system conversation message.
POST /user/chat/notification/all
Authorization: Bearer <admin token>
Content-Type: application/json
Request example:
{
"fromUserID": "system",
"notificationID": "notice-20260521-1",
"title": "Event reminder",
"text": "The weekend event is starting soon.",
"data": { "url": "https://example.com/events/1" },
"push": true,
"pushTitle": "Event reminder",
"pushBody": "The weekend event is starting soon.",
"ttlSeconds": 86400
}
Fields:
fromUserID: system conversation target ID, defaults tosystemnotificationID: generated when omitted; push tasks are deduped per user by this IDobjectName: defaults toBALALA:SystemNotificationtitle/text/content: at least one is requiredpush: when true, the server enqueues offline push tasks for users with active push devicesdata: business extension fields
Tag user notification:
POST /user/chat/notification/tag
Authorization: Bearer <admin token>
Content-Type: application/json
Additional request fields:
tags: target user tags, for example["vip", "me-region"]matchAllTags: when true, users must have all tags; by default, any tag match is enough
Set user notification tags:
POST /user/chat/notification/tags
Authorization: Bearer <admin token>
Content-Type: application/json
{
"userID": "1001",
"tags": ["vip", "me-region"]
}
Query user notification tags:
GET /user/chat/notification/tags?userID=1001
Authorization: Bearer <admin token>
Online user notification:
POST /user/chat/notification/online
Authorization: Bearer <admin token>
Content-Type: application/json
The request fields are the same as full notification, but the server forces:
- delivery only to currently online WebSocket users
- no offline push task
chat_online_notification.landing=false
Clients should treat chat_online_notification as a transient notification and should not write it into local message history.
8.10 Message Log Download
Admins can download local IM message logs by hour. The response is a gzip-compressed JSON Lines file. Each line is one message and covers DM, group, and chatroom messages.
GET /user/chat/message-log?date=2026052117
Authorization: Bearer <admin token>
date must be YYYYMMDDHH, meaning the requested hour in the server’s local time zone. The response file is chat-message-log-<date>.jsonl.gz.
Each line contains:
scene:dm/group/roomconversationIDroomID/groupIDmessageIDpayload: original server-to-client envelope JSONcreatedAt
chat_system_notification payload:
notificationIDfromUserIDscope:alltagsobjectNametitletextcontentdatalanding:truesentAt
9. Offline Push Device Registration
Native clients should register push token after successful login.
Register:
{
"seq": "60",
"cmd": "push_device_register",
"data": {
"deviceID": "ios-001",
"platform": "ios",
"pushToken": "device-token",
"vendor": "apns",
"appID": "com.demo.app"
}
}
Unregister:
{
"seq": "61",
"cmd": "push_device_unregister",
"data": {
"deviceID": "ios-001"
}
}
Suggested vendor values:
- iOS:
apns - Android Google:
fcm - Android vendor push gateway:
huawei,xiaomi,oppo,vivo,honor
10. API Rate Limit Plans
The server supports package-style API rate limits for both WebSocket commands and HTTP routes. When enabled, the same identity shares a token bucket for the same plan and endpoint. When exceeded:
- WebSocket returns a normal response envelope with
code=OperationFailureandmsg=API call frequency exceeded - HTTP returns
429 Too Many Requestswithplan,kind,name,perMinute, andburst /system/stateexposestotal_api_rate_limited
Configuration example:
api_rate_limit:
enabled: true
active_plan: "ultimate"
plans:
default:
per_minute: 120
burst: 30
premium:
per_minute: 600
burst: 120
ultimate:
per_minute: 3000
burst: 600
overrides:
ws:
chat_room_send:
per_minute: 1200
burst: 240
chat_room_broadcast:
per_minute: 60
burst: 10
http:
"POST_/user/chat/room/broadcast":
per_minute: 60
burst: 10
Notes:
active_planselects the service package; missing plans fall back toplans.default.overrides.ws.<cmd>overrides a specific WebSocket command.overrides.http.<METHOD>_<route>overrides a specific HTTP route, for examplePOST_/user/chat/room/broadcast.- Identity uses JWT user first, then admin token hash, then source IP.
11. Platform Integration Notes
11.1 Web
Browser example:
const token = "<JWT>";
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${protocol}://${window.location.host}/acc?token=${encodeURIComponent(token)}`);
ws.onopen = () => {
ws.send(JSON.stringify({
seq: "1",
cmd: "login",
data: {
userID: "1001",
userName: "alice",
avatar: "https://cdn.example.com/a.png"
}
}));
setInterval(() => {
ws.send(JSON.stringify({
seq: String(Date.now()),
cmd: "heartbeat",
data: {}
}));
}, 30000);
};
ws.onmessage = (event) => {
const packet = JSON.parse(event.data);
console.log(packet);
};
Notes:
- Browser automatically sends
Origin - Frontend origin must match server whitelist
- Upload, image preview, and PDF preview should use current-site relative URLs instead of hard-coded
127.0.0.1
11.2 Android
OkHttp example:
val request = Request.Builder()
.url("wss://im.example.com/acc?token=$jwt")
.addHeader("Origin", "https://im.example.com")
.build()
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
val login = """
{
"seq":"1",
"cmd":"login",
"data":{
"userID":"1001",
"userName":"alice"
}
}
""".trimIndent()
webSocket.send(login)
}
override fun onMessage(webSocket: WebSocket, text: String) {
Log.d("IM", text)
}
}
OkHttpClient().newWebSocket(request, listener)
Android recommendations:
- heartbeat every
30s - reconnect with exponential backoff
- cache unsent messages locally by
seq - re-register push token after app reinstall or token rotation
11.3 iOS
URLSessionWebSocketTask example:
var request = URLRequest(url: URL(string: "wss://im.example.com/acc?token=\(jwt)")!)
request.setValue("https://im.example.com", forHTTPHeaderField: "Origin")
let task = URLSession.shared.webSocketTask(with: request)
task.resume()
let login = """
{
"seq":"1",
"cmd":"login",
"data":{
"userID":"1001",
"userName":"alice"
}
}
"""
task.send(.string(login)) { error in
if let error = error {
print("send error: \(error)")
}
}
iOS recommendations:
- if
Origincannot be controlled in your networking stack, setauth.allow_empty_origin=true - register APNs token with
push_device_register - reconnect on app foreground
12. Error Codes
Common codes:
200: success1000: not logged in1001: parameter illegal1002: unauthorized user id1003: unauthorized1004: server error1009: operation failure1010: routing not exist
13. Client Recommendations
13.1 Required client state
At minimum, keep these locally:
- current JWT
- current user id
- conversation list cache
- message list by conversation
- message sending state by
seq - upload asset metadata
- last read cursor
13.2 Reconnect strategy
Recommended:
- reconnect with exponential backoff
- after reconnect, send
login - resume heartbeat
- reload conversation list
- reload incremental history by cursor if needed
13.3 Message deduplication
Use these keys:
- request side:
seq - message side:
messageID
13.4 UI behavior
Recommended UI mapping:
chat_typing: transient typing indicatorchat_message_mutation: patch local message itemchat_dm_conversation_update/chat_room_conversation_update/chat_group_conversation_update: patch list item (or remove it when payload has"removed": true)chat_dm_read_receipt: update read statuschat_group_read_receipt: update group read statuschat_dm_delivery_receipt: update delivered statussegments.image: render thumbnail + in-page image viewersegments.video: inline playbacksegments.filewithmimeType=application/pdf: in-page PDF previewsegments.nudge: strong reminder, e.g. vibration, title flash, modal promptsegments.gift: render a gift card; play a shortgiftAnimationonly for realtime messages or successful local sends, not for historical replay
14. Minimal Integration Checklist
Shared
- obtain JWT from business server
- connect
/accwith JWT - send
login - start heartbeat
- implement generic message envelope parsing
- handle reconnect
Web
- ensure browser origin is whitelisted
- implement DM + room chat commands
- implement upload flow
Android
- set
Originif possible - register push token
- reconnect on network change
iOS
- set
Originif possible or allow empty origin on server - register APNs token
- refresh UI from push events after reconnect