Lightweight, standalone package for receiving and decrypting Webex messages over a persistent WebSocket. No full Webex SDK required — just provide a bot token.
npm install webex-message-handlerimport { WebexMessageHandler, consoleLogger } from 'webex-message-handler';
const handler = new WebexMessageHandler({
token: process.env.WEBEX_BOT_TOKEN!,
logger: consoleLogger, // optional, silent by default
});
handler.on('message:created', (message) => {
console.log(`[${message.personEmail}] ${message.text}`);
});
handler.on('error', (err) => console.error('Error:', err));
await handler.connect();The WebexMessageHandler class has five methods and one getter. This is the complete public interface a wrapper needs:
| Method | Purpose |
|---|---|
connect() |
Start the connection (device registration, WebSocket, KMS handshake). |
disconnect() |
Tear down the connection cleanly. |
reconnect(newToken) |
Update the token and re-establish everything from scratch. |
status() |
Health check — returns structured connection state of all subsystems. |
connected |
Quick boolean: is the handler connected and WebSocket open? |
on(event, callback) |
Subscribe to events (message:created, disconnected, etc.). |
new WebexMessageHandler(config: WebexMessageHandlerConfig)WebexMessageHandlerConfig fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
token |
string |
Yes | — | Webex bot or user access token. |
mode |
NetworkMode |
No | 'native' |
Networking mode: 'native' or 'injected'. See Networking Modes. |
agent |
http.Agent | https.Agent | undici.Dispatcher |
No | — | Native mode only: Proxy agent for HTTP/HTTPS requests. Recommended: undici's ProxyAgent for Node.js v18+. |
fetch |
FetchFunction |
Required for injected | — | Injected mode only: Custom fetch function for all HTTP requests. |
webSocketFactory |
WebSocketFactory |
Required for injected | — | Injected mode only: Custom WebSocket factory function. |
logger |
Logger |
No | silent | Logger implementation (consoleLogger provided). |
pingInterval |
number |
No | 15000 |
WebSocket heartbeat ping interval in ms. |
pongTimeout |
number |
No | 14000 |
How long to wait for pong before triggering reconnect. |
reconnectBackoffMax |
number |
No | 32000 |
Max backoff delay between reconnection attempts. |
maxReconnectAttempts |
number |
No | 10 |
Max consecutive reconnection attempts before giving up. |
reconnectStabilityWindow |
number |
No | 60000 |
How long a connection must hold in ms before the reconnect-attempt counter resets. Stops a flap storm from forgiving attempts forever. |
The handler supports two networking modes controlled by the mode configuration field. The mode determines how the library makes HTTP requests and creates WebSocket connections.
Uses Node.js built-in fetch and ws library directly. Supports proxy configuration via the agent parameter.
Basic usage:
const handler = new WebexMessageHandler({
token: process.env.WEBEX_BOT_TOKEN!,
});With proxy:
import { ProxyAgent } from 'undici';
const handler = new WebexMessageHandler({
token: process.env.WEBEX_BOT_TOKEN!,
agent: new ProxyAgent('http://proxy.example.com:8080'),
});Note: Use undici's
ProxyAgentfor best compatibility with Node.js v18+ nativefetch(). Whilehttps-proxy-agentmay work, undici'sProxyAgentis more reliable since Node.js fetch uses undici internally.
Provides complete control over networking by injecting custom fetch and WebSocket factory functions. Useful for:
- Mocking network calls in tests
- Logging/monitoring all requests
- Custom routing or load balancing
- Integration with non-standard networking layers
Configuration validation:
- When
mode: 'injected', BOTHfetchandwebSocketFactoryare required - The
agentparameter cannot be used with injected mode (conflict error) - When
mode: 'native',fetchandwebSocketFactorycannot be provided (conflict error)
Type signatures:
type NetworkMode = 'native' | 'injected';
interface FetchRequest {
url: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
headers: Record<string, string>;
body?: string;
}
interface FetchResponse {
status: number;
ok: boolean;
json(): Promise<unknown>;
text(): Promise<string>;
}
type FetchFunction = (request: FetchRequest) => Promise<FetchResponse>;
interface InjectedWebSocket {
send(data: string): void;
close(code?: number): void;
readonly readyState: number;
on(event: 'message', listener: (data: string) => void): void;
on(event: 'open', listener: () => void): void;
on(event: 'close', listener: (code: number, reason: string) => void): void;
on(event: 'error', listener: (error: Error) => void): void;
}
type WebSocketFactory = (url: string) => InjectedWebSocket;Example with logging:
import WebSocket from 'ws';
const handler = new WebexMessageHandler({
token: process.env.WEBEX_BOT_TOKEN!,
mode: 'injected',
fetch: async (request) => {
console.log(`[HTTP] ${request.method} ${request.url}`);
const response = await fetch(request.url, {
method: request.method,
headers: request.headers,
body: request.body,
});
return {
status: response.status,
ok: response.ok,
json: () => response.json(),
text: () => response.text(),
};
},
webSocketFactory: (url) => {
console.log(`[WS] Connecting to ${url}`);
return new WebSocket(url) as any;
},
});Example with proxy (corporate environments):
import { ProxyAgent } from 'undici';
import { HttpsProxyAgent } from 'https-proxy-agent';
import WebSocket from 'ws';
const proxyUrl = process.env.HTTPS_PROXY || 'http://proxy.example.com:8080';
const httpProxyAgent = new ProxyAgent(proxyUrl);
const wsProxyAgent = new HttpsProxyAgent(proxyUrl);
const handler = new WebexMessageHandler({
token: process.env.WEBEX_BOT_TOKEN!,
mode: 'injected',
fetch: async (request) => {
const response = await fetch(request.url, {
method: request.method,
headers: request.headers,
body: request.body,
dispatcher: httpProxyAgent, // Route HTTP through proxy
});
return {
status: response.status,
ok: response.ok,
json: () => response.json(),
text: () => response.text(),
};
},
webSocketFactory: (url) => {
// IMPORTANT: ws library requires 'agent' option for proxy support
return new WebSocket(url, { agent: wsProxyAgent }) as any;
},
});Critical for proxy users: The
wslibrary requires an explicitagentoption to route WebSocket connections through a proxy. Simply creatingnew WebSocket(url)will bypass the proxy and attempt a direct connection.
Example for testing:
// Mock fetch that returns canned responses
const mockFetch: FetchFunction = async (request) => {
if (request.url.includes('/devices')) {
return {
status: 200,
ok: true,
json: async () => ({ deviceUrl: 'mock-device', userId: 'mock-user', ... }),
text: async () => '',
};
}
// ... handle other endpoints
};
// Mock WebSocket that doesn't actually connect
class MockWebSocket extends EventEmitter {
send(data: string) { /* no-op */ }
close() { /* no-op */ }
readyState = 1;
}
const handler = new WebexMessageHandler({
token: 'mock-token',
mode: 'injected',
fetch: mockFetch,
webSocketFactory: (url) => new MockWebSocket() as any,
});Network call inventory:
The library makes exactly 6 types of network calls:
| Component | Method | URL | Purpose |
|---|---|---|---|
| DeviceManager | POST | wdm-a.wbx2.com/wdm/api/v1/devices |
Register virtual device |
| DeviceManager | PUT | {deviceUrl} |
Refresh device registration |
| DeviceManager | DELETE | {deviceUrl} |
Unregister device |
| KmsClient | GET | {encryptionServiceUrl}/kms/{userId} |
Fetch KMS cluster details |
| KmsClient | POST | {encryptionServiceUrl}/kms/messages |
ECDH key exchange and key requests |
| MercurySocket | WebSocket | Mercury URL from device | Persistent message stream |
Establishes the full connection pipeline:
- Registers a virtual device with Webex WDM (Web Device Management)
- Opens a Mercury WebSocket and authenticates
- Performs KMS ECDH key exchange (for end-to-end message encryption)
- Begins listening for encrypted messages, decrypting them automatically
Resolves when the connection is fully established and ready to receive messages. Throws on failure (AuthError, MercuryConnectionError, KmsError).
await handler.connect();
// handler.status() now returns { status: 'connected', ... }Tears down the connection cleanly:
- Closes the Mercury WebSocket (stops heartbeat, cancels auto-reconnection)
- Unregisters the virtual device with WDM
- Clears KMS context and decryption keys from memory
After disconnect, the instance can be reconnected by calling connect() again or reconnect(newToken) with a fresh token.
await handler.disconnect();
// handler.status() now returns { status: 'disconnected', ... }Updates the access token and re-establishes the connection from scratch. Internally calls disconnect(), stores the new token, then calls connect().
Use this when:
- The token has been refreshed externally (OAuth rotation)
- You receive a
'disconnected'event with reason'auth-failed' - You want to proactively rotate the token before it expires
const freshToken = await myAuthSystem.refreshToken();
await handler.reconnect(freshToken);
// Fully reconnected with the new tokenReturns a structured health check of all connection subsystems. Use this to build monitoring, health endpoints, or decide whether to reconnect.
const health = handler.status();HandlerStatus fields:
| Field | Type | Description |
|---|---|---|
status |
ConnectionStatus |
Overall state: 'connected', 'connecting', 'reconnecting', or 'disconnected'. |
webSocketOpen |
boolean |
Whether the Mercury WebSocket is currently open. |
kmsInitialized |
boolean |
Whether the KMS encryption context has been established. |
deviceRegistered |
boolean |
Whether a virtual device is registered with WDM. |
reconnectAttempt |
number |
Current auto-reconnect attempt number (0 if not reconnecting). |
ConnectionStatus values:
| Value | Meaning |
|---|---|
'connected' |
Fully operational. WebSocket is open, KMS is initialized, messages flowing. |
'connecting' |
Initial connect() call is in progress. |
'reconnecting' |
Auto-reconnect is in progress after a connection drop. |
'disconnected' |
Not connected. Either never connected, manually disconnected, or reconnect gave up. |
// Health check endpoint example
app.get('/health', (req, res) => {
const health = handler.status();
const httpStatus = health.status === 'connected' ? 200 : 503;
res.status(httpStatus).json(health);
});
// Wrapper decision logic
const health = handler.status();
if (health.status === 'disconnected') {
// Not connected at all — need to connect or reconnect
await handler.reconnect(await getToken());
} else if (health.status === 'reconnecting' && health.reconnectAttempt > 5) {
// Auto-reconnect is struggling — maybe token expired
await handler.reconnect(await refreshToken());
}Quick boolean shorthand. Returns true only when status would be 'connected'.
if (handler.connected) {
// ready to receive messages
}Subscribe with handler.on(event, callback). Remove with handler.off(event, callback).
Fired when a new message is received and decrypted.
handler.on('message:created', (message: DecryptedMessage) => {
message.id; // string — unique message ID
message.roomId; // string — conversation/room ID
message.personId; // string — sender's Webex user ID
message.personEmail; // string — sender's email address
message.text; // string — decrypted plain text of the message
message.html; // string | undefined — decrypted HTML content (rich text)
message.created; // string — ISO 8601 timestamp
message.roomType; // 'direct' | 'group' | undefined
message.raw; // MercuryActivity — the full decrypted activity object
});Fired when a message is deleted.
handler.on('message:deleted', (data: DeletedMessage) => {
data.messageId; // string — ID of the deleted message
data.roomId; // string — conversation/room ID
data.personId; // string — who deleted it
});Fired when the connection is established (initial connect or after reconnection).
handler.on('connected', () => {
console.log('Ready to receive messages');
});Fired when the connection is lost. The reason string indicates why:
| Reason | Meaning | Action |
|---|---|---|
'client' |
You called disconnect(). |
None — intentional. |
'auth-failed' |
Token is invalid or expired (WebSocket code 4401). | Call reconnect(newToken) with a fresh token. |
'permanent-failure' |
Server rejected the connection permanently (code 4400/4403). | Investigate — likely a configuration issue. |
'max-attempts-exceeded' |
Auto-reconnect gave up after max attempts. | Call reconnect(newToken) — token may have expired during retries. |
'manual' |
WebSocket closed and reconnection was disabled. | None — intentional. |
handler.on('disconnected', async (reason: string) => {
if (reason === 'auth-failed' || reason === 'max-attempts-exceeded') {
const freshToken = await refreshMyToken();
await handler.reconnect(freshToken);
}
});Fired when auto-reconnect is attempting. The attempt number starts at 1.
handler.on('reconnecting', (attempt: number) => {
console.log(`Reconnect attempt ${attempt}...`);
});Fired on non-fatal errors (decryption failures, reconnect issues, etc.). The connection may still be alive.
handler.on('error', (err: Error) => {
console.error('Handler error:', err.message);
});The simplest pattern. Wait for the connection to fail, then get a new token:
handler.on('disconnected', async (reason) => {
if (reason === 'auth-failed') {
const freshToken = await myAuth.getToken();
await handler.reconnect(freshToken);
}
});If you know when the token expires, rotate it before the connection fails (avoids any message gap):
setInterval(async () => {
if (isTokenExpiringSoon()) {
const freshToken = await refreshMyToken();
await handler.reconnect(freshToken);
}
}, 60_000);Pass a token-fetching function to your wrapper so it always has a way to get a fresh token:
class MyBot {
constructor(private getToken: () => Promise<string>) {}
async start() {
const token = await this.getToken();
this.handler = new WebexMessageHandler({ token });
this.handler.on('disconnected', async (reason) => {
if (reason === 'auth-failed' || reason === 'max-attempts-exceeded') {
const fresh = await this.getToken();
await this.handler.reconnect(fresh);
}
});
await this.handler.connect();
}
}interface Logger {
debug(message: string, ...args: unknown[]): void;
info(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
}Built-in loggers:
| Export | Behavior |
|---|---|
noopLogger |
Silent. All methods are no-ops. This is the default. |
consoleLogger |
Logs to console.debug, console.info, console.warn, console.error. |
Compatible with winston, pino, bunyan — pass the instance directly if it has these four methods, or wrap it.
All errors extend WebexError which extends Error. Each has a .code string and .name.
| Class | .code |
When |
|---|---|---|
AuthError |
AUTH_ERROR |
Token is invalid, expired, or unauthorized. |
DeviceRegistrationError |
DEVICE_REGISTRATION_ERROR |
WDM device registration/refresh/unregister failed. Has .statusCode. |
MercuryConnectionError |
MERCURY_CONNECTION_ERROR |
WebSocket connection failed. Has .closeCode. |
KmsError |
KMS_ERROR |
KMS key exchange or key retrieval failed. |
DecryptionError |
DECRYPTION_ERROR |
Message decryption failed (bad key, corrupt ciphertext). |
import { AuthError, KmsError } from 'webex-message-handler';
handler.on('error', (err) => {
if (err instanceof AuthError) {
// token issue — reconnect with fresh token
} else if (err instanceof KmsError) {
// KMS issue — will auto-recover on reconnect
}
});All TypeScript types are exported:
import type {
// Config & events
WebexMessageHandlerConfig,
WebexMessageHandlerEvents,
// Networking types (v0.3.0+)
NetworkMode,
FetchRequest,
FetchResponse,
FetchFunction,
InjectedWebSocket,
WebSocketFactory,
// Status
HandlerStatus,
ConnectionStatus,
// Message types
DecryptedMessage,
DeletedMessage,
// Internal types (for advanced use)
DeviceRegistration,
MercuryActivity,
MercuryActor,
MercuryObject,
MercuryTarget,
MercuryEnvelope,
// Logger
Logger,
} from 'webex-message-handler';interface DecryptedMessage {
id: string; // Unique message ID
roomId: string; // Conversation/space ID
personId: string; // Sender's user ID
personEmail: string; // Sender's email
text: string; // Decrypted plain text
html?: string; // Decrypted HTML (rich text messages)
created: string; // ISO 8601 timestamp
roomType?: string; // 'direct' | 'group' | undefined
raw: MercuryActivity; // Full decrypted activity for advanced use
}interface DeletedMessage {
messageId: string; // ID of the deleted message
roomId: string; // Conversation/space ID
personId: string; // Who deleted it
}type ConnectionStatus = 'connected' | 'connecting' | 'reconnecting' | 'disconnected';
interface HandlerStatus {
status: ConnectionStatus; // Overall connection state
webSocketOpen: boolean; // Mercury WebSocket is open
kmsInitialized: boolean; // KMS encryption context is active
deviceRegistered: boolean; // Virtual device is registered with WDM
reconnectAttempt: number; // Current auto-reconnect attempt (0 if not reconnecting)
} +-----------+ +----------+ +----------+ +---------+
| register | --> | Mercury | --> | KMS | --> | ready |
| device | | connect | | ECDH | | |
+-----------+ +----------+ +----------+ +---------+
| | | |
| heartbeat encrypts emits:
| ping/pong messages message:created
| | | message:deleted
| v | |
| (connection drop) | |
| | | |
| v v |
| +-----------+ +------------+ |
| | auto |--> | re-init |----------->+
| | reconnect | | KMS + WDM |
| +-----------+ +------------+
|
reconnect(newToken) --> disconnect() --> connect() with new token
Auto-reconnect handles transient WebSocket drops automatically:
- Exponential backoff (1s, 2s, 4s, ... up to
reconnectBackoffMax) - Reconnect the WebSocket
- Refresh the WDM device registration
- Re-perform the KMS ECDH key exchange
- Resume receiving messages
Auto-reconnect does not handle expired tokens. If the token expired, reconnect fails with 'auth-failed' and you must call reconnect(newToken).
A full wrapper with token refresh, health checks, graceful shutdown, and message routing:
import {
WebexMessageHandler,
consoleLogger,
} from 'webex-message-handler';
import type {
DecryptedMessage,
HandlerStatus,
Logger,
} from 'webex-message-handler';
interface BotOptions {
/** Returns a valid access token. Called on start and on auth failure. */
getToken: () => Promise<string>;
/** Called for each incoming message. */
onMessage: (message: DecryptedMessage) => void | Promise<void>;
/** Optional logger. Defaults to consoleLogger. */
logger?: Logger;
}
class WebexBot {
private handler: WebexMessageHandler | null = null;
private opts: BotOptions;
private logger: Logger;
constructor(opts: BotOptions) {
this.opts = opts;
this.logger = opts.logger ?? consoleLogger;
}
/** Connect to Webex and start receiving messages. */
async start(): Promise<void> {
const token = await this.opts.getToken();
this.handler = new WebexMessageHandler({
token,
logger: this.logger,
});
this.handler.on('message:created', async (msg) => {
try {
await this.opts.onMessage(msg);
} catch (err) {
this.logger.error('onMessage handler error:', err);
}
});
this.handler.on('disconnected', async (reason) => {
this.logger.warn(`Disconnected: ${reason}`);
if (reason === 'auth-failed' || reason === 'max-attempts-exceeded') {
try {
const freshToken = await this.opts.getToken();
await this.handler!.reconnect(freshToken);
} catch (err) {
this.logger.error('Token refresh/reconnect failed:', err);
}
}
});
this.handler.on('reconnecting', (attempt) => {
this.logger.info(`Auto-reconnecting (attempt ${attempt})...`);
});
this.handler.on('connected', () => {
this.logger.info('Connected and ready');
});
this.handler.on('error', (err) => {
this.logger.error('Error:', err);
});
await this.handler.connect();
}
/** Disconnect cleanly. */
async stop(): Promise<void> {
if (this.handler) {
await this.handler.disconnect();
this.handler = null;
}
}
/** Get connection health for monitoring. */
health(): HandlerStatus | null {
return this.handler?.status() ?? null;
}
}
// --- Usage ---
const bot = new WebexBot({
getToken: async () => process.env.WEBEX_BOT_TOKEN!,
onMessage: (msg) => {
console.log(`[${msg.personEmail}] ${msg.text}`);
},
});
await bot.start();
// Health check
console.log(bot.health());
// { status: 'connected', webSocketOpen: true, kmsInitialized: true, deviceRegistered: true, reconnectAttempt: 0 }
// Graceful shutdown
process.on('SIGINT', async () => {
await bot.stop();
process.exit(0);
});- Thread safety: Single-threaded (Node.js event loop). No mutexes needed.
- Memory: Encryption keys are cached in memory. On auto-reconnect, the key cache is preserved. On
reconnect(newToken), a fresh KMS context is created but cached keys for already-seen conversations are reused. - One instance per token: Each instance registers its own virtual device. Multiple instances with the same token each receive all messages independently.
- Message ordering: Mercury guarantees per-conversation ordering.
- No outbound messaging: This package only receives messages. To send messages, use the Webex REST API directly (
POST https://webexapis.com/v1/messages). - Dual format: Ships as both CommonJS and ESM.
require()andimportboth work. - Heartbeat: The WebSocket sends a ping every
pingIntervalms. If no pong arrives withinpongTimeoutms, the connection is considered dead and auto-reconnect triggers. This keeps the connection alive through NAT timeouts and load balancer idle timeouts.