Real time

WebSocket stream

A relay. The events of the venues covered are normalised and merged into a single output, pushed on a 100 ms tick. That is the only transformation performed: a trade goes in, a trade comes out.

It is not a source of metrics (no rolling window, no counter, no average), and there is no history: you receive what happens from your connection onwards. The past is served by the REST API.

#Connection

WSSwss://api.bytnode.com/ws?token=wst_…WebSocket transport

The same stream is served at wss://ws.bytnode.com/ws, its own server: same key, same channels, same frames. Use either address.

From a server, the key goes in the X-API-KEY header of the handshake; from a web page, a short-lived token goes in the URL (?token=, issued by POST /v1/ws/token: 60 seconds, one connection). Both are checked at the handshake, before the connection opens. It is the same key as the REST API (?api_key= in the URL is still accepted). Two refusals are possible at the handshake: key or token missing, invalid, expired or already used, a 401 (X-Deny-Reason: missing_api_key / invalid_api_key, and WWW-Authenticate as on REST); plan without the stream, which is the case of Free, a 403 (X-Deny-Reason: plan_websocket). A 503 at the handshake means something else: the stream service has reached its capacity, all accounts together; retry a little later. Once open, the connection too many against the ceiling of the plan is closed with code 4003.

The session therefore opens already authenticated: the first message can be the subscription. The old op: auth frame is still accepted for clients written before the handshake check: on a connection already authenticated it is ignored and answers {"op": "authenticated", "authenticated_by": "handshake"}, whatever key it carries.

{"op": "auth", "api_key": "your_key"}

#Subscribing

{
  "op": "subscribe",
  "channels": ["trades.futures.BTCUSDT", "orderbook.futures.BTCUSDT"],
  "depth": 5
}

Unsubscribing is symmetrical: op: unsubscribe, response op: unsubscribed.

  • The response lists every active channel of the connection, sorted, not only those of the request. It is a state, not an acknowledgement.
  • depth applies to the connection, not to a channel. Every subscribe resets the depth of all the order books of the session; omitted, it falls back to 5. It is the number of price levels per side (bids and asks); accepted values: 1 to 5.
  • Subscription is all or nothing. If a single channel of the list is invalid, none is applied and the error says why. A partial subscription would let you believe you are receiving what you asked for. Channel names are case-insensitive: trades.futures.btcusdt is listed back as trades.futures.BTCUSDT.
  • As soon as a subscription to an order book is accepted, the current state is sent immediately, without waiting for the next tick.

mode, optional on subscribe: frame (default) sends one message per frame, as described below; batch sends, at every tick, every pending frame in ONE message {"op": "batch", "frames": [...]} — fewer messages, the same frames inside. A client in batch mode must read frames instead of skipping every message that carries op. Like depth, it applies to the connection: every subscribe resets it, and omitted it falls back to frame. The machine-readable description of the stream (channels, frames, control messages, close codes) is the AsyncAPI contract.

#The five channels

ChannelNature and cadence
trades.futures.<SYMBOL>Events. At most one packet per 100 ms tick, absent if there is nothing to say. Under load packets are spaced further apart, sometimes by more than a second: a silence of a few seconds is not a stall.
trades.spot.<SYMBOL>Events, same cadence.
liquidations.<SYMBOL>Events, same cadence.
orderbook.spot.<SYMBOL>State: the spot order book, consolidated across the spot books quoted in USDT only (a venue that lists the asset only against the dollar is absent from it). Broadcast at every tick, even without a change.
orderbook.futures.<SYMBOL>State: the perpetual order book, consolidated across the perpetual venues, perpetuals quoted in USD or USDC included. Same cadence.

Twenty perpetuals are available on the five channels. The two stablecoins only exist in spot: they are served on trades.spot and orderbook.spot, and subscribing to their futures stream, to their perpetual order book or to their liquidations returns an explicit error, never a silently empty channel.

The channels and the plan

Each channel belongs to a data family, the same as on the REST API: trades.futures, trades.spot, orderbook.spot and orderbook.futures are microstructure, liquidations is derivatives. A family missing from your plan is missing on every channel, stream included. Concretely: Free does not have the stream at all (403 at the handshake), and the whole stream is open from Traders upwards. Real time is not the raw tick family, open from the Financial plan, which designates the tick-by-tick history of the REST API.

A channel outside the plan receives {"op": "error", "server_time": "…", "error": {"code": "plan_family", "message": "Channel 'orderbook.futures.BTCUSDT' is not in your plan: the 'microstructure' data family is not included in your subscription.", "param": "channels"}} and, as for any subscription, no channel of the frame is applied. The number of simultaneous connections per account also depends on the plan: 3 on Traders, 10 on Financial, 20 on Startup. See the plans.

#Message format

Every pushed message carries three common keys.

FieldTypeDescription
channelstringThe channel of origin.
server_timestringThe server tick that built the message, in ISO 8601 UTC with milliseconds — the same key and format as the REST envelope. The initial order book state carries the tick it was taken at, so it can be earlier than the subscribed before it. Every trade and every liquidation carries its own time in data.
seqintegerSequence number, monotonic per channel. Its starting value is arbitrary: do not expect 0 or 1. Only a jump between two consecutive messages signals lost messages.

Trades

trades
{
  "channel": "trades.futures.BTCUSDT",
  "server_time": "2026-10-14T09:25:26.408Z",
  "data": [
    { "time": "2026-10-14T09:25:26.176Z", "id": "3cf0988ca613980c", "side": "buy", "price": 83283.9, "quantity": 0.24, "value_usd": 19988.136 },
    { "time": "2026-10-14T09:25:26.184Z", "id": "4b408a64af719aa6", "side": "sell", "price": 83268.6, "quantity": 0.002, "value_usd": 166.5372 }
  ],
  "seq": 443379
}

side is the side of the taker: buy means a buyer consumed the offer. One row is one execution: a taker order that sweeps several maker orders of the same size at the same price produces several identical rows, and that is normal. Trades are grouped per tick, but none is lost: each one keeps its original timestamp in data[].time, distinct from the server_time of the message, and the trades of a message are sorted by that timestamp, oldest first. That order holds within a message only: a later message can carry a trade older than the newest one already received (a venue reporting late, usually by less than a second, at times up to about 1.5 s). To build candles, bucket on data[].time and close a period about two seconds after its end.

Each trade carries id, the same identifier as the tick of /v1/trades/futures or /v1/trades/spot for that trade (an opaque fingerprint: it names neither the venue nor its native id), and value_usd = price × quantity. Use id to deduplicate the live stream against the REST history.

Liquidations

liquidations
{
  "channel": "liquidations.BTCUSDT",
  "server_time": "2026-10-10T09:11:51.652Z",
  "data": [
    { "time": "2026-10-10T09:11:51.576Z", "price": 83168.0, "quantity": 0.081, "side": "short", "value_usd": 6736.608 }
  ],
  "seq": 7
}

side is here the side of the liquidated position: long means a long position was liquidated. Not every venue has this convention; the normalisation is done for you. As for trades, the liquidations of a message are sorted by time. A venue can report a liquidation a few seconds late: filter and window on time, never on the moment of reception. An old liquidation that a venue publishes again more than 60 s after its time is not relayed (/v1/liquidations/events keeps every event).

Order book

book
{
  "channel": "orderbook.futures.BTCUSDT",
  "server_time": "2026-10-14T09:25:26.304Z",
  "data": {
    "bids": [[83271.0, 67.65684], [83270.0, 4.85424], [83269.0, 15.31266]],
    "asks": [[83272.0, 17.0135], [83273.0, 11.33315], [83274.0, 5.34624]],
    "avg_mid_price": 83271.75,
    "avg_spread_bps": 0.036
  },
  "seq": 627302
}

Each row is [price, quantity], the quantity being the sum of the venues at that price level. The bids are decreasing, the asks increasing. Prices are brought back to a common grid per asset, without which each venue would fall into its own buckets and the consolidated order book would have more rows, each of them thinner.

The step of that grid, in the quote currency: BTC 1; ETH, BNB, TAO, ZEC, BCH 0.1; SOL, HYPE, XMR, LTC, QNT, AAVE, ETC, VVV, DASH, BSV 0.01; LINK, UNI, GRAM, AVAX, NEAR, DOT, MORPHO, ICP, RENDER, ATOM, FIL, INJ, LIT, ZRO, CAKE, TRUMP, PENDLE, LDO 0.001; XRP, TRX, SUI, XLM, ADA, USDCUSDT, USDTUSDC, ENA, ONDO, WLD, ASTER, WLFI, ARB, ALGO, POL, JUP, AERO, APT, STX, TIA, CRV, FET, VIRTUAL, GRASS, XPL, SPX, XTZ 0.0001; DOGE, HBAR, CC, SKY, JST, KAS, STABLE, AKE, NIGHT, PYTH, STRK, SEI, FF 0.00001; PUMP, VET, PENGU 0.000001.

There is one order book per market: orderbook.spot merges only the spot books, orderbook.futures only the perpetual books. The two are never mixed, since their prices differ by the basis. Within a market, a venue whose mid-price strays more than 5 basis points from the median of the others (when at least three venues are present) is left out of the merge until it comes back in line: an isolated venue cannot distort the consolidated book.

#Things to know

The consolidated order book is a depth, not one venue's book

Between several venues of the same market there is no common matching engine: their mid-prices differ by a few basis points, while one venue's bid/ask spread on BTC is about 0.1 basis point. Summed at their raw prices, their levels crossed the consolidated book in virtually every message.

Since 2026-10-08, each venue is re-centred on avg_mid_price before the merge: all its levels are shifted by avg_mid_price minus its own mid-price. Quantities are untouched, the distance of each level to its venue's mid is kept, and the consolidated book is never crossed: bids[0][0] is always below asks[0][0]. A venue that has only one side of its book at that moment does not contribute.

The number of trades does not measure activity

Some venues split their executions to the extreme, down to orders of one satoshi, a fraction of a cent. In the consolidated stream, the number of trades is therefore dominated by tiny executions. To measure activity, sum quantity or quantity × price, never count the rows.

A jump in seq means you have lost messages

The send queue of each client is bounded to 512 frames, about eight ticks at full load (800 ms of absorbable jolt). A client that does not consume fast enough sees its oldest messages dropped; it degrades on its own, without affecting the others.

On the event channels, a dropped message is definitively lost; the jump in seq is what tells you. On the order book, only the last state is kept: a jump there has no consequence, the next state entirely replaces the previous one.

The credential is checked at the handshake, not in a frame

The key (or the token) is checked at the handshake, before the connection opens: a plan that does not include the stream receives a 403 and nothing is established. From a server it travels in the X-API-KEY header; a browser, which cannot set a header on a WebSocket, uses a short-lived token in the URL (?token=), so that the key itself never leaves your server.

The {"op": "auth"} frame is still accepted and answers authenticated, but it is no longer necessary: the session opens already authenticated. ?token= and ?api_key= are only accepted on the WebSocket handshake, never on the REST routes, and the request line of /ws is not logged by the gateway.

The number of simultaneous connections depends on the plan

Every plan bounds the number of connections open at the same time, for the whole account, all keys together. The connection too many is closed at once with code 4003 and the ceiling in the reason. Do not confuse it with a 503 at the handshake, which says that the service itself is full, whatever the plan.

A disconnection immediately frees its slot: there is nothing to wait for and nothing to reset.

The stream never names a venue

No message says which venue a trade, a liquidation or a level of the order book comes from, nor how many venues fed it: the stream serves the consolidated market. A venue silent for more than ten seconds leaves the order book automatically, and a venue with an empty order book is never merged into it.

#Limits and closures

LimitWhen exceeded
Key or token missing, invalid or already used401 at the handshake, X-Deny-Reason: missing_api_key / invalid_api_key
Stream missing from the plan (Free)403 at the handshake, X-Deny-Reason: plan_websocket
Simultaneous connections per account: 3 on Traders, 10 on Financial, 20 on Startupclose 4003, the ceiling in the reason
Capacity of the stream service, all accounts together503 at the handshake, retry later
Channel of a family outside the planop: error, no channel of the frame is applied
Openings: 1 per second, burst of 10429 at the handshake
Send queue of 512 framesthe oldest ones dropped, seq jumps
10 overflows toleratedclose 4005, reason “client too slow”
depth between 1 and 5error, the subscription is not applied
Close codeReason
4003 (ceiling)The plan’s ceiling on simultaneous connections reached, for the whole account. A disconnection frees its slot at once.
4005 (client too slow)Queue saturated for a sustained period. A client that loses eight hundred milliseconds of stream ten times in a row will not catch up.
4008More than 20 messages per second sent by the client: control messages are rate-limited per connection.
4010Session expired: a connection is closed after 6 hours; reconnect and subscribe again (the key and plan are checked again).
1011Keepalive ping timeout: the server pings every 20 seconds and closes when no pong comes back within 20 seconds. Client libraries answer on their own; reconnect.
4001No longer sent in practice: the key is checked at the handshake (401), and an auth frame on an open session answers authenticated.
4002Session never authenticated within 5 s. Residual, since the key is checked at the handshake.

Every impossible request receives a named error, never a silence: unknown symbol, unknown channel, futures market asked for on a stablecoin, depth out of bounds, subscription before authentication, unreadable message. The error says what was expected.

An error frame has the parts of a REST error: {"op": "error", "server_time": …, "error": {"code": …, "message": …, "param": …, "details": …}'}, param being the field of your message at fault. Every control frame (subscribed, unsubscribed, authenticated) carries server_time, and {"op": "ping"} answers {"op": "pong", "server_time": …}.

#Reconnecting

A connection can drop (a deployment, the network). Reconnect with a growing wait (0.5 s, doubled up to 30 s, with a random part), then send your subscribe again: subscriptions do not survive the connection. Do not retry a 401 or 403 at the handshake or a close 4003 for the ceiling: the same answer would come back. seq continues across reconnections while the service runs but restarts from an arbitrary value when it restarts, so take the first message after a reconnection as a new start. On a plan with individual trades, the trades missed in between can be read back on /v1/trades/spot or /v1/trades/futures with since_ms: each carries the same id as on the stream, deduplicate on it. The SDKs reconnect and subscribe again for you.

#Full example

Authentication, subscription, loss detection and reading the order book.
import asyncio
import json
import os

import websockets  # pip install websockets


async def main() -> None:
    # From a server, the key goes in the X-API-KEY header, checked at the
    # handshake: the session opens already authenticated. A refused key gets 401
    # before it opens, a plan without the stream 403, a service at capacity 503
    # (InvalidStatus on the websockets side). In a web page: ?token= instead.
    headers = {"X-API-KEY": os.environ["BYTNODE_API_KEY"]}
    async with websockets.connect("wss://api.bytnode.com/ws", additional_headers=headers) as ws:
        await ws.send(json.dumps({
            "op": "subscribe",
            "channels": ["trades.futures.BTCUSDT", "orderbook.futures.BTCUSDT"],
            "depth": 5,
        }))
        print(await ws.recv())

        expected: dict[str, int] = {}

        async for raw in ws:
            msg = json.loads(raw)
            if "op" in msg:  # a control message (error, pong...), not data
                print(msg)
                continue
            channel, seq = msg["channel"], msg["seq"]

            # The first seq of a channel is arbitrary: only a jump between two
            # messages counts. It signals lost messages: the client is not
            # consuming fast enough.
            if channel in expected and seq != expected[channel]:
                print(f"!! {seq - expected[channel]} message(s) lost on {channel}")
            expected[channel] = seq + 1

            if channel.startswith("orderbook."):
                # The book is a consolidated depth centred on avg_mid_price, not one
                # venue's book: use the per-venue average spread to trade.
                spread = msg["data"]["avg_spread_bps"]
                if spread is not None:
                    print(f"{channel} mid={msg['data']['avg_mid_price']} spread={spread:.3f} bps")
            else:
                print(f"{channel} {len(msg['data'])} event(s)")


asyncio.run(main())