Events and error handling
The Web SDK tells you about data and state changes through events, and reports every error as a DRError. This page lists all events and their payloads, the structure of the error object, all error codes with handling advice, and what the SDK does automatically when offline or when something fails.
Most UIs don't need to handle data-change events directly: live lists such as the conversation list and message list update themselves. Events are mainly for three things: sign-in and connection changes (returning to the login page, showing "Connecting…"), alerts (new message notifications, incoming calls), and knowing when to re-read values you read synchronously (unread total, typing and so on).
| You want to | See |
|---|---|
| Subscribe and unsubscribe | Subscribe and unsubscribe |
| Look up an event's payload | All events |
| Keep badges, status and other synchronous values current | Reading synchronous values with events |
| Check errors and look up error codes | The error object, SDK error codes, Common server error codes |
| Show error messages to users | Showing error messages |
| Know what the SDK retries automatically | Offline and retries |
Subscribe and unsubscribe
const off = im.on('message.received', ({ message, alert }) => {
if (alert) showToast(`${message.sender}:新消息`);
});
// 不再需要时取消
off();
// 只处理一次
im.once('sync.completed', ({ trigger }) => console.log('第一次同步完成', trigger));im.on(event, listener)returns an unsubscribe function that's safe to call more than once;im.oncefires only once.- Event names and payloads are typed: the
listenerargument type comes from the event name (DREvents[eventName]). - Exceptions thrown by a listener are logged as
warnand don't affect other listeners or the SDK. - Data-change events fire after local data is updated. Payloads only say what changed (such as which conversations), and you read the latest data from the matching list or method.
- A change is emitted only once: the result of the user's own action and the server notification that follows are merged, so there are no duplicate events.
- Every tab in the same browser receives the same events.
- After
im.destroy(), no more events are emitted.
In React and Vue, subscribe with the bindings' useDREvent, which unsubscribes when the component unmounts; see React and Vue.
All events
Sign-in and connection
| Event | Payload | Description |
|---|---|---|
auth.stateChanged | AuthState | Sign-in state changed: signed in, signed out, session ended (with a reason), service suspended or resumed. See Session ended |
auth.userSwitched | { username: string } | Another tab signed in a different user and this tab has switched to them. Open lists have been emptied and disposed; reload your UI for the new user |
connection.stateChanged | ConnectionState | Connection state changed; see Connection state |
sdk.upgradeRequired | { min_version: string } | The SDK is older than the minimum version the server requires. The connection has stopped and local data is read-only. Ask the user to reload or upgrade |
tabs.roleChanged | { isLeader: boolean; isAlertTab: boolean } | This tab became or stopped being the leader tab or the alert tab |
tabs.refreshRequired | { reason: 'protocol_version' | 'storage_version' } | A newer version of the page was opened in this browser, or local data was upgraded by a newer version, so this page can only read local data. Ask the user to reload |
Sync and local data
| Event | Payload | Description |
|---|---|---|
sync.completed | { modules: SyncModule[]; trigger: string } | A sync round finished. modules lists the modules synced (config, me, friends, groups, messages, push, calls, chatrooms, presence). trigger is what started it: login (after sign-in), reconnect (after reconnecting), sync_required (the server requested a full sync), hint (the server signaled new data), foreground (the page returned to the foreground), periodic (a periodic check), fallback (periodic sync while the connection is unavailable) |
sync.failed | { module: SyncModule; error: DRError } | A module failed to sync. The SDK retries automatically; usually just log it |
storage.reset | { reason: 'reset_required' | 'reloaded'; dropped_messages?: number } | Local data was reset (at the server's request) or reloaded from local storage. Open lists have re-read automatically; you only need to re-read data you stored yourself. dropped_messages is the number of unsent messages dropped as a result |
storage.modeChanged | { mode: 'indexeddb' | 'memory'; reason: 'unavailable' | 'quota_exceeded' | 'upgrade_blocked' } | Local data is now kept only in memory: the browser doesn't allow IndexedDB, the disk is full, or an older page blocked the upgrade. Features keep working, but data is reloaded from the server after a page reload |
Runtime configuration and the user
| Event | Payload | Description |
|---|---|---|
config.changed | ClientConfig | null | The app's runtime configuration changed; same as im.config. null after sign-out |
me.changed | SelfProfile | null | The user's profile changed; same as im.users.me(). null after sign-out |
me.muteChanged | { scope: 'chat' | 'group' | 'room'; muted: boolean; expires_at: string | null } | The user was globally muted or unmuted (received during the current connection) |
users.changed | { usernames: string[] } | Other users' cached profiles updated; UIs that use im.users.get() should re-read |
devices.changed | { devices: OnlineDevice[] } | The user's online devices changed; same as im.devices.online() |
devices.newLogin | { session_id: string; device_name: string; platform: string; login_method: string; created_at: string } | The user signed in on a new device; you can show "Your account signed in on XX" |
Conversations and messages
| Event | Payload | Description |
|---|---|---|
conversations.changed | { upserted: string[]; removed: string[] } | Conversations in the list were added, changed or removed; values are conversation_keys |
conversations.unreadChanged | { total: number; source: 'local' | 'server' } | The unread total changed; total matches im.conversations.getUnreadTotal() |
messages.changed | { conversation_key: string; upserted: number[]; removed: number[]; pending: string[]; renamed_from?: string } | Messages in a conversation changed. upserted and removed are message sequence numbers; pending lists changed client_msg_ids in the send queue. renamed_from means a draft conversation became a real one, and holds the old draft key |
message.received | { message: MessageView; notify: NotifyDecision | null; alert: boolean } | A new message from someone else, for alerts. notify says whether it should alert (mutes and quiet hours already considered), and alert: true means this tab handles alerts. Only emitted for messages received in real time, not for backfilled history or messages from while offline synced after reconnecting |
message.sendFailed | { client_msg_id: string; conversation_key: string; error: DRError } | A message finally failed to send (automatic retries ran out, or the server rejected it). The message stays in the list with status failed, so the user can resend or delete it |
message.online | { message: OnlineMessage } | An online-only message (sent with sendOnline, such as custom signaling like "the other user is doing something"). Not stored |
typing.changed | { conversation_key: string; usernames: string[] } | Who is typing in a conversation changed |
receipts.changed | { conversation_id: string; seqs: number[] } | Read counts of group messages changed; read them with im.messages.getReceipt() |
Contacts and groups
| Event | Payload | Description |
|---|---|---|
friends.changed | { upserted: string[]; removed: string[] } | Friends added, profile or remark changed, or removed; values are usernames |
blacklist.changed | { upserted: string[]; removed: string[] } | The blocklist changed |
friendRequests.changed | { lists: Array<'received' | 'sent'>; unread_count: number } | Received or sent friend requests changed; unread_count is the number of unread received requests |
groups.changed | { upserted: string[]; removed: string[] } | "My groups" or group information changed; values are group IDs |
groupMembers.changed | { group_id: string; usernames: string[] | null } | Group members changed; usernames: null means the whole member list changed |
groupRequests.changed | { list: 'pending' | 'invitations' | 'applications'; group_id: string | null } | Group requests changed: pending is a group's requests awaiting approval (with group_id), invitations the user's received invitations, applications the user's join applications |
Push settings and presence
| Event | Payload | Description |
|---|---|---|
push.changed | { settings: boolean; mutes: Array<{ conversation_type: string; target: string }> } | Push settings (settings: true) or conversation mutes changed |
presence.changed | { items: PresenceState[]; reset: boolean; cleared?: string[] } | Presence of subscribed people changed. reset: true means all local presence was cleared (for example after reconnecting); cleared lists people whose status was cleared |
presence.subscribeFailed | { usernames: string[]; reason: 'denied' | 'limit_exceeded' | 'presence_disabled' } | People whose subscription didn't take effect: not allowed to view, over the subscription limit, or presence isn't on for the app |
Audio and video
| Event | Payload | Description |
|---|---|---|
call.incoming | { call: CallHandle; alert: boolean } | An incoming call. When alert is true, this tab rings and shows the incoming-call UI |
call.changed | { call: CallHandle; change: CallChange | null } | A call's status, members or other details changed. change: null means it was caused by this tab's own action or a resync |
groupCall.changed | GroupCallBanner | null | An ongoing call in a group changed, for showing a "group call in progress" banner (group_id, call_id, media, status, joined_count, initiator, version). null means a group's banner was removed; re-read each group's banner with im.calls.groupCallBanner(group_id) |
For details on calls, see Audio and video calls. Chat room messages, member entry and exit and so on are subscribed to on the object you get when entering a room; see Chat rooms. Game room events are in Game room events.
Reading synchronous values with events
These values can be read synchronously, and an event fires when each changes. Subscribe to the event and re-read to keep your UI current:
| Read | Event on change |
|---|---|
im.auth.state, im.auth.currentUser | auth.stateChanged |
im.connection.state | connection.stateChanged |
im.connection.isLeader, im.connection.isAlertTab | tabs.roleChanged |
im.config | config.changed |
im.users.me() | me.changed |
im.users.myMute | me.muteChanged |
im.users.get(username) | users.changed |
im.conversations.getUnreadTotal() | conversations.unreadChanged |
im.conversations.getTyping(conversation_key) | typing.changed |
im.friends.get(username), im.friends.listInfo | friends.changed |
im.friends.blacklist.has(username) | blacklist.changed |
im.friends.requests.unreadCount | friendRequests.changed |
im.messages.getReceipt(conversation_id, seq) | receipts.changed |
im.push.settings(), im.push.getMute(target) | push.changed |
im.presence.get(username) | presence.changed |
im.devices.online() | devices.changed |
im.calls.current, im.calls.incoming | call.incoming, call.changed |
im.calls.groupCallBanner(group_id) | groupCall.changed |
When a value hasn't changed, reading returns the same object. These values may also change on sign-in, sign-out, user switch or a local data reload, and the matching events fire then too.
const showUnread = () => render(im.conversations.getUnreadTotal());
im.on('conversations.unreadChanged', showUnread);
showUnread();The error object
Every SDK error is a DRError: methods returning a Promise reject with it, synchronous methods throw it, and event payloads such as message.sendFailed and sync.failed carry it.
import { DRError } from '@deeprespond/im-web';
try {
await im.groups.leave('g_123');
} catch (err) {
if (!(err instanceof DRError)) throw err;
console.log(err.code, err.reason, err.details, err.status, err.requestId, err.retryAfter, err.source);
}| Field | Type | Description |
|---|---|---|
code | string | Error code: the server's error code, or one of the SDK's own (see below) |
reason | string | undefined | Reason, i.e. details.reason |
details | Record<string, unknown> | undefined | The server's details as is; for SDK errors, extra information |
status | number | undefined | HTTP status; absent for connection errors and SDK errors |
requestId | string | undefined | The server's request_id; give it to support |
retryAfter | number | undefined | Suggested wait in seconds |
source | 'http' | 'ws' | 'local' | Where the error came from: an HTTP request, the connection, or the SDK locally |
message | string | A description for debugging. Don't show it to users; for user-facing text, see Showing error messages |
Decide based on code and reason, not status or message.
How it relates to server error codes
Server errors are passed to you as is: code and details match the server docs, and source is http or ws. For each code's meaning and its details values, see the server error codes. Client-specific codes (such as user_muted, group_muted, not_friend, user_blocked) are under error codes for console and client APIs.
Before sending a request, the SDK checks locally and rejects anything the server would certainly reject, with source local:
- Features that are off or not permitted (such as group read receipts not enabled, or a regular member mentioning everyone) use the server's
permission_deniedwith the same reason, such asread_ack_disabled,mention_all_deniedorpresence_disabled. - Going over the server's fixed rate limits (such as "mark all read" twice per minute) uses
rate_limitedwithretryAfter. - Invalid parameters or content (size, format, required fields, unsupported characters and so on) always use
local_validation, withdetails.reasonanddetails.fieldexplaining why.local_validationalways means the request wasn't sent.
That way you handle "unavailable or not permitted" and "too frequent" once, by the server's error codes.
SDK error codes
code | Meaning | What to do |
|---|---|---|
network_error | The network is unavailable or the request didn't arrive | Ask the user to check the network and retry later |
timeout | The request timed out | Retry later; for writes, check the result first; see Offline and retries |
aborted | Cancelled by your AbortSignal | No message |
not_signed_in | A method that needs sign-in was called while signed out | Sign in first |
not_connected | An operation that must go over the connection (such as some chat room operations) was called while disconnected | Try again once connected |
leader_unavailable | The leader tab in this browser is changing, and the call didn't finish | Retry later; see Multiple tabs |
local_validation | Failed a local check; the request wasn't sent. details.reason is the reason (such as required, too_large, control_characters, url_not_allowed) and details.field the field | Ask the user to correct it |
storage_error | Reading or writing local storage failed | Ask the user to reload |
unsupported | The browser doesn't meet the requirements, or the server doesn't provide a needed service (such as uploading when there's no file service; details.reason is media_disabled) | Suggest another browser, or hide the feature |
permission_required | The user hasn't allowed the microphone or camera; details.permissions lists what's missing | Guide the user to allow it in the browser |
device_error | The microphone or camera is missing or in use. details.device is microphone or camera; details.reason is not_found or busy | Ask the user to check the device |
invalid_state | Can't be called in the current state. details.reason says why: client_exists (this page already has a client for this AppKey), destroyed (the client was destroyed), disposed (the list was disposed), signed_in (can't clear the current user's local data), upgrade_required (the SDK is too old and local data is read-only), and so on | Usually a call-order problem; check your code |
send_abandoned | The SDK gave up sending a message. details.reason is attachment_lost (the attachment is gone, such as one over 50 MB after the page closed) or expired (not sent within 48 hours) | Ask the user to send it again |
Reasons for local_validation
details.reason of local_validation says what's wrong, and details.field names the field (such as body.text or username):
details.reason | Meaning |
|---|---|
required | A required parameter or field is empty |
invalid_format | Wrong format, such as appKey, apiUrl or client_msg_id |
invalid_value | The value isn't one of those allowed |
invalid_type | The parameter has the wrong type |
out_of_range | A number is out of range |
too_long | Text is too long |
too_large | Content or a file is too large, such as a message over max_message_body_bytes in the runtime configuration or an attachment over max_upload_bytes |
too_many_items | Too many items at once, such as deleting more than 100 messages in one call |
control_characters | The text has unsupported control characters (only text message bodies allow line feeds, carriage returns and tabs) |
invalid_body, unknown_type | The message content doesn't fit the message type; the message type isn't supported |
mentions_not_allowed, receipt_not_allowed | Mentions and group read receipts only work in group chats |
url_not_allowed | The app only accepts this service's file URLs, and this URL doesn't qualify |
invalid_purpose | This file can't be used here (such as a video as an avatar) |
invalid_ext, invalid_priority, invalid_attribute_value, attributes_too_large | A chat room extension, message priority or attribute doesn't qualify |
too_many_invitees | Too many people invited to a call at once |
invalid_evidence, self_report | Report evidence doesn't qualify; you can't report yourself |
not_found | The conversation or message to act on isn't found locally (such as resending a message no longer in the send queue) |
describeError has specific messages for the common reasons; the rest show a generic "content doesn't meet the requirements" message.
Common server error codes
code | Meaning | What to do |
|---|---|---|
invalid_argument | Parameters break the server's rules | Ask the user to correct it; details.reason says why |
unauthenticated, session_revoked | Sign-in is no longer valid | Handled by the SDK (refresh or end the session); you only handle ended in auth.stateChanged |
invalid_credentials | Wrong username, password or ticket | See Sign-in errors |
permission_denied | Not permitted, or the feature is off; details.reason says why | Show a message based on the reason, or hide the feature |
not_found | The user, group, conversation, message or similar doesn't exist or was deleted | Show "This doesn't exist or was deleted" |
already_exists | What you're creating already exists | Treat as success, or tell the user |
version_conflict | The data was changed | Re-read, then submit again |
limit_exceeded | A count limit was exceeded; details.reason says which | Tell the user the limit is reached |
payload_too_large | Content or a file is too large | Ask the user to shorten the content or use a smaller file |
rate_limited | Too frequent | Retry after retryAfter; the SDK already waits and retries requests that are safe to retry |
too_many_attempts | Too many wrong passwords | Ask the user to try again after retryAfter |
message_rejected, content_rejected | The message or profile failed content moderation | Ask the user to change the content. When reason is app_rejected, message is the text your server gave and can be shown directly |
user_muted, group_muted | The user is globally muted, muted in the group, or the group has mute-everyone on | Show that they're muted; details.expires_at is when it ends |
user_blocked | Blocked by the other user | Show "The other user isn't accepting your messages" |
not_friend | The app only allows messaging friends, and they aren't friends | Ask the user to add them as a friend first |
not_group_member | The user is no longer in the group | Tell the user and refresh the UI |
tenant_unavailable, app_unavailable | The service is suspended or read-only | Show "Service temporarily unavailable"; see Service suspended |
internal | Server failure | Retry later; if it persists, contact support with the requestId |
Showing error messages
describeError in @deeprespond/im-web/locale/zh-CN gives Chinese message text based on code and reason, such as "发送太频繁,请稍后再试" (sending too fast, try again later), "群主已开启全员禁言" (the owner muted everyone) or "你已被禁言,至 10 月 5 日 18:00" (you're muted until 18:00 on October 5):
import { DRError } from '@deeprespond/im-web';
import { describeError } from '@deeprespond/im-web/locale/zh-CN';
im.on('message.sendFailed', ({ error }) => showToast(describeError(error)));
try {
await im.friends.requests.send({ username: 'lisi', message: '你好' });
} catch (err) {
if (err instanceof DRError) showToast(describeError(err));
}Lookup order is code.reason first (such as permission_denied.mention_all_denied), then code; if neither exists, a generic "server busy, try again later" message is shown. Exception: for message_rejected and permission_denied with reason app_rejected, the server's message (the text your server gave) is shown.
To change some messages or use another language, pass a second argument. The keys of the zhCN.errors table are the code or code.reason described above:
import { describeError, zhCN } from '@deeprespond/im-web/locale/zh-CN';
import type { DRError } from '@deeprespond/im-web';
const myLocale = {
errors: {
rate_limited: '手速太快啦,休息一下',
'permission_denied.mention_all_denied': '只有管理员可以 @所有人',
},
};
const tip = (err: DRError) => describeError(err, myLocale);
console.log(Object.keys(zhCN.errors)); // 内置的全部条目Offline and retries
The SDK handles errors automatically by type, and you only handle the error that finally comes back:
| Category | Error codes | What the SDK does |
|---|---|---|
| Sign-in invalid | unauthenticated, session_revoked | For expired tokens, refreshes and resends the request once; for revoked sessions, ends the session; see Session ended |
| Suspended or read-only | tenant_unavailable, app_unavailable | Returns it to you; once confirmed as a suspension, enters the service suspended state and continues automatically when service resumes |
| Rate limits | rate_limited, too_many_attempts | Waits and retries automatically when the wait is 60 seconds or less; longer waits are returned to you. Never retried automatically: friend requests, group creation, group invitations, join applications, starting and inviting to calls, reports, chat room messages, and too_many_attempts |
| Temporary failures | network_error, timeout, internal, HTTP 5xx | Requests safe to resend (queries, writes with an idempotency key, naturally idempotent operations such as marking read) are retried with backoff, up to 3 times. Other writes aren't retried and are returned to you |
| Needs the user | All other codes | Returned to you; show a message based on code and details |
Sending messages has its own send queue:
im.messages.send()returns once the message is written to the send queue; the SDK sends it from there. While offline, messages wait in the queue and are sent when the network returns. If the page closes, sending continues the next time it opens.- On network errors, timeouts or server failures, sending retries at intervals of 1, 2, 4, 8, 16 and 30 seconds, and the message is marked failed after 2 minutes in total of available network time without success. Rate-limited sends wait the time the server gives, then retry.
- Messages the server rejects (muted, blocked, failed moderation and so on) are marked failed immediately.
- On failure the message's status becomes
failedandmessage.sendFailedfires. Callim.messages.resend(client_msg_id)to resend orim.messages.discard(client_msg_id)to delete it; see Messages. - Retries of the same message use the same
client_msg_id, which the server uses to de-duplicate, so it's never sent twice.
Writes that aren't retried automatically (such as creating a group, sending a friend request or editing a group profile) may already have happened when they hit network_error or timeout. Check the result first (for example re-read the group list), and only let the user retry if it didn't take effect.
While offline, local data stays readable and the UI can keep showing it. Conversation and message operations that only change local data (such as drafts) take effect immediately; those that need the server return network_error after automatic retries; sent messages go into the send queue. The connection state is offline or waiting, and when the network returns the SDK reconnects and syncs changes made while offline.
API reference
im.on()
Subscribes to an event.
| Parameter | Type | Required | Description |
|---|---|---|---|
event | keyof DREvents | Yes | Event name; see All events |
listener | (payload: DREvents[E]) => void | Yes | Listener; its argument is the event payload |
Returns: () => void, an unsubscribe function that's safe to call more than once. After the client is destroyed, the returned function does nothing.
im.once()
Subscribes to an event for a single call. Parameters and return value are the same as im.on(); calling the returned function before it fires cancels it.
describeError()
Returns the error's message text (string). Import it from @deeprespond/im-web/locale (recommended); the second argument is the language:
| Call | Language |
|---|---|
describeError(err) | Simplified Chinese (default) |
describeError(err, 'zh-TW') | Traditional Chinese |
describeError(err, 'en') | English |
Text is chosen by code and reason; rejection text your server gives in a callback is shown as is. To change some messages, get an independent message table with createLocale(language), edit it, and pass it to the display helpers that take one.
The @deeprespond/im-web/locale/zh-CN used in the examples above is the older entry point and still works. Its describeError(err, locale) takes a message table object as the second argument, not a language name, so don't mix the two entry points' styles. For a full example, see SDK languages.
Types
DRError
Extends Error; see The error object for its fields. It also has name, always 'DRError'.
DREvents
The map from event names to payload types, which im.on() and im.once() use to infer payload types. For each event's payload, see All events. You can use it to type your own handlers:
import type { DREvents } from '@deeprespond/im-web';
function onReceived(p: DREvents['message.received']) {
console.log(p.message.conversation_id, p.alert);
}
im.on('message.received', onReceived);NotifyDecision
notify in message.received, saying whether this message should alert.
| Field | Type | Description |
|---|---|---|
notify | boolean | Whether to alert |
title | string | Notification title, built from the push settings' preview mode. Optional |
body | string | Notification body. Optional |
reason | string | Why not to alert. Optional: dnd (do not disturb), quiet_hours, muted (conversation muted), not_mentioned (muted to mentions only, and this message doesn't mention the user), excluded (message doesn't count as unread), self (the user's own message), tip (group notice), call (call record), not_loaded (push settings not loaded yet) |
For full alert usage, see Alerts, push settings and reports.
Channel events
channel.changed fires when this tab's current channel changes. Its payload is { channel: ChannelHandle | null }: the channel's handle after joining, or null after leaving.
- It fires only in this tab; other tabs don't receive it and can't get the handle. Handle it in the page that joins the channel and plays the audio and video.
- Track, member, leave, close and connection events are subscribed to on the
ChannelHandle; for the full table, see Channel events. - After leaving a channel because permissions lapsed or heartbeats failed, the SDK doesn't rejoin automatically.
Game room events
game.changed is emitted when you join a game room and when the current room ends; the payload is { room: GameRoom | null }: the room object after joining, and null once the room has ended for this client (left, removed, match over, or past the reconnect window).
im.on('game.changed', ({ room }) => {
if (room) showMatchScreen(room.roomId);
else showLobby();
});- The event is emitted only in this client; other tabs of the same browser don't receive it.
- Frames, members joining and leaving, room state, custom messages and the end reason are subscribed to on the
GameRoomobject (room.onFrame(),room.onMember(),room.onClose()and so on); see Game rooms · Web client. - The SDK handles reconnection automatically and doesn't emit
game.changedwhile reconnecting; the room object'sstatebecomesreconnectingorrejoining, which you can subscribe to withroom.onState().
Simplified Chinese, Traditional Chinese and English messages
The SDKs ship with message text in Simplified Chinese, Traditional Chinese and English, defaulting to Simplified Chinese, and never call an online translation service:
| Platform | Usage |
|---|---|
| Web | createLocale() and describeError() from @deeprespond/im-web/locale |
| Android | DRLocale.en, DRLocale.zhTW, DRLocale.forLanguage(tag) |
| Flutter | Import en, zhTW and localeForLanguage(tag) from render.dart |
Pass the chosen message table to the display helpers; your app stores the user's language choice. Switching language only changes message text; error codes and user content stay the same.
Server errors may carry message_key and message_params (for building localized messages); keep using code, reason and details to decide what happened. For entry points and how the language is chosen, see Languages and localization.
