WebSocket protocol
All messages are JSON text frames. The current protocol version is v1. Every server message has a type and a ts (server time in ms). The message reference below shows every message's JSON and explains each field.
Connection lifecycle
- Connect to
/ws(optionally with?subscribe=ch1,ch2). - The server sends
welcome. - You send
subscribe. For each new channel the server replies withsubscribed, then asnapshot. - The server streams
ticker/aggdeltas until you unsubscribe or disconnect.
Client → server
op | Fields | Description |
|---|---|---|
subscribe | channels: string[] | Add channels. Already subscribed channels are ignored. |
unsubscribe | channels: string[] | Remove channels. |
ping | Application-level ping. The server replies with pong. |
{ "op": "subscribe", "channels": ["ticker:SOLUSDT"] }
{ "op": "unsubscribe", "channels": ["ticker:SOLUSDT"] }
{ "op": "ping" }Server → client
type | Description |
|---|---|
welcome | Sent once on connect: version, pairs, exchanges, limits. |
subscribed / unsubscribed | Acknowledges channel changes. |
snapshot | Full current state of one channel, sent right after subscribing (and after a resync). |
ticker | Changed exchange quotes for one pair (real-time). |
agg | Changed cross-exchange aggregates (every second). |
pong | Reply to ping. |
error | code and msg. The connection stays open. |
Timestamps
All timestamps are Unix epoch milliseconds from the server's clock.
| Field | Where | Meaning |
|---|---|---|
ts | every server message | When the server built this message. Use it to measure delivery latency (Date.now() - ts) and to correct for your local clock (offset = welcome.ts - Date.now()). Cached agg:* snapshots can be up to 1 s older than the moment they are sent. |
t | each quote in ticker / snapshot | Receive time: when the quote arrived from the exchange. Unchanged quotes are re-sent at least every 10 s with a fresh t. |
et | each quote, optional | Exchange event time: the timestamp the exchange itself put on the update. t - et is the lag: network latency plus how old the exchange's state already was. Feeds that only emit on trades (Coinbase ticker) can show large lags on illiquid pairs, which is a useful staleness signal. Missing for Binance, Bitfinex and Gate, which don't send one. |
t | each row in agg / snapshot | The newest t among the quotes in that aggregate. |
Message reference
welcome
Sent once, right after you connect.
{
"type": "welcome",
"ts": 1731400000000,
"version": "v1",
"channels": ["agg:*", "ticker:<SYMBOL>"],
"pairs": ["BTCUSDT", "ETHUSDT", "SOLUSDT"],
"exchanges": ["binance", "bybit", "okx"],
"limits": {
"max_subscriptions": 200,
"max_connections_per_ip": 10,
"ticker_interval_ms": 0,
"agg_interval_ms": 1000
},
"docs": "/docs",
"disclaimer": "For informational purposes only. Not investment advice. See /terms"
}| Field | Type | Description |
|---|---|---|
type | string | Always "welcome" |
ts | number | Server time (ms). Compute your clock offset as ts - Date.now(). |
version | string | Protocol version ("v1") |
channels | string[] | Channel patterns you can subscribe to |
pairs | string[] | All published symbols |
exchanges | string[] | Exchange ids that may appear in ex |
limits.max_subscriptions | number | Max channels per connection |
limits.max_connections_per_ip | number | Max WebSocket connections per IP |
limits.ticker_interval_ms | number | 0 = tickers are real-time |
limits.agg_interval_ms | number | How often agg:* is published |
docs / disclaimer | string | Documentation path and usage notice |
subscribed / unsubscribed
Confirms a subscribe or unsubscribe request. It lists only the channels that actually changed.
{ "type": "subscribed", "ts": 1731400000012, "channels": ["ticker:BTCUSDT", "agg:*"] }| Field | Type | Description |
|---|---|---|
type | string | "subscribed" or "unsubscribed" |
ts | number | Server time (ms) |
channels | string[] | Channels added or removed |
snapshot (ticker channel)
The full current state of one pair, sent right after you subscribe and again if your connection falls behind. Replace your local state for that pair.
{
"type": "snapshot",
"ts": 1731400000013,
"channel": "ticker:BTCUSDT",
"s": "BTCUSDT",
"data": [
{ "ex": "binance", "b": 97012.5, "a": 97012.51, "bq": 3.214, "aq": 0.82, "t": 1731400000001 },
{ "ex": "okx", "b": 97011.9, "a": 97012.0, "bq": 0.51, "aq": 1.07, "t": 1731399999987, "et": 1731399999861 }
]
}ticker
Real-time updates for one pair. Each message contains only the exchanges whose quote changed: merge them into your state by ex.
{
"type": "ticker",
"ts": 1731400000140,
"channel": "ticker:BTCUSDT",
"s": "BTCUSDT",
"data": [
{ "ex": "okx", "b": 97012.1, "a": 97012.2, "bq": 0.33, "aq": 2.4, "t": 1731400000139, "et": 1731400000012 }
]
}| Field | Type | Description |
|---|---|---|
type | string | "ticker" (or "snapshot") |
ts | number | Server time (ms) when the message was built |
channel | string | ticker:<SYMBOL> |
s | string | Symbol, e.g. BTCUSDT |
data | array | Quotes, one per exchange (fields below) |
data[].ex | string | Exchange id: binance, bybit, okx, mexc, gateio, kucoin, bitget, coinbase, kraken, bitfinex, upbit, cryptocom |
data[].b | number | Best bid price |
data[].a | number | Best ask price |
data[].bq | number | Size at the best bid (base asset, e.g. BTC) |
data[].aq | number | Size at the best ask (base asset) |
data[].t | number | Receive time: when the quote arrived from the exchange (ms). Unchanged quotes are re-sent at least every 10 s with a fresh t. |
data[].et | number, optional | Exchange event time (ms) as reported by the exchange. t - et ≈ latency. Omitted for Binance, Bitfinex and Gate. |
snapshot (agg:*)
The current summary of all pairs, sent right after you subscribe to agg:*. Replace your local state.
{
"type": "snapshot",
"ts": 1731400000000,
"channel": "agg:*",
"data": [
{ "s": "BTCUSDT", "p": 97012.3, "bb": 97014.0, "bbx": "kraken", "bbq": 0.42, "ba": 97011.8, "bax": "bybit", "baq": 1.37, "sp": -0.0023, "n": 11, "ch": 1.84, "v": 1834455120, "t": 1731399999990 }
]
}agg
Sent every second with only the pairs that changed: merge by s. A row with n: 0 means the pair is currently unavailable, so remove it.
{
"type": "agg",
"ts": 1731400001000,
"channel": "agg:*",
"data": [
{ "s": "ETHUSDT", "p": 3561.2, "bb": 3561.3, "bbx": "okx", "bbq": 4.1, "ba": 3561.1, "bax": "binance", "baq": 2.0, "sp": -0.0056, "n": 12, "ch": 2.1, "v": 912003311, "t": 1731400000990 }
]
}| Field | Type | Description |
|---|---|---|
type | string | "agg" (or "snapshot") |
ts | number | Server time (ms) when the message was built |
channel | string | agg:* |
data[].s | string | Symbol |
data[].p | number | Reference price: median of exchange mid prices |
data[].bb | number | Highest bid across exchanges |
data[].bbx | string | Exchange with the highest bid |
data[].bbq | number | Size at that bid (base asset) |
data[].ba | number | Lowest ask across exchanges |
data[].bax | string | Exchange with the lowest ask |
data[].baq | number | Size at that ask (base asset) |
data[].sp | number | Cross-exchange spread in % of p: (ba - bb) / p × 100. Negative = crossed books. |
data[].n | number | Exchanges with a fresh quote. 0 = pair unavailable, remove it. |
data[].ch | number, optional | 24h change in % (reference exchange) |
data[].v | number, optional | 24h quote volume in USDT (reference exchange) |
data[].t | number | Newest quote time among the included exchanges (ms) |
pong
Reply to {"op":"ping"}.
{ "type": "pong", "ts": 1731400005000 }error
Your request was rejected. The connection stays open.
{ "type": "error", "ts": 1731400000015, "code": "unknown_channel", "msg": "`ticker:NOPEUSDT` is not a published pair" }| Field | Type | Description |
|---|---|---|
code | string | bad_request, unknown_channel or too_many_subscriptions |
msg | string | Human-readable explanation |
Errors
code | Meaning |
|---|---|
bad_request | The message is not valid JSON or has an unknown op. |
unknown_channel | The channel name is malformed or the pair is not published. |
too_many_subscriptions | The per-connection channel limit was reached. |
Connection-level rejections happen before the upgrade, as plain HTTP:
429: too many connections from your IP.503: the server is at capacity. Retry later with backoff.
Heartbeats
The server sends a WebSocket ping frame every 30 s. Browsers and most libraries answer automatically. Connections with no traffic for 90 s are closed. A client that cannot keep up with sending is disconnected.
Reconnecting
Reconnect with exponential backoff and jitter (for example 0.5 s, 1 s, 2 s … capped at 15 s), then re-send your subscriptions. A fresh snapshot follows, so there is no state to repair. @aeon-feed/client does this for you.
Snapshots and resyncs
If your connection falls behind on a channel, the server skips the missed deltas and sends a new snapshot for that channel instead. Always treat a snapshot as replace, and a delta as merge.