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.
| Client | Endpoint | Channels |
|---|---|---|
sdk.subscriptions | /ws | Market, user, explorer, and Outcome channels |
sdk.apiSubscriptions | /api-ws | candle 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 |
|---|---|---|
testnet | wss://rpc.txflow-testnet.xyz/ws | wss://rpc.txflow-testnet.xyz/api-ws |
mainnet | wss://rpc.txflow.com/ws | wss://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.
| Option | Type | Purpose |
|---|---|---|
signal | AbortSignal | Stops waiting for frame delivery and detaches the listener |
onError | (error: WebSocketRequestError) => void | Fires 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
| Method | Parameters |
|---|---|
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.
| Method | Parameters |
|---|---|
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
| Method | Pushed 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.
| Channel | Endpoint | Answers |
|---|---|---|
outcomeOrderUpdate (singular) | /api-ws | Did the queued order fail before reaching the node? Pushed only on failure. |
outcomeOrderUpdates (plural) | /ws | What 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 })| Option | Default | Meaning |
|---|---|---|
timeout | 10_000 | Frame delivery timeout in ms; null disables it |
resubscribe | true | Replay subscriptions after a reconnect |
reconnect.maxRetries | Infinity | Consecutive attempts before permanent termination |
reconnect.baseDelay | 1_000 | First backoff delay in ms |
reconnect.backoffFactor | 2 | Backoff multiplier per attempt |
reconnect.maxDelay | 30_000 | Backoff cap in ms |
reconnect.jitter | 0.3 | Random jitter ratio applied to each delay |
reconnect.connectionTimeout | 10_000 | Time to wait for a connection to open |
keepAlive.interval | 10_000 | Ping interval in ms |
keepAlive.timeout | 10_000 | Pong 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
- Advanced usage — errors, custom HTTP transport, and vault routing
- Quickstart — place and confirm a testnet order