Calling the API
This page covers parameters, return values, errors, retries, paging, files and testing in the Node.js SDK. For installation and client config, see Node.js SDK; for each service's methods and REST docs, see Services and methods. Below, im is an existing DRClient.
| You want to | See |
|---|---|
| Write parameters, read responses, keep large integers exact | Naming rules |
| Set a timeout, cancellation or request ID for one call | Per-call options |
| Check errors | Error handling |
| Know which requests are retried, handle rate limits | Retries and idempotency |
| Page through results, iterate over everything | Automatic paging |
| Read per-item results of batch APIs, import in bulk | Batch results and batches over the limit |
| Upload and download files, send file messages | Uploading and downloading files |
| Logs, metrics and unit tests | Logs and troubleshooting, Testing your own code |
| Standalone RTC channels, AI streaming replies | Using standalone channels, AI streaming replies |
Naming rules
- Services and methods are camelCase, such as
im.users.issueLoginTicket()andim.groups.listMembers(). - Request and response data fields keep the REST snake_case, such as
auto_create,client_msg_idandnext_cursor. - Path parameters are positional; query parameters or the request body go in a parameter object; call options always come last.
undefinedmeans not sent andnullmeans sending null explicitly; whether a field can be cleared depends on the REST API. Unknown parameters giveinvalid_params.- IDs and timestamps come back in the server's format; RFC 3339 strings aren't turned into Date objects.
await im.users.get('alice');
await im.users.update('alice', { nickname: '小明' });
await im.users.list({ limit: 20, username_prefix: 'a' });
await im.users.get('alice', { timeoutMs: 3000 });Import data types from the main package with import type, and your editor completes parameters and responses. Responses keep unknown fields; the full raw response is available through the RAW symbol exported by the main package, as result[RAW], which isn't included in normal enumeration or JSON.stringify.
With only call options, still keep the optional parameter object's position. For example, im.users.list(undefined, { timeoutMs: 3000 }); don't put timeoutMs into the first argument as a query field.
Raw JSON
To keep a message body or extension fields exactly as written, use RawJSON; fields such as body_raw in responses use it too. Which fields support it depends on the method's types.
import { RawJSON } from '@deeprespond/im-server-sdk';
await im.messages.send({
from: 'notice',
to_user: 'alice',
type: 'custom',
body: new RawJSON('{"order_id":9007199254740993}'),
});RawJSON validates the JSON when constructed, and the SDK keeps the text as is when encoding the request. Plain JavaScript numbers can't represent integers beyond the safe range exactly, so parsing with JSON.parse first and then wrapping can't restore the precision.
Per-call options
| Field | Description |
|---|---|
timeoutMs | Time limit per attempt (milliseconds), overriding the client setting and the default 150 seconds for completing uploads |
totalTimeoutMs | Total time limit for the whole call (milliseconds), including retries, rate-limit waits and any token fetch this call does itself |
signal | An AbortSignal that cancels the request in progress and any waits |
maxRetries | Retries after network errors, timeouts and 5xx for requests that are safe to retry |
maxRateLimitedRetries | How many times to wait and resend after being rate-limited |
maxRetryAfterMs | Longest Retry-After to wait automatically (milliseconds) |
requestId | X-Request-ID, 1 to 64 letters, digits or ._- |
const user = await im.users.get('alice', {
timeoutMs: 3000,
totalTimeoutMs: 5000,
signal: AbortSignal.timeout(5000),
requestId: 'profile-alice-1',
});When AbortSignal.timeout() fires, the error code is timeout; a manual abort gives canceled. A cancelled or timed-out write may already have run on the server, so follow the API's idempotency rules before resending.
Error handling
Server errors and the SDK's local errors use DRError; check with isDRError(), and don't rely on the message text.
import { isDRError } from '@deeprespond/im-server-sdk';
try {
await im.users.get('alice');
} catch (error) {
if (isDRError(error)) {
console.error({ code: error.code, reason: error.reason, requestId: error.requestId });
if (error.code === 'not_found') {
// 根据业务决定是否创建用户。
}
}
throw error;
}| Property | Meaning |
|---|---|
code, reason, details | Error code, details.reason, extra information |
status | HTTP status; may be missing for local failures |
serverMessage | The message text the server returned |
requestId, op | Request ID and API name, such as users.issue_login_ticket |
retryAfter | How long the server asks you to wait, in seconds |
attempts, retried | Number of attempts; whether the error came from a retried attempt |
partial | What batching, iteration, downloads and the like already finished, shaped per operation |
isTemporary | Whether it's a temporary error such as a network error, timeout, rate limit or 5xx |
isPermanentlyUnavailable | Whether it's permanently unavailable because the app was deleted or the tenant closed |
Common local errors are invalid_params, network_error, timeout, canceled, invalid_response and client_unavailable. For business errors, see Error codes. Exceptions thrown by hooks and your own handlers may also come through as is.
isServiceUnavailable recognizes an app or tenant being temporarily unavailable, and the resulting client_unavailable during the cooldown. Keep op and requestId when troubleshooting; see Authentication and network.
Retries and idempotency
The method reference marks each REST call's automatic retry category:
| Category | After a network error, timeout or 5xx |
|---|---|
| Query | Retried automatically |
| Idempotent | Retried automatically, such as setting a profile on a fixed target or lifting a mute |
| Unique create | Retried automatically; if already_exists comes back, use retried to look up and check the existing object |
| No retry | Not retried automatically, to avoid repeating side effects |
The default client_msg_id for sending messages only stays the same within one SDK call. For de-duplication across your own retries, pass a stable ID. When not_found, already_exists or version_conflict comes back on a retry, the earlier attempt may have run; check the server's state.
Rate limits
429 rate_limited waits for Retry-After, controlled by maxRateLimitedRetries and maxRetryAfterMs; this also applies to APIs in the "No retry" category. Past the wait limit or total time limit it stops waiting and passes the error to the caller.
rateLimit: { perSecond: 50, burst: 50 } limits rates locally in the client; it only covers calls in this process sharing that client and doesn't replace the server's app-level quota.
Automatic paging
for await (const user of im.users.iter({ username_prefix: 'a', limit: 100 })) {
console.log(user.username);
// 提前 break 不会继续请求下一页。
}The iterator requests pages one at a time, only fetching the next page when needed. Iteration options support every CallOptions field except totalTimeoutMs; to limit the whole iteration, use signal. If a page finally fails, the error is thrown, and partial keeps the cursor that page used so you can continue with the list method.
When paging by hand, read items and next_cursor; versioned lists also have version and not_modified. Conversation messages and message exports page by their own seq or message ID; see Query messages.
let cursor: string | undefined;
do {
const page = await im.users.list({ limit: 100, cursor });
for (const user of page.items) console.log(user.username);
cursor = page.next_cursor ?? undefined;
} while (cursor !== undefined);Pass cursors back as the server gave them, without parsing or incrementing them. For versioned incremental lists, not_modified means the version hasn't changed, so don't treat the empty items as deleting everything locally.
Batch results and batches over the limit
A batch request succeeding over HTTP doesn't mean every item did. Results have ok, failed_items and throwIfFailed():
const result = await im.users.batchCreate([
{ username: 'alice' },
{ username: 'bob' },
]);
if (!result.ok) console.error(result.failed_items);
result.throwIfFailed(); // 有失败项时抛出 BatchError。Methods ending in All split into batches by the API's limit automatically, such as batchCreateAll(). Failed items stay in the result; if a whole request fails, it stops and throws, and partial keeps what finished. For every method, see sections such as Users.
import { BatchError } from '@deeprespond/im-server-sdk';
const result = await im.users.batchCreateAll([
{ username: 'import_alice' },
{ username: 'import_bob' },
]);
try {
result.throwIfFailed();
} catch (error) {
if (!(error instanceof BatchError)) throw error;
for (const item of result.failed_items) {
console.error(item.key, item.code);
}
}Retry the failed items rather than rerunning the whole import after a partial success. Handle request-level DRErrors thrown by batch methods separately from the per-item BatchError thrown by throwIfFailed().
Uploading and downloading files
const file = await im.media.upload('/data/report.pdf', {
purpose: 'attachment',
kind: 'file',
onProgress(uploaded, total) {
console.log(uploaded, total);
},
});
await im.media.download(file.url, '/data/report-copy.pdf');Upload sources can be a path, a file: URL, Buffer, Uint8Array, ArrayBuffer or Blob. Paths supply the file name; for in-memory sources of ordinary files, pass name. Readable streams and async iterators aren't supported as sources; when parts may need re-reading, save to a file first.
The SDK creates the upload, transfers the content and completes the upload automatically, splitting large files into parts with concurrency 4 by default. onProgress(uploaded, total) is called as parts complete, and only once at completion for single uploads. Group files need group_id and user avatars need owner; see Upload files.
If you saved the upload session's file_id, resume with im.media.resumeUpload(fileId, source, params?, options?); the session must still be valid and the source must match the original file. Cancelling an upload tries to revoke the upload session.
The first argument to download is the media file's url, and the SDK gets an authorized download address first. The destination can be a path, a file: URL, a Writable or a WritableStream, and it returns the number of bytes written. For path destinations, the target file is only replaced after a successful download, and the temporary file is deleted on failure. The response body is read with an idle timeout; limit the overall duration with signal or totalTimeoutMs.
Once uploaded as an attachment, put the stable media URL into a file message; for the full file fields, see Message format:
const file = await im.media.upload(Buffer.from('Node SDK 示例附件\n'), {
purpose: 'attachment',
kind: 'file',
name: 'hello.txt',
content_type: 'text/plain',
});
await im.messages.send({
from: 'alice',
to_user: 'bob',
client_msg_id: 'document-hello-1',
type: 'file',
body: { url: file.url, name: file.name ?? 'hello.txt', size: file.size },
});file.url is for messages and later downloads; the temporary download_url isn't a long-term address for messages. When proxying files to the browser, your own HTTP service sets suitable content types and download headers.
Moderation review
const count = await im.moderation.processPending(async (item, content, ctx) => {
if (item.scene !== 'message') return null; // 留给人工处理。
// 按自己的规则检查 content;业务 I/O 可使用 ctx.signal。
return { decision: 'no_violation', note: '业务复审通过' };
}, { scene: 'message', limit: 20 });Returning null or undefined skips an item. The SDK submits decisions with the record version and skips version conflicts; stopOnError is false by default, so handler errors are logged and processing continues, while true makes it throw. It returns the number of decisions submitted; for API rules, see Moderation items.
Logs and troubleshooting
const im = new DRClient({
baseUrl: 'https://api.example.com',
orgName: process.env.IM_ORG_NAME!,
appName: process.env.IM_APP_NAME!,
clientId: process.env.IM_CLIENT_ID!,
clientSecret: process.env.IM_CLIENT_SECRET!,
logger: { level: 'warn', sink: entry => console.warn(entry) },
hooks: {
beforeRequest(req) { req.headers.set('X-Trace-ID', 'order-service'); },
afterResponse(info) { console.log(info.op, info.status, info.durationMs, info.willRetry); },
},
});In this example DRClient comes from the main package. Hooks are awaited before and after each attempt and suit tracing and metrics; the SDK sets Authorization, X-Request-ID and Content-Type again afterward. Don't print credentials, request bodies or full signed URLs in hooks.
im.stats() returns counts keyed by API name, including requests, final failures, retries and rate-limit wait time. API names stay snake_case, unlike the camelCase method names, such as users.issue_login_ticket.
Testing your own code
The testing subpath works with node:test, Vitest or Jest, with no real server needed.
import assert from 'node:assert/strict';
import { FakeOpenAPI } from '@deeprespond/im-server-sdk/testing';
const fake = new FakeOpenAPI();
fake.respond('messages.send', {
status: 201,
json: { message_id: 'm1', client_msg_id: 'order-1', created_at: '2026-10-08T08:00:00Z' },
});
const im = fake.client();
try {
const result = await im.messages.send({
from: 'notice', to_user: 'alice', client_msg_id: 'order-1',
type: 'text', body: { text: '你好' },
});
assert.equal(result.message_id, 'm1');
assert.equal(fake.calls('messages.send').length, 1);
} finally {
await im.close();
}| Tool | Use |
|---|---|
fake.respond(op, { json, status?, headers?, times? }) | Sets a response, in effect until changed by default |
fake.fail(op, code, { status?, retryAfter?, times? }) | Simulates an error, once by default; retryAfter is in seconds |
fake.handle(op, responder) | Handles the Request yourself and returns a Response |
fake.calls(op), fake.requests | Inspect recorded calls |
fake.revokeTokens() | Invalidates tokens already issued |
op is the API name from the method reference, not the JavaScript method name. For testing callbacks, see Local testing.
Using standalone channels
const created = await im.channels.create('meeting-demo', {
name: '项目会议', media: 'video', access_mode: 'ticket',
});
const issued = await im.channels.issueTickets(created.channel.channel_id, {
users: [{ username: 'alice', role: 'publisher' }],
});
// 仅把 issued.tickets 交给绑定用户,不输出原文。
const page = await im.channels.list({ status: 'open', limit: 20 });
const corrections = await im.channels.usageAdjustments({ limit: 20 });- Parameters and results are their own
RTCChannel…types, with raw data kept throughWithRaw. - Channel lists have no
itermethod; passnext_cursorto the next call to page yourself. - Omitting
expires_atleaves it unchanged, andnullclears it. createdin the create result means the server returned 201 (created), andpendingin close and remove results means it returned 202 (in progress).- Issuing tickets isn't retried automatically; when retrying member removal or ticket revocation, keep the same
operation_id. - These methods are available from npm 1.0.0; for the matching APIs, see Channel methods.
Read the current version before updating a channel. expires_at: null clears the expiry, and leaving the field out keeps the existing value:
const current = await im.channels.get('meeting-demo');
const updated = await im.channels.update('meeting-demo', {
version: current.channel.version,
name: '项目例会',
expires_at: null,
});
console.log(updated.channel.name, updated.channel.version);On version_conflict, read again and check what your business intends. The channel ID, ban record version and operation_id each have their own purpose and can't stand in for each other; only give issued tickets to the users they're bound to, and clients join the media with their own sign-in.
Game rooms
Game rooms give multiplayer games a real-time match channel. Your server uses im.game to create rooms, issue join tickets to players and close rooms when matches end; players join and play through the client SDK. Here is what your server does for one match:
import { DRClient, isDRError } from '@deeprespond/im-server-sdk';
const im = new DRClient({
baseUrl: process.env.IM_API!, orgName: 'demo-org', appName: 'demo',
clientId: process.env.IM_CLIENT_ID!, clientSecret: process.env.IM_CLIENT_SECRET!,
});
/** 匹配成功后建房,给每个玩家签一张加入票据。 */
export async function startMatch(matchId: string, players: string[]): Promise<Record<string, string>> {
try {
// 房间 ID 用你的对局 ID:重试或重复调用都只有一个房间
await im.game.createRoom(matchId, { max_players: players.length, start_mode: 'all_ready', ext: { mode: 'ranked' } });
} catch (err) {
if (isDRError(err) && err.reason === 'game_capacity_unavailable') throw new Error('游戏服务繁忙,请稍后再试');
if (isDRError(err) && err.reason === 'open_rooms') throw new Error('同时进行的对局太多');
throw err;
}
// 不会自动重试;超时后直接再签一次,旧票据随之作废
const tickets = await im.game.issueTickets(matchId, { users: players.map((username, seat) => ({ username, seat })) });
// 经你的业务接口分别交给每个玩家,不要写进日志
return Object.fromEntries(tickets.map((t) => [t.username, t.ticket]));
}
/** 对局结束时带上结算结果结束房间。 */
export async function finishMatch(matchId: string, winner: string): Promise<void> {
const { accepted } = await im.game.closeRoom(matchId, { reason: '对局结束', result: { winner } });
if (!accepted) console.info(`${matchId} 之前已在结束,这次的结果不生效`);
}
/** 把作弊的玩家移出房间。 */
export async function kickCheater(matchId: string, username: string): Promise<boolean> {
const { removed } = await im.game.removeMember(matchId, username, { reason: '作弊' });
return removed; // false:他已经不在房间里
}| To do | Method | Notes |
|---|---|---|
| Create a room | createRoom | Use your match ID as the room ID. Calling again with the same parameters returns the same room (created is false), and network errors are retried automatically |
| Issue join tickets | issueTickets | 1–10 players per call. Not retried automatically: after a timeout just issue again, which revokes the user's previous unused ticket. The raw ticket is returned only once; don't log it |
| Start manually | startRoom | Only for rooms whose start mode is manual |
| Close a room | closeRoom | The result is passed as-is to the players and to the game_room.closed callback. If the room was already closing, accepted is false and this call's reason and result don't apply |
| Remove a player | removeMember | The SDK generates operation_id and keeps it across retries. If the player isn't in the room, removed is false |
| Query | getRoom, listRooms / iterRooms, listMembers, listRegions | Lists are newest first; you can pass several statuses separated by commas |
- Parameters and results use types such as
CreateGameRoomParams,GameRoomandGameTicket, imported from the main package; results keep the raw data throughWithRaw. created,acceptedandremovedcome from the HTTP status code; they are not fields of the response body.- Common errors:
unavailable(game_capacity_unavailable) when the region has no free game node — retry later or give fallback regions when creating the room;limit_exceeded(open_rooms) when the number of open rooms hits the limit. See game room errors for the full list. extcan be up to 2KB andresultup to 4KB; for limits and billing see Console, billing and limits.- The settlement callback after a match (
game_room.closed) is covered in Game room callbacks. - For each method's signature and API see game room methods; for request and response fields see Game rooms: server API. Game room methods are available from npm 1.0.7.
AI streaming replies
In AI conversations, after a user messages a bot you get an ai.message_received callback, then start a reply with im.ai.startStream and write it as it's generated. The returned Stream handles numbering and batching deltas (sending a chunk every 20 milliseconds or 4 KB), switching between continuous and batch writes, and resending on failure; after three failed writes in a row it ends with fail('transport'). Manage bots with im.ai.createBot and related methods; see Services and methods.
Start a reply when a message arrives
Callback handlers should return quickly without waiting for the model. Use the event ID as client_stream_id so a redelivered callback doesn't start a second reply:
import { CallbackHandler, MemoryDedupStore, MemoryNonceStore } from '@deeprespond/im-server-sdk/callback';
const handler = new CallbackHandler({
secrets: { [ENDPOINT_ID]: [SECRET] },
nonceStore: new MemoryNonceStore(),
dedupStore: new MemoryDedupStore(),
}).onAIMessage(async (event, data) => {
const stream = await im.ai.startStream({
bot: data.bot,
to: data.from,
reply_to_seq: Number(data.message['seq']),
client_stream_id: event.id,
});
const question = String((data.message['body'] as { text?: string } | undefined)?.text ?? '');
void reply(stream, question);
});With replace: true, startStream first ends any reply being generated in the conversation; batch_only: true uses only batch writes (for runtimes without streaming request bodies, such as some cloud functions).
Connect a model
pipeOpenAI, pipeOpenAIResponses, pipeAnthropic and pipeGemini read the model's stream to the end and then finish automatically (with the finish reason, model name and usage); if the model returns an error, they end with fail('model_error', description) and throw that error. The argument can be a stream from an official SDK (openai, @anthropic-ai/sdk, @google/genai) or a raw SSE response (Response, ReadableStream, or an iterator yielding strings or bytes). Models compatible with the OpenAI format (DeepSeek, Qwen, Kimi, Doubao, vLLM, Ollama and so on) all use pipeOpenAI:
import OpenAI from 'openai';
import type { Stream } from '@deeprespond/im-server-sdk';
const openai = new OpenAI();
async function reply(stream: Stream, question: string): Promise<void> {
await using _ = stream; // 离开作用域时没有结束的以 fail('canceled') 结束
const completion = await openai.chat.completions.create(
{ model: 'gpt-4.1', messages: [{ role: 'user', content: question }], stream: true, stream_options: { include_usage: true } },
{ signal: stream.signal },
);
await stream.pipeOpenAI(completion);
}The four conversions follow the same rules as the Go SDK.
Write manually
stream.writeReasoning('先查询天气。');
stream.writeTool('call_1', 'weather', '查询天气', 'running');
// … 调用工具 …
stream.writeTool('call_1', 'weather', '查询天气', 'done');
stream.writeText('明天上海**多云**,');
stream.writeText('最高 23℃。');
stream.writeImage({ url: 'https://cdn.example.com/a.png', width: 800, height: 600 });
const result = await stream.finish({ model: 'gpt-4.1', usage: { input_tokens: 120, output_tokens: 380 } });write*only buffers in memory and needs noawait; text of the same type as the previous part is appended to the same part.flush()sends immediately and waits for confirmation;finish()confirms all content first, then ends.- When the model call fails, call
stream.fail('model_error', 'The model is temporarily unavailable'); content generated so far is kept, and the user sees "Generation interrupted" with your message.
Stop generating
When the user taps stop, the reply times out, or it's replaced by a new reply, stream.signal (an AbortSignal) fires with signal.reason a DRError of stream_closed, and later writes throw that error. Pass stream.signal to the model request so the model call stops too; pipe* also stops reading and aborts the model's stream (calling controller.abort() on official SDK streams).
CallbackHandler stops the matching stream in this process automatically when ai.stream_stopped arrives, with no handler needed; to do more, use onAIStreamStopped. If you handle callbacks yourself (such as taking events from a queue), call im.ai.streamStopped(streamId).
