Copyright © 2025-2026 Dankest, LLC

XChain SDK: WebSocket Client

The SDK includes a built-in WebSocket client for real-time event streaming from the xchain-explorer. It wraps the explorer’s WebSocket API with a developer-friendly interface including convenience methods, automatic reconnection with catch-up, and lifecycle hooks.

Quick Start

const { XChainSDK } = require('@dankest-llc/xchain-sdk');

const sdk = new XChainSDK({
    network: 'bitcoin-mainnet',
    explorerUrl: 'explorer.xchain.io',
    explorerPort: 8080
});

// Connect the WebSocket
await sdk.connectWs();

// Listen for new blocks
const unsub = sdk.onBlock((event) => {
    console.log('New block:', event.data.block_index);
});

// Stop listening
unsub();

Configuration

The WebSocket client is auto-created when explorerUrl or websocketUrl is configured. No separate initialization needed; it shares the explorer’s host and port by default.

Constructor Options

Option Type Default Description
websocketUrl string explorerUrl WebSocket server hostname (falls back to explorer URL)
websocketPort number explorerPort WebSocket server port (falls back to explorer port)

Environment Variables

Variable Description
WEBSOCKET_URL WebSocket server hostname
WEBSOCKET_PORT WebSocket server port

Resolution Priority

  1. Constructor websocketUrl / websocketPort
  2. Constructor explorerUrl / explorerPort
  3. Hub-discovered endpoints (via init())
  4. Environment variables WEBSOCKET_URL / WEBSOCKET_PORT
  5. Environment variables EXPLORER_URL / EXPLORER_PORT

Connection Management

// Connect (returns Promise, resolved on WELCOME)
const serverInfo = await sdk.connectWs();
console.log('Server version:', serverInfo.version);

// Check connection state
sdk.ws.isConnected(); // true

// Disconnect
sdk.disconnectWs();

sdk.stop() automatically disconnects the WebSocket.

Convenience Methods

Every convenience method returns an unsubscribe function. Call it to remove the handler and unsubscribe from the channel.

sdk.onBlock(callback)

Listen for new blocks.

const unsub = sdk.onBlock((event) => {
    console.log('Block', event.data.block_index, 'with', event.data.action_count, 'actions');
});

sdk.onAction(callback, opts?)

Listen for new actions with optional filters.

// All actions
sdk.onAction((event) => {
    console.log(event.data.action, event.data.action_index);
});

// Only specific types
sdk.onAction((event) => {
    console.log('Order event:', event.data.action_index);
}, { types: ['ORDER', 'ORDER_MATCH', 'ORDER_EXPIRE'] });

Options: { types?: string[], statuses?: string[], ticks?: string[] } (statuses is not supported: accepted for backward compatibility but never honored by the server, so it silently matches every event; do not rely on it)

sdk.onAddress(address, callback, opts?)

Watch an address for all relevant events (actions, balance changes, order matches, COINPay obligations).

const unsub = sdk.onAddress('1abc...', (event) => {
    console.log(event.type, ':', event.data);
}, { snapshot: true });

Events delivered: NEW_ACTION, ADDRESS_UPDATE, ORDER_MATCH, COINPAY_REQUIRED, COINPAY_FULFILLED, COINPAY_EXPIRED, SWAP_MATCH, DISPENSE

Options: { types?: string[], statuses?: string[], snapshot?: boolean } (statuses is not supported: never honored by the server; do not rely on it)

sdk.onToken(tick, callback)

Watch a token for supply/holder changes. Sends a snapshot on subscribe.

sdk.onToken('PEPE', (event) => {
    console.log('PEPE supply:', event.data.supply, 'holders:', event.data.holders);
});

sdk.onMarket(tick1, tick2, callback)

Watch a market pair for price/volume changes. Sends a snapshot on subscribe.

sdk.onMarket('PEPE', 'BTC', (event) => {
    console.log('PEPE/BTC price:', event.data.last_price);
});

sdk.onDispenser(actionIndex, callback)

Watch a dispenser for triggers, status changes, and expiration. Sends a snapshot on subscribe.

sdk.onDispenser(12345, (event) => {
    console.log(event.type, 'remaining:', event.data.give_remaining);
});

Events delivered: DISPENSER_UPDATE, DISPENSE, DISPENSER_CLOSED, DISPENSER_EXPIRED

sdk.onCoinpayRequired(address, callback)

Shortcut for watching an address for COINPAY_REQUIRED events only. Ideal for trading bots that need to auto-fulfill COINPay obligations.

sdk.onCoinpayRequired('1BotAddr...', (event) => {
    const { payer_address, payee_address, coin_amount, expiration } = event.data;
    console.log('COINPay needed:', coin_amount, 'BTC to', payee_address);
    console.log('Deadline:', new Date(expiration * 1000));
    // Construct and broadcast COINPAY transaction...
});

sdk.onOrderMatch(address, callback, opts?)

Shortcut for watching an address for ORDER_MATCH events. To react only to coinpay-required matches, check event.data.status inside the callback: statuses filtering is not supported by the server.

// All order matches
sdk.onOrderMatch('1abc...', (event) => {
    console.log('Order matched:', event.data.action_index);
});

// Only coinpay-required matches (filter client-side; the server does not honor a statuses option)
sdk.onOrderMatch('1abc...', (event) => {
    if (event.data.status !== 'pending_coinpay') return;
    console.log('Needs COINPay:', event.data.action_index);
});

sdk.onNetworkStats(callback)

Listen for network statistics updates (emitted with each new block).

sdk.onNetworkStats((event) => {
    console.log('Block height:', event.data.block_height);
});

Low-Level API

For advanced use cases, access the WebSocket client directly via sdk.ws.

const ws = sdk.ws;

// Subscribe with full control (statuses is not a supported filter; the server ignores it)
await ws.subscribe(['address'], {
    address: '1abc...',
    types: ['ORDER_MATCH', 'COINPAY_REQUIRED'],
    snapshot: true,
    since_action_index: 45000
});

// Register event handlers
ws.on('ORDER_MATCH', (msg) => { /* ... */ });
ws.once('COINPAY_REQUIRED', (msg) => { /* fires once then auto-removes */ });

// List active subscriptions
const list = await ws.listSubscriptions();
console.log(list.data.subscriptions);

// Unsubscribe
ws.unsubscribe(['address'], { address: '1abc...' });

// Follow one betting market: bets placed, the deadline latch, resolution,
// cancellation, expiry. A parimutuel payout is only ever a projection until the
// market resolves, so a market page needs the pools to stay current.
await ws.subscribeBetFeed(1234);

// Create, cancel, place and resolve all arrive as ONE event type, because BET is
// one action name carrying four formats. `data.action_format` is what tells them
// apart: 0 create, 1 cancel, 2 a stake placed, 3 the payout decision.
ws.on('BET', (msg) => {
    if (msg.data.action_format === 2) { /* recompute projected payouts */ }
    if (msg.data.action_format === 3) { /* the market paid out */ }
});

// The deadline latch closes betting. It has no transaction behind it (the node
// writes it in an end-of-block pass), so it arrives under its own type with
// tx_hash null and synthetic true.
ws.on('BET_CLOSED', (msg) => { /* stop accepting stakes; msg.data.block_index */ });

// The refund pass, when a market is never resolved inside its refund window.
ws.on('BET_EXPIRED', (msg) => { /* every stake refunded */ });

ws.unsubscribeBetFeed(1234);

Every event on this channel is keyed on the MARKET, not on the action that caused it: data.feed_action_index is the feed you subscribed to, so a page following one market receives stakes placed by strangers on it and nothing from any other.

Reconnection and Catch-Up

The client automatically reconnects on disconnect with exponential backoff. On reconnect, it replays all subscriptions with since_action_index set to the last received action index, so no events are missed.

Catch-up events have catch_up: true in the envelope. After replay, a CATCH_UP_COMPLETE event is emitted.

ws.on('CATCH_UP_COMPLETE', (msg) => {
    console.log('Caught up:', msg.data.events_replayed, 'events replayed');
});
sequenceDiagram
    participant Client
    participant Server

    Note over Client,Server: connection is lost
    Client->>Client: reconnect with exponential backoff
    Client->>Server: reconnect

    Client->>Server: replay subscriptions, since_action_index = last received index
    Server-->>Client: catch-up events, catch_up true
    Server-->>Client: CATCH_UP_COMPLETE, events_replayed

Reconnection uses the same retry configuration as the explorer/encoder clients:

Setting Default
Max attempts 10
Base delay 1,000 ms
Max delay 30,000 ms
Backoff factor 2x

Configure via the retry constructor option:

new XChainSDK({
    network: 'bitcoin-mainnet',
    explorerUrl: 'explorer.xchain.io',
    retry: { maxRetries: 20, baseDelay: 500, maxDelay: 60000 }
});

Lifecycle Hooks

The WebSocket client supports four hooks, configured via the hooks constructor option:

Hook Fires When
onWsConnect WebSocket connection established
onWsDisconnect WebSocket connection closed
onWsMessage Any message received from server
onWsReconnect Before a reconnection attempt
const sdk = new XChainSDK({
    network: 'bitcoin-mainnet',
    explorerUrl: 'explorer.xchain.io',
    hooks: {
        onWsConnect:    (info) => console.log('WS connected:', info.url),
        onWsDisconnect: (info) => console.log('WS disconnected:', info.code),
        onWsMessage:    (msg)  => console.log('WS event:', msg.type),
        onWsReconnect:  (info) => console.log('WS reconnecting, attempt', info.attempt)
    }
});

Error Handling

WebSocket errors throw SDKExplorerError (same class as REST API errors):

const { SDKExplorerError } = require('@dankest-llc/xchain-sdk');

try {
    await sdk.connectWs();
} catch (e) {
    if (e instanceof SDKExplorerError) {
        console.log('WS error:', e.code, e.message);
    }
}
Error Code Cause
WS_CONNECTION_FAILED Could not connect to WebSocket server
WS_CONNECTION_CLOSED Connection closed before response received
WS_TIMEOUT No response for a request within timeout
WEBSOCKET_NOT_CONFIGURED Called a WS method without configuring WebSocket URL

Server-side errors (e.g., SUBSCRIPTION_LIMIT, RATE_LIMITED) are returned as rejected Promises from subscribe().

Edit this page on GitHub ↗