WebSocket
The live stream delivers the same normalized rows as the REST API, as they are captured. Our target is under 5 ms median added latency from capture to delivery in the same region.
Live streaming is included in the Desk and Enterprise plans and needs a key with the live scope. Test keys can also connect: they replay the sample day (2026-09-15) at recorded speed. All messages are JSON text frames, UTF-8, one message per frame. Decimals are strings and timestamps are integer nanoseconds, as in REST. The Python SDK implements everything on this page; read on if you are writing your own client.
Connect and authenticate
The stream endpoint routes you to the nearest capture region. To pin a region, add ?region=ap-northeast-1 (Tokyo) or ?region=eu-west-1 (Ireland). Region choice matters for latency only; both regions serve every venue.
Authenticate in one of two ways:
- Send
Authorization: Bearer fdc_live_...on the upgrade request. The server replies withwelcomeimmediately. - Connect without the header and send
{"op": "auth", "api_key": "..."}as the first frame, within 5 seconds of connecting. This path is for clients that cannot set upgrade headers. Keep keys out of browser and mobile code; proxy through your own backend instead.
{"op": "auth", "api_key": "fdc_live_..."}{
"type": "welcome",
"session_id": "ws_01JB8A2R7T5V3X1Z9B7D5F3H1K",
"region": "ap-northeast-1",
"plan": "desk",
"schema_version": "0.9",
"limits": { "subscriptions_per_connection": 250, "connections": 10, "ops_per_second": 20 },
"ts": 1789482600000412339
}A failed or late auth produces an error frame with one of the 401 codes from Authentication, followed by a close with code 4401. A key without the live scope, or a request outside your plan, gets an error frame and close code 4403.
Subscribe and unsubscribe
A subscription is one channel on one venue and one symbol. Include an id of your choosing; the server echoes it on the reply so you can match responses to requests.
{"op": "subscribe", "id": 1, "channel": "book_l3", "venue": "hyperliquid", "symbol": "BTC-PERP"}{
"type": "subscribed",
"id": 1,
"sub": "s_7Hq2Lk",
"channel": "book_l3",
"venue": "hyperliquid",
"symbol": "BTC-PERP",
"snapshot": true,
"resume": null
}| Field | Type | Default | Description |
|---|---|---|---|
op | string | subscribe. | |
id | int | Client request id, echoed in the reply. | |
channel | string | See Channels. | |
venue | string | Venue id. | |
symbol | string | list | Normalized symbol, or a list of up to 50 (each counts as one subscription). "*" is allowed on low-volume channels. | |
snapshot | bool | true | Book channels: send a full snapshot before the first delta. |
resume_from | int | Replay every message with seq greater than this value, then continue live. See Reconnect and resume. | |
levels | int | full depth | book_l2: truncate to the top N levels per side. |
conflate_ms | int | 0 | book_l2: merge deltas into one message per 100, 250 or 1,000 ms. 0 sends every venue update. |
interval | string | candles: 1s, 1m, 5m, 1h or 1d. | |
fields | list | all | extended: group keys or column names. |
To stop a subscription, send its sub id. {"op": "list"} returns all active subscriptions on the connection.
{"op": "unsubscribe", "id": 2, "sub": "s_7Hq2Lk"}Channels
| Channel | Content | Message types | "*" | Plan |
|---|---|---|---|---|
trades | Every trade with aggressor side | data | no | Desk |
quotes | Best bid and offer changes | data | no | Desk |
candles | Closed candles at the chosen interval | data | yes | Desk |
book_l2 | Price-level book | data | no | Desk |
book_l3 | Order events: add, modify, cancel, fill | data | no | Desk |
book_l4 | L3 with participant id | data | no | Desk |
funding | Predicted rate changes and settlements | data | yes | Desk |
open_interest | Open interest updates | data | yes | Desk |
liquidations | Forced closes | data | yes | Desk |
mark_index | Mark, index and oracle prices | data | yes | Desk |
extended | Extended field rows on the 100 ms grid | data | no | Desk |
instruments | Listings, delistings and contract changes | data | yes | Desk |
gaps | Gap notices as they are recorded | gap | yes | Desk |
chain_flows | Venue deposits and withdrawals; use "venue" for the venue and "*" for symbol | data | yes | Enterprise or add-on |
Channel names are dataset names, and row contents match the Schema for that dataset, with the common columns moved to the envelope. Venues not yet live return an error with code venue_not_available.
Message format
Every market data message has the same envelope. data is an array of rows; it holds one row on most channels and one row per changed price level on book_l2. Venue identifiers and timestamps live on the envelope and are not repeated in each row.
{
"type": "data",
"sub": "s_2Bv9Qa",
"channel": "trades",
"venue": "hyperliquid",
"symbol": "BTC-PERP",
"seq": 1094102785458213,
"prev_seq": 1094102785458207,
"ts_exchange": 1789482600127000000,
"ts_capture": 1789482600131482113,
"ts_send": 1789482600132510348,
"region": "apne1",
"data": [
{ "price": "104318.5", "size": "0.0421", "side": "buy", "trade_id": "812774190531627",
"notional": "4391.80885", "taker_order_id": "141092377410",
"maker_order_id": "141092371988", "is_block": false }
]
}| Field | Description |
|---|---|
type | data, gap, or a control type below. Book snapshots are data messages whose rows have is_snapshot = true. |
sub | Subscription id from the subscribed reply. |
seq | Venue sequence number. Strictly increasing within a subscription. |
prev_seq | The seq of the previous message we delivered for this subscription. If it does not equal the last seq you processed, you missed a message. |
ts_exchange, ts_capture | Venue and capture timestamps. See Timestamps and clocks. |
ts_send | When our edge wrote the frame. ts_send − ts_capture is our added latency. |
region | Capture region that supplied the message: apne1, euw1 or, from Phase 2, use1. |
checksum, checksum_ok | Book channels: carried in each row, as in Schema. checksum is our CRC32 on snapshot rows; checksum_ok is the result of the venue checksum check at capture, on venues that publish one. |
Control messages are welcome, subscribed, unsubscribed, heartbeat, pong, warning and error. Errors reference the request id when there is one. A malformed client message gets an error frame with code invalid_request and the connection stays open:
{"type": "error", "id": 3, "code": "plan_required", "message": "book_l4 requires the Desk plan or above"}Heartbeats
- The server sends
{"type": "heartbeat", "ts": ...}every 5 seconds on every connection, regardless of traffic. If you receive nothing for 15 seconds, treat the connection as dead and reconnect. - The server sends WebSocket ping frames every 15 seconds. Most libraries answer automatically. A connection that does not answer within 30 seconds is closed.
- To measure round-trip time, send
{"op": "ping", "id": 9}. The reply is{"type": "pong", "id": 9, "ts": ...}.
Starting an order book
Book channels start with a snapshot and continue with deltas. Both arrive as data messages; each row's is_snapshot says which it is. The server takes the snapshot and orders the deltas behind it, so you do not need to buffer deltas or call REST yourself.
subscribe with snapshot: true (the default).seq = S in rows with is_snapshot: true and snapshot_reason: "checkpoint": all levels for L2, all resting orders for L3 and L4. Large books arrive in several frames with the same seq; the final row of the last frame has is_last: true.is_snapshot: false and seq > S. The first delta has prev_seq = S. Apply each delta only if its prev_seq equals the last seq you applied.checksum_ok is not null it should be true; if it is false we resync from a venue snapshot and send it to you (snapshot_reason: "venue"). On a prev_seq break in your own processing, unsubscribe and subscribe again with snapshot: true.{"type": "data", "sub": "s_9Xc1Wd", "channel": "book_l2", "venue": "okx", "symbol": "BTC-PERP",
"seq": 30188241177, "prev_seq": null,
"ts_exchange": 1822348800001000000, "ts_capture": 1822348800003391022, "ts_send": 1822348800004118405,
"data": [ {"side": "bid", "price": "128401.2", "size": "4.81", "is_snapshot": true,
"snapshot_reason": "checkpoint", "is_last": false, "checksum": 3087325760, "checksum_ok": null},
...,
{"side": "ask", "price": "128401.3", "size": "0.92", "is_snapshot": true,
"snapshot_reason": "checkpoint", "is_last": true, "checksum": 3087325760, "checksum_ok": null} ]}
{"type": "data", "sub": "s_9Xc1Wd", "channel": "book_l2", "venue": "okx", "symbol": "BTC-PERP",
"seq": 30188241178, "prev_seq": 30188241177,
"ts_exchange": 1822348800011000000, "ts_capture": 1822348800012906330, "ts_send": 1822348800013554871,
"data": [ {"side": "ask", "price": "128401.3", "size": "0", "is_snapshot": false, "snapshot_reason": null,
"is_last": false, "checksum": null, "checksum_ok": true},
{"side": "ask", "price": "128401.8", "size": "1.4", "is_snapshot": false, "snapshot_reason": null,
"is_last": true, "checksum": null, "checksum_ok": true} ]}In a delta, size is the new total at that level and "0" removes it. L3 and L4 deltas carry one order event per message, in the row format of /v1/book/l3.
Reconnect and resume
Every subscription can be resumed by sequence number. Keep the last seq you processed for each subscription; after a disconnect, reconnect, authenticate and subscribe again with resume_from set to that value. The server replays everything after it from the replay buffer and then continues live, with no duplicates and no missing messages.
{"op": "subscribe", "id": 1, "channel": "book_l3", "venue": "hyperliquid", "symbol": "BTC-PERP",
"snapshot": false, "resume_from": 1094102785458213}The reply confirms the replay with "resume": "ok". For book channels you keep your local book and apply the replayed deltas. The replay buffer holds 24 hours on Desk and 72 hours on Enterprise. If resume_from is older than the buffer, the server sends an error frame with code resume_out_of_range and closes the connection with code 4010; fetch the missing range from REST, then resume from the last seq REST returned.
Reconnect with exponential backoff: start at 500 ms, double on each failure up to 30 seconds, and add up to 25% random jitter. Close code 1001 or 1012 means we are restarting a server; reconnect immediately, without backoff.
Gaps
The stream forwards the Tokyo copy of each message as soon as it arrives. When Tokyo sees a sequence break, the stream holds that symbol for up to 750 ms while it waits for the Ireland copy; recovered messages are delivered in order with region = "euw1". If neither region has them and the venue cannot replay them, you receive a gap message in place of the missing range. For book channels the server then sends a fresh snapshot, because deltas after a gap cannot be applied safely. Every gap is also written to the gaps table; see Data quality.
{"type": "gap", "sub": "s_7Hq2Lk", "gap_id": "gap_hl_20270115_0002", "dataset": "book_l3",
"seq_start": 1102311840017735, "seq_end": 1102311840018102, "reason": "venue_outage", "resync": true}Backpressure
Each connection has a 16 MB send buffer on our side. If you read more slowly than data arrives, the buffer fills:
- Above 8 MB for more than 5 seconds, you receive
{"type": "warning", "code": "slow_consumer", "buffered_bytes": ...}. - At 16 MB the connection is closed with code 4008. Nothing is dropped silently: resume each subscription from its last
seqon a new connection.
To stay ahead, read frames on a dedicated thread or task and hand them to processing through a queue. Spread heavy channels (book_l3, book_l4) across several connections, and use conflate_ms on book_l2 when you do not need every update. A Hyperliquid BTC-PERP L3 subscription peaked at 4,100 messages per second during September 2026.
Close codes
| Code | Meaning | Action |
|---|---|---|
| 1000 | Normal close | None |
| 1001, 1012 | Server restart or deploy | Reconnect immediately and resume |
| 4401 | Authentication failed or not completed within 5 seconds (after an error frame with the code) | Check the key |
| 4403 | Key lacks the live scope, or the request is outside your plan | Use a key with the scope, or check your plan |
| 4008 | Slow consumer | Reconnect, resume, read faster |
| 4029 | Connection or operation limit exceeded | See Rate limits |
| 4010 | Resume window expired: resume_from is older than the replay buffer | Backfill from REST, then resume |
Minimal client
A complete Python client using the websockets library, with resume on reconnect. It prints trades and keeps the last sequence number per subscription.
import asyncio, json, os, random
import websockets
URL = "wss://stream.financedatacloud.com/v1"
KEY = os.environ["FDC_API_KEY"]
SUBS = [{"channel": "trades", "venue": "hyperliquid", "symbol": "BTC-PERP"}]
last_seq = {} # (channel, venue, symbol) -> seq
async def run():
delay = 0.5
while True:
try:
async with websockets.connect(
URL, additional_headers={"Authorization": f"Bearer {KEY}"}
) as ws:
for i, s in enumerate(SUBS):
msg = {"op": "subscribe", "id": i, **s}
key = (s["channel"], s["venue"], s["symbol"])
if key in last_seq:
msg["resume_from"] = last_seq[key]
await ws.send(json.dumps(msg))
delay = 0.5
while True:
m = json.loads(await asyncio.wait_for(ws.recv(), timeout=15))
if m["type"] == "data":
last_seq[(m["channel"], m["venue"], m["symbol"])] = m["seq"]
for row in m["data"]:
print(m["ts_exchange"], row["side"], row["price"], row["size"])
elif m["type"] == "error":
raise RuntimeError(m)
except (websockets.ConnectionClosed, asyncio.TimeoutError, OSError):
await asyncio.sleep(delay * (1 + random.random() * 0.25))
delay = min(delay * 2, 30)
asyncio.run(run())A resume_out_of_range error (close code 4010) raises in this sketch. Production clients should backfill from REST at that point, as the SDK does.