Receiving callbacks
The Node.js SDK supports event callbacks, sync callbacks and URL verification, and handles signatures, nonces, replay prevention, event de-duplication and dispatching. For console settings, see Callbacks; for the protocol, see the callback overview.
For runnable Fastify, Express and NestJS examples and the "create endpoint → start → verify" flow, see the examples guide.
| You want to | See |
|---|---|
| Set up callbacks for the first time | Setup order, Create the handler |
| Deploy several instances with Redis for replay prevention and de-duplication | Shared storage |
| Mount it on Express, NestJS, Fastify, Koa or Next.js | Express, Fastify, Koa, Next.js and standard Web Request |
| Handle events, hand them to a queue | Handling events |
| Review, reject or modify messages before they're sent | Sync callbacks |
| Fix signature failures, 503s and other problems | Verification, signatures and troubleshooting |
| Unit test callbacks | Local testing |
Setup order
- Create a callback endpoint in the console and note the endpoint ID and signing secret; the receiving URL must be reachable from the IM server.
- Pick a framework adapter below, and create and mount the handler with the raw request body. For multi-instance deployments, configure shared storage first.
- Start the receiver and run URL verification in the console; the handler answers the challenge automatically.
- Turn on the event subscriptions or sync hooks you need, trigger the matching business actions, and check the handler's logs and business results.
URL verification only proves the address and signature flow work. Event callbacks and sync callbacks each need configuring and triggering, and business-level de-duplication of real events needs verifying separately.
Create the handler
For single-process testing or single-instance deployments, you can use in-memory stores explicitly:
import {
CallbackHandler, MemoryNonceStore, MemoryDedupStore, allow, reject,
} from '@deeprespond/im-server-sdk/callback';
const handler = new CallbackHandler({
secrets: {
[process.env.IM_CALLBACK_ENDPOINT_ID!]: [process.env.IM_CALLBACK_SECRET!],
},
nonceStore: new MemoryNonceStore(),
dedupStore: new MemoryDedupStore(),
});
handler.on('user.created', async (event, data, ctx) => {
console.log(event.id, data.username);
// 业务数据库操作可以使用 ctx.signal。
});
handler.onHook('message.before_send', async (req, ctx) => {
if (typeof req.body.text === 'string' && req.body.text.includes('spam')) return reject('spam', '消息不符合发送规则');
return allow();
});secrets is keyed by the callback endpoint ID, with an array of secrets as the value; a single string isn't accepted. During rotation, provide both the new and old secrets. You can also read them dynamically with async (endpointId, { signal }) => readonly string[], returning an empty array when not found. If getting secrets fails or times out, the response is 503.
| Option | Default | Description |
|---|---|---|
secrets | Required | Secrets array per endpoint ID, or a function that looks them up |
nonceStore, dedupStore | Required | Nonce store and event de-duplication store |
queue | None | A durable queue that receives events through enqueue; see Handling events |
hookFallback | null | When a sync callback fails, return 503; or set an explicit result such as allow() or reject() |
maxConcurrentHooks | 100 | Maximum sync callback handlers running at once, including ones still running after a timeout |
claimTtlMs | 30000 | How long handling an event holds its claim (milliseconds) |
maxBodyBytes | 2097152 | Maximum request body size in bytes, 2 MiB by default |
logger | error and warn to the console | Log level and sink |
Shared storage
For multiple processes, clusters, serverless or several servers, use a shared Redis; in-memory stores disappear when the process exits and can't prevent replay or de-duplicate across instances.
import { Redis } from 'ioredis';
import { CallbackHandler } from '@deeprespond/im-server-sdk/callback';
import { ioredisStores } from '@deeprespond/im-server-sdk/redis';
const redis = new Redis(process.env.REDIS_URL!);
const { nonceStore, dedupStore } = ioredisStores(redis, {
keyPrefix: 'my-service:im:',
retentionMs: 7 * 24 * 60 * 60 * 1000,
});
const handler = new CallbackHandler({
secrets: { [process.env.IM_CALLBACK_ENDPOINT_ID!]: [process.env.IM_CALLBACK_SECRET!] },
nonceStore,
dedupStore,
});With node-redis, call nodeRedisStores(redis, options) after connecting to Redis. The default keyPrefix is drim:, and event de-duplication records are kept for 7 days by default. Every instance receiving the same callbacks uses the same store and prefix; your app manages the Redis connection.
Express
import express from 'express';
import { expressHandler } from '@deeprespond/im-server-sdk/callback/express';
const app = express();
app.post('/im/callback', expressHandler(handler));
app.use(express.json());
app.listen(3000);Works with Express 4 and 5. Put the callback route before the JSON parsing middleware so the adapter reads the raw bytes directly. If you use express.raw({ type: 'application/json', limit: '2mb' }) first, you can also pass the raw Buffer to the adapter. A request already parsed into an object without keeping the original can't be verified; don't verify a re-serialized JSON.stringify result.
NestJS
Use the Express platform and create the app with rawBody: true to keep the raw request body:
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { expressHandler } from '@deeprespond/im-server-sdk/callback/express';
// AppModule 为业务应用已有的 NestJS 模块。
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });
app.useBodyParser('json', { limit: '2mb' });
app.use('/im/callback', expressHandler(handler));
await app.listen(3000);Fastify
import Fastify from 'fastify';
import { fastifyCallback } from '@deeprespond/im-server-sdk/callback/fastify';
const app = Fastify();
await app.register(fastifyCallback(handler), { path: '/im/callback' });
await app.listen({ port: 3000 });Works with Fastify 5. The plugin keeps the raw bytes within its own route scope without changing JSON parsing for other routes.
Koa
import Koa from 'koa';
import { koaHandler, type KoaContextLike } from '@deeprespond/im-server-sdk/callback/koa';
const app = new Koa();
const receive = koaHandler(handler);
app.use(async (ctx, next) => {
if (ctx.path === '/im/callback') await receive(ctx as KoaContextLike);
else await next();
});
app.listen(3000);Works with Koa 2 and 3. Put this route before any body parser; if you already use koa-bodyparser or @koa/bodyparser, keep ctx.request.rawBody and set jsonLimit to 2mb.
Next.js and standard Web Request
The adapter uses Request and Response, but must run in the Node.js runtime. For the Next.js App Router, app/im/callback/route.ts:
import { fetchHandler } from '@deeprespond/im-server-sdk/callback/fetch';
import { handler } from '@/lib/im-callback'; // 导出上文创建并注册好的处理器。
export const runtime = 'nodejs';
export const POST = fetchHandler(handler);Don't call request.json() beforehand, which consumes the body. Multiple serverless instances use shared storage; this adapter also works with frameworks such as Hono running on Node.js.
Native node:http and custom frameworks
import { createServer } from 'node:http';
import { nodeHandler } from '@deeprespond/im-server-sdk/callback/node';
const receive = nodeHandler(handler);
createServer((req, res) => {
if (req.url === '/im/callback') {
void receive(req, res).catch(() => { res.statusCode = 500; res.end(); });
} else {
res.statusCode = 404;
res.end();
}
}).listen(3000);Custom frameworks can call await handler.handle({ headers, body }), with the body as a raw Uint8Array or ArrayBuffer, and write the returned status, headers and body into the HTTP response as is.
Handling events
handler.on(type, fn) infers the type of data from the event name, and fn takes (event, data, ctx). Each event can be registered once, and the event ID is event.id. Events already handled successfully aren't dispatched again; when part of a batch fails, the successful items keep their de-duplication records, so redelivery only handles the unfinished ones.
When a handler throws, the response is 503 and the server retries according to the delivery rules. To set the wait time, throw new RetryLater(seconds), imported from the callback subpath. Handle unknown events with handler.onUnknown((event, ctx) => ...); for event structures, see Event callbacks.
For long-running work, pass queue: { async enqueue(event, ctx) { /* write to a durable queue */ } }. A successful enqueue means the event was received reliably; failure or timeout gives 503. With queue set you can't register event handlers with on, onAny or onUnknown; sync callbacks still use onHook. Queue consumers must still keep business operations idempotent by event ID.
SDK de-duplication can't make your business write and the de-duplication commit one transaction. If the process exits after your business write but before the de-duplication commit, the event may be handled again, so your business database should also prevent duplicate side effects by event ID.
Sync callbacks
handler.onHook(name, fn) takes (req, ctx). It supports message.before_send, friend.before_add, group.before_join and chatroom.message_before_send, and the return value must be built with the SDK's result functions:
| Result | Use |
|---|---|
allow() | Allow |
reject(reason?, message?) | Reject; reason is 1 to 64 lowercase letters, digits or _.-, and message is a single-line hint of up to 256 characters |
modify({ body?, ext? }) | Replace the message body or extension fields; for where it applies, see Sync callbacks |
partial([{ username, reason?, message? }]) | Reject some people; only for invitation and group-creation targets in group.before_join |
ctx.timeoutMs is the wait the server gave, ctx.deadline is the deadline after the SDK keeps 50 milliseconds in reserve, and ctx.signal fires at that time. Pass the signal to your own database or fetch calls, so tasks don't keep holding resources after a timeout. When there's no handler, or it times out, throws or returns an invalid result, the response is 503 by default and the server applies its configured failure policy; hookFallback is a substitute result you choose explicitly.
Don't do slow synchronous computation in sync callbacks. It blocks the Node.js event loop, so even the deadline timer can't fire in time; move the computation to a worker thread, or only do the essential checks within the time limit.
Verification, signatures and troubleshooting
The handler answers URL verification automatically; you don't register a verification function. Signature checks use the raw request body and check the timestamp and nonce; for the algorithm and secret rotation, see Callback security.
Common integration problems:
- Signature failures: check the endpoint ID, the secrets array, and whether middleware changed the raw bytes.
- Expired timestamps: check the system clocks of the server and the receiver.
- Request body over the limit: check the SDK, framework and reverse proxy body limits together.
- 503 responses: look at secret lookups, Redis, handler exceptions and sync callback time limits.
| Response | Meaning |
|---|---|
| URL verification 200 | Returns the verification result with the challenge |
| Event 204 | The batch was received; events may have been de-duplicated, or have no registered handler |
| Sync callback 200 | Returns the allow, reject, modify or partial result encoded from the SDK Verdict |
| 400 / 401 / 413 / 503 | Request structure, signature or replay, size, or processing failures; see Callback troubleshooting |
Local testing
import assert from 'node:assert/strict';
import { CallbackHandler, MemoryNonceStore, MemoryDedupStore } from '@deeprespond/im-server-sdk/callback';
import { eventBody, signedRequest } from '@deeprespond/im-server-sdk/testing';
const body = eventBody(['user.created', { username: 'alice' }]);
const endpointId = JSON.parse(Buffer.from(body).toString('utf8')).endpoint_id as string;
const handler = new CallbackHandler({
secrets: { [endpointId]: ['test-secret'] },
nonceStore: new MemoryNonceStore(),
dedupStore: new MemoryDedupStore(),
});
let username = '';
handler.on('user.created', (_event, data) => { username = data.username; });
const response = await handler.handle(signedRequest('test-secret', endpointId, body));
assert.equal(response.status, 204);
assert.equal(username, 'alice');
await handler.close();hookBody(name, data, { timeoutMs?, test? }) builds a sync callback body and verifyBody(challenge) a URL verification body; add a signature with signedRequest. You can also send the built raw bytes to your local framework route to check middleware order.
Stopping the service
Stop accepting new requests first, then await handler.close({ timeoutMs: 5000 }) to wait for in-flight work; after the timeout, cancellation is signaled through handlers' ctx.signal. Finally close the Redis connections your app manages.
Standalone channel callbacks
| Event | Type |
|---|---|
rtc_channel.created | RTCChannelCreated |
rtc_channel.updated | RTCChannelUpdated |
rtc_channel.closing | RTCChannelClosing |
rtc_channel.closed | RTCChannelClosed |
rtc_channel.session_joined | RTCChannelSessionJoined |
rtc_channel.session_leaving | RTCChannelSessionLeaving |
rtc_channel.session_left | RTCChannelSessionLeft |
rtc_channel.member_banned | RTCChannelMemberBanned |
rtc_channel.member_unbanned | RTCChannelMemberUnbanned |
These 9 events are dispatched by the handler like any other, with the same signature verification, de-duplication and error handling. For fields, see Channel events; some fields may be absent, and apart from session events, don't assume an event includes a target user. Channel events have separate names and types from call events (rtc.call_*), and internal broadcasts of config changes aren't sent as callbacks.
Game room callbacks
The 7 game room events are dispatched like any other event, with the same signature checks, deduplication and error handling; handler.on infers the type of data from the event name. Most games only need game_room.closed: it carries the result and every player's online time, for rewards, match history and billing reconciliation.
import { CallbackHandler, MemoryDedupStore, MemoryNonceStore } from '@deeprespond/im-server-sdk/callback';
const handler = new CallbackHandler({
secrets: { [process.env.IM_CALLBACK_ENDPOINT_ID!]: [process.env.IM_CALLBACK_SECRET!] },
nonceStore: new MemoryNonceStore(),
dedupStore: new MemoryDedupStore(), // 多台服务器时换成共享的存储
});
handler.on('game_room.closed', async (_event, data) => {
// data.result 是结束房间时给的结算结果;超时、全员离开等自动结束时为 null
await saveMatch(data.room_id, data.close_reason, data.result);
for (const m of data.members) await addPlayMinutes(m.username, m.billable_minutes);
});
handler.on('game_room.member_left', async (_event, data) => {
console.info(`${data.username} 离开了 ${data.room_id}:${data.reason}`);
});| Event | Type of data | Description |
|---|---|---|
game_room.created | GameRoomCreated | A room was created (assigned to a game node) |
game_room.started | GameRoomStarted | The match started (the game node began advancing frames) |
game_room.member_joined | GameRoomMemberJoined | A player got a seat |
game_room.member_connected | GameRoomMemberConnected | A player connected to the game node for the first time; frequent, subscribe only if needed |
game_room.member_disconnected | GameRoomMemberDisconnected | A player disconnected from the game node (can recover within the reconnect window); frequent, subscribe only if needed |
game_room.member_left | GameRoomMemberLeft | A player left (left, timed out, was removed or did not connect in time) |
game_room.closed | GameRoomClosed | The room ended, with duration, result, and each player's online time and billable minutes |
- Events can arrive more than once or out of order: the handler deduplicates by event ID; in your own logic use
version(the room version, incremented on every state change) to tell newer from older. - When the room closes automatically (timeout, everyone left)
resultisnull. billable_minutesis this match's minutes per player, rounded up, for your own timing and reconciliation; invoices sum per day and then round, see Billing.- Field meanings are in game room callback events.
