Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Subscriptions

sdk.subscriptions streams the real-time channels of the WebSocket API with typed payloads, automatic reconnection, and automatic re-subscription. Every method takes a listener and returns a handle you can unsubscribe.

import { TxFlow } from 'txflow-typescript-sdk'
 
const sdk = new TxFlow('testnet')
 
const book = await sdk.subscriptions.l2Book(
  { coin: '1' },
  (data) => {
    const [bids, asks] = data.levels
    console.log(data.coin, bids[0]?.px, asks[0]?.px)
  },
)
 
// later
await book.unsubscribe()

Two endpoints, two clients

TxFlow serves real-time data from two independent endpoints, so the SDK exposes one client per endpoint. Each opens its own connection.

ClientEndpointChannels
sdk.subscriptions/wsMarket, user, explorer, and Outcome channels
sdk.apiSubscriptions/api-wscandle and outcomeOrderUpdate

sdk.apiSubscriptions was called sdk.chartSubscriptions while candles were the only channel on /api-ws. The old name — and createChartSubscriptionContext — still resolve to the same client as deprecated aliases.

Both are lazy: the connection is established on first property access, so an HTTP-only integration never opens a socket. The endpoint URLs come from the network preset:

Network/ws/api-ws
testnetwss://rpc.txflow-testnet.xyz/wswss://rpc.txflow-testnet.xyz/api-ws
mainnetwss://rpc.txflow.com/wswss://rpc.txflow.com/api-ws

Override them with wsUrl and wsApiUrl when routing through a proxy:

const sdk = new TxFlow({
  network: 'testnet',
  wsUrl: 'wss://gateway.internal/ws',
  wsApiUrl: 'wss://gateway.internal/api-ws',
})

Instrument identifiers

Subscription parameters take the instrument index as a string, never the pair symbol. Resolve it from metadata first:

const meta = await sdk.info.perpMeta()
const market = meta.universe.find(({ name }) => name === 'BTC-USDC')
 
if (!market) {
  throw new Error('Market not found')
}
 
await sdk.subscriptions.trades(
  { coin: String(market.index) },
  (trades) => console.log(trades.length),
)

Outcome markets follow the same rule on the shared l2Book, trades, and activeAssetCtx channels: pass the instrument id as a decimal string, not the OUTCOME-<id> name. The Outcome-specific channels take numeric instrumentIds arrays instead.

Pushes are delivered as received, without local filtering by instrument. Several channels echo the resolved pair symbol in the payload rather than the index you subscribed with, so when one connection carries several instruments of the same channel, route on the payload field yourself:

import type { WsL2BookEvent } from 'txflow-typescript-sdk'
 
const routes = new Map<string, (data: WsL2BookEvent) => void>()
 
await sdk.subscriptions.l2Book({ coin: '1' }, (data) => {
  routes.get(data.coin)?.(data)
})

Subscription lifecycle

The backend sends no confirmation frames on /ws, so a subscribe promise resolves once the frame has been handed to an open connection — not once the server acknowledged it. A rejection therefore means the frame could not be delivered at all (timeout, abort, or a permanently terminated connection).

import { WebSocketRequestError } from 'txflow-typescript-sdk'
 
try {
  const sub = await sdk.subscriptions.allMids((data) => console.log(data.mids))
  await sub.unsubscribe()
} catch (error) {
  if (error instanceof WebSocketRequestError) {
    console.error('Subscribe frame was not delivered:', error.message)
  }
}

Listeners are deduplicated per subscription: identical subscriptions share one wire subscription, and the wire unsubscribe is only sent when the last listener detaches. One connection holds at most 1000 distinct subscriptions; the 1001st throws instead of being silently dropped by the server.

Options

Every method accepts an options object as its last argument.

OptionTypePurpose
signalAbortSignalStops waiting for frame delivery and detaches the listener
onError(error: WebSocketRequestError) => voidFires at most once when an established subscription stops being served

The two never overlap: a failure before the subscribe frame is delivered rejects the promise, and a failure after that goes to onError. Once onError fires, the subscription is gone — no further events or errors follow, so resubscribe from there if you need the stream back.

const controller = new AbortController()
 
await sdk.subscriptions.orderUpdates(
  { user: '0xOwnerAddress' },
  (updates) => console.log(updates),
  {
    signal: controller.signal,
    onError: (error) => console.error('Stream stopped:', error.message),
  },
)

onError fires when the connection is permanently terminated, when it drops while re-subscription is disabled, or when a replay after reconnect cannot be delivered. Ordinary reconnects are handled internally and stay invisible to your listener.

Market channels

MethodParameters
allMids(listener, options?)—
allAssetCtxs(listener, options?)—
l2Book({ coin, tick? }, listener, options?)tick aggregates price levels; '' is the finest granularity
l2BookDepth({ coin }, listener, options?)500-level stream: one snapshot, then deltas
trades({ coin }, listener, options?)—
activeAssetCtx({ coin }, listener, options?)Perp ticker context
outcomeBbos({ instrumentIds, exchangeId }, listener, options?)Batched Outcome best bid/ask
outcomeTrades({ instrumentIds }, listener, options?)Trades across several Outcome markets

outcomeBbos is what Outcome discovery grids and watchlists should use. A subscription carries 1–50 instrument ids and one connection accepts up to 20 active groups, so split larger sets across several subscriptions on the same connection. For full depth on a single market, use l2Book with the instrument id as coin.

User channels

These take the owner address, not an agent address.

MethodParameters
orderUpdates({ user }, listener, options?)—
clearinghouseState({ user }, listener, options?)Positions and margin summary
activeAssetData({ coin, user }, listener, options?)Available funds and leverage for one instrument
vaultUpdates({ user }, listener, options?)—
twapStates({ user, dex? }, listener, options?)dex defaults to ''
userTwapSliceFills({ user }, listener, options?)—
outcomeOrderUpdates({ user, instrumentIds? }, listener, options?)Outcome matching-engine lifecycle

User channels stream state; they do not authenticate anything. Signed writes still go through sdk.exchange.

Block and explorer channels

MethodPushed channel
newBlock(listener, options?)newBlock — height ticker
explorerBlock(listener, options?)block — full explorer blocks, high frequency
explorerTxs(listener, options?)tx — explorer transactions

Candles

Candles live on the gateway endpoint, so they use sdk.apiSubscriptions:

await sdk.apiSubscriptions.candle(
  { coin: '1', interval: '1m', symbol: 'BTC-USDC' },
  (candle) => console.log(candle.t, candle.o, candle.c),
)

Pass symbol whenever you can. The backend is inconsistent about the payload's s field — the same subscription may push the instrument index on one frame and the base symbol on the next — and symbol lets the SDK filter frames by index, full symbol, or base name. Without it, frames are matched on interval alone, so two instruments subscribed at the same interval on one connection will cross-feed.

Unlike /ws, this endpoint does answer with confirmations and rejections. Watch the raw error channel to see a rejected subscription:

sdk.apiSubscriptions.transport.events.addEventListener('error', (event) => {
  console.warn(event.detail)
})

Outcome order results

Placing an Outcome order needs two subscriptions, on two different endpoints. Subscribing to only one leaves a blind spot.

ChannelEndpointAnswers
outcomeOrderUpdate (singular)/api-wsDid the queued order fail before reaching the node? Pushed only on failure.
outcomeOrderUpdates (plural)/wsWhat did the book do with it — filled, cancelled, rejected?

The gateway may park an Outcome order in a delay queue, so the HTTP response to sdk.exchange.placeLimitOrder only means "accepted". The singular channel pushes only when that queued order fails, keyed by traceId, with the failure reason; a successful submission is not pushed:

await sdk.apiSubscriptions.outcomeOrderUpdate({ user }, (result) => {
  if (result.done && !result.success) {
    console.error('Order never reached the node', result.traceId, result.body?.message)
  }
})
 
await sdk.subscriptions.outcomeOrderUpdates({ user }, (updates) => {
  for (const { order, status } of updates) {
    console.log(order.oid, order.outcome, status)
  }
})

The names differ by one character, so it is worth being deliberate about which client each one hangs off: the singular is on apiSubscriptions, the plural on subscriptions.

Connection management

await sdk.subscriptions.transport.ready()
 
sdk.disconnectWebSockets()

ready() resolves when the connection is open, which is useful before a burst of subscriptions. disconnectWebSockets() permanently closes both sockets; accessing sdk.subscriptions or sdk.apiSubscriptions afterwards creates fresh connections.

Reconnection and keep-alive run on their own: the socket reconnects with exponential backoff and jitter, pings every 10 seconds, and forces a reconnect when a ping goes unanswered (half-open detection). Established subscriptions are replayed after every reconnect.

Transport tuning

The TxFlow class uses the transport defaults. To change them — or to inject a WebSocket implementation on older Node.js — build the transport yourself and pass it to a client:

import WebSocket from 'ws'
import {
  SubscriptionClient,
  TxFlowWebSocketTransport,
} from 'txflow-typescript-sdk'
 
const transport = new TxFlowWebSocketTransport({
  url: 'wss://rpc.txflow-testnet.xyz/ws',
  timeout: 10_000,
  resubscribe: true,
  reconnect: {
    maxRetries: Infinity,
    baseDelay: 1_000,
    backoffFactor: 2,
    maxDelay: 30_000,
    jitter: 0.3,
    connectionTimeout: 10_000,
    WebSocketImpl: WebSocket as unknown as typeof globalThis.WebSocket,
  },
  keepAlive: {
    interval: 10_000,
    timeout: 10_000,
  },
})
 
const subscriptions = new SubscriptionClient({ transport })
OptionDefaultMeaning
timeout10_000Frame delivery timeout in ms; null disables it
resubscribetrueReplay subscriptions after a reconnect
reconnect.maxRetriesInfinityConsecutive attempts before permanent termination
reconnect.baseDelay1_000First backoff delay in ms
reconnect.backoffFactor2Backoff multiplier per attempt
reconnect.maxDelay30_000Backoff cap in ms
reconnect.jitter0.3Random jitter ratio applied to each delay
reconnect.connectionTimeout10_000Time to wait for a connection to open
keepAlive.interval10_000Ping interval in ms
keepAlive.timeout10_000Pong wait before forcing a reconnect

Setting resubscribe: false turns a reconnect into a terminal event for existing subscriptions: each one's onError fires and you resubscribe explicitly.

Standalone functions

Subscription channels share names with Info methods, so their tree-shakable functions live under the ws namespace:

import {
  createApiSubscriptionContext,
  createSubscriptionContext,
  ws,
} from 'txflow-typescript-sdk'
 
const ctx = createSubscriptionContext('testnet')
const sub = await ws.trades(ctx, { coin: '1' }, (trades) => console.log(trades))
 
const apiCtx = createApiSubscriptionContext('testnet')
await ws.candle(apiCtx, { coin: '1', interval: '1m' }, (c) => console.log(c.c))
 
await sub.unsubscribe()
ctx.transport.close()
apiCtx.transport.close()

Each factory creates and starts its own transport, and accepts the same tuning options as the second argument. You own the lifecycle: close the transport when you are done.

Protocol reference

  • WebSocket API — channel catalog, subscription frames, and payload schemas
  • Info API — snapshot reads to seed state before streaming
  • Exchange API — act on what the stream reports

Next steps