DOCS/ACCESS

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.

wss://stream.financedatacloud.com/v1

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 with welcome immediately.
  • 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_..."}

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"}
FieldTypeDefaultDescription
opstringsubscribe.
idintClient request id, echoed in the reply.
channelstringSee Channels.
venuestringVenue id.
symbolstring | listNormalized symbol, or a list of up to 50 (each counts as one subscription). "*" is allowed on low-volume channels.
snapshotbooltrueBook channels: send a full snapshot before the first delta.
resume_fromintReplay every message with seq greater than this value, then continue live. See Reconnect and resume.
levelsintfull depthbook_l2: truncate to the top N levels per side.
conflate_msint0book_l2: merge deltas into one message per 100, 250 or 1,000 ms. 0 sends every venue update.
intervalstringcandles: 1s, 1m, 5m, 1h or 1d.
fieldslistallextended: group keys or column names.

To stop a subscription, send its sub id. {"op": "list"} returns all active subscriptions on the connection.

CLIENT
{"op": "unsubscribe", "id": 2, "sub": "s_7Hq2Lk"}

Channels

ChannelContentMessage types"*"Plan
tradesEvery trade with aggressor sidedatanoDesk
quotesBest bid and offer changesdatanoDesk
candlesClosed candles at the chosen intervaldatayesDesk
book_l2Price-level bookdatanoDesk
book_l3Order events: add, modify, cancel, filldatanoDesk
book_l4L3 with participant iddatanoDesk
fundingPredicted rate changes and settlementsdatayesDesk
open_interestOpen interest updatesdatayesDesk
liquidationsForced closesdatayesDesk
mark_indexMark, index and oracle pricesdatayesDesk
extendedExtended field rows on the 100 ms griddatanoDesk
instrumentsListings, delistings and contract changesdatayesDesk
gapsGap notices as they are recordedgapyesDesk
chain_flowsVenue deposits and withdrawals; use "venue" for the venue and "*" for symboldatayesEnterprise 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.

SERVER · TRADES
{
  "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 }
  ]
}
FieldDescription
typedata, gap, or a control type below. Book snapshots are data messages whose rows have is_snapshot = true.
subSubscription id from the subscribed reply.
seqVenue sequence number. Strictly increasing within a subscription.
prev_seqThe 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_captureVenue and capture timestamps. See Timestamps and clocks.
ts_sendWhen our edge wrote the frame. ts_send − ts_capture is our added latency.
regionCapture region that supplied the message: apne1, euw1 or, from Phase 2, use1.
checksum, checksum_okBook 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:

SERVER
{"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.

01Subscribe with snapshotSend subscribe with snapshot: true (the default).
02Load the snapshotThe first message carries the full book at 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.
03Apply deltasEvery following message has rows with 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.
04VerifyWhere 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.
SERVER · BOOK_L2
{"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.

CLIENT
{"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.

SERVER
{"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 seq on 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

CodeMeaningAction
1000Normal closeNone
1001, 1012Server restart or deployReconnect immediately and resume
4401Authentication failed or not completed within 5 seconds (after an error frame with the code)Check the key
4403Key lacks the live scope, or the request is outside your planUse a key with the scope, or check your plan
4008Slow consumerReconnect, resume, read faster
4029Connection or operation limit exceededSee Rate limits
4010Resume window expired: resume_from is older than the replay bufferBackfill 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.

PYTHON
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.