New realtime endpoint: Ohio (AWS us-east-2, next to Kalshi) — Binance from Tokyo at ~64.1 ms one-way line latencyNew endpoint: Ohio · ~64.1 ms one-way line

Configure
Low-Latency Trading Solutions
CART
Realtime access

Connect to the realtime feed

Live L2 order book, trades and derivatives feeds over a plain WebSocket. Three messages get you streaming: connect to the port of your market, send a login, send a subscribe. This page has everything a self-serve customer needs — the platform docs stay the field-level reference.

What you need

  1. An API key — created with your first realtime purchase and shown on /account/realtime (one key per account; rotate it there any time).
  2. Access — realtime days are sold per UTC calendar day in the shop, either a whole market (every instrument of it) or single instruments (Kalshi and Polymarket: whole market only). Paid days start on the next UTC day and the remainder of the purchase day is included free: the key validates the moment the order is paid.
  3. The instrument ids you want — numeric, from the master-data export (/api/realtime/instruments?market=<code>, JSON or &format=csv: ids, codes, state, tick and lot sizes — downloaded from your access card, or fetched by any script with your API key as Authorization: Bearer header; see “Instrument master data” below), or from any instrument page under /analyze/instrument/67824 (the id is in the URL and the header chip).
  4. A WebSocket client — any library that speaks RFC 6455 and answers pings by itself (Python websockets, Node ws, websocat for a first smoke test, …).
Coming from the platform docs?

Three things differ for self-serve access: there is no host discovery (/api/v2/marketdata is not available — the endpoints below are static), no master-data API (/api/exchanges, /api/instruments — the “Instrument master data” section below replaces them: the same list from the browser or with your API key as a Bearer header), and authentication is the apiKey query parameter, not an IP whitelist. Login, subscribe, encodings and every message are identical to the protocol reference.

Endpoints — the port selects the market

Each market listens on its own port, the same port at every location that serves it — not every location serves every market, the table lists each combination; there is no market switch inside the protocol and no discovery endpoint. Pick the endpoint nearest to you and the URL of the market you bought there — access is bought per endpoint and market — append your key as ?apiKey=…, and choose the encoding by path: /api/v6 is JSON (text frames, human-readable — start here), /api/v6/sbe is Simple Binary Encoding (binary frames, for low-latency clients — see the SBE docs).

MarketCodeLocationPortJSON endpointSBE endpointSelf-check
Binance USDT-Mbinance_swapLondon14004ws://lon1.cryptostruct.com:14004/api/v6ws://lon1.cryptostruct.com:14004/api/v6/sbe/api/info
Binance Spotbinance_spotLondon14003ws://lon1.cryptostruct.com:14003/api/v6ws://lon1.cryptostruct.com:14003/api/v6/sbe/api/info
OKXokexLondon14008ws://lon1.cryptostruct.com:14008/api/v6ws://lon1.cryptostruct.com:14008/api/v6/sbe/api/info
Binance USDT-Mbinance_swapFrankfurt14004ws://fra1.cryptostruct.com:14004/api/v6ws://fra1.cryptostruct.com:14004/api/v6/sbe/api/info
Binance Spotbinance_spotFrankfurt14003ws://fra1.cryptostruct.com:14003/api/v6ws://fra1.cryptostruct.com:14003/api/v6/sbe/api/info
OKXokexFrankfurt14008ws://fra1.cryptostruct.com:14008/api/v6ws://fra1.cryptostruct.com:14008/api/v6/sbe/api/info
Binance USDT-Mbinance_swapAshburn14004ws://ash1.cryptostruct.com:14004/api/v6ws://ash1.cryptostruct.com:14004/api/v6/sbe/api/info
Binance Spotbinance_spotAshburn14003ws://ash1.cryptostruct.com:14003/api/v6ws://ash1.cryptostruct.com:14003/api/v6/sbe/api/info
OKXokexAshburn14008ws://ash1.cryptostruct.com:14008/api/v6ws://ash1.cryptostruct.com:14008/api/v6/sbe/api/info
Binance USDT-Mbinance_swapOhio14004ws://ohio1.cryptostruct.com:14004/api/v6ws://ohio1.cryptostruct.com:14004/api/v6/sbe/api/info
Binance Spotbinance_spotOhio14003ws://ohio1.cryptostruct.com:14003/api/v6ws://ohio1.cryptostruct.com:14003/api/v6/sbe/api/info
Coinbase SpotcoinbaseOhio14006ws://ohio1.cryptostruct.com:14006/api/v6ws://ohio1.cryptostruct.com:14006/api/v6/sbe/api/info
OKXokexOhio14008ws://ohio1.cryptostruct.com:14008/api/v6ws://ohio1.cryptostruct.com:14008/api/v6/sbe/api/info
Coinbase SpotcoinbaseTokyo14006ws://tyo1.cryptostruct.com:14006/api/v6ws://tyo1.cryptostruct.com:14006/api/v6/sbe/api/info
KalshikalshiTokyo14005ws://tyo1.cryptostruct.com:14005/api/v6ws://tyo1.cryptostruct.com:14005/api/v6/sbe/api/info
PolymarketpolymarketTokyo14007ws://tyo1.cryptostruct.com:14007/api/v6ws://tyo1.cryptostruct.com:14007/api/v6/sbe/api/info

Append ?apiKey=<your key> to the WebSocket endpoints. The self-check is plain HTTP and needs no key.

GET http://<host>:<port>/api/info?all=true answers with the proxy's process id, protocol version, the two endpoints and the capabilities per topic — the same object the login response carries. Capabilities differ per market — Binance USDT-M and OKX: order book, top of book, trades, mark price, index price, funding rate and liquidations; Binance Spot: order book, top of book and trades; Coinbase Spot, Kalshi and Polymarket: order book and trades. A topic a market does not carry is null there and never emits.

SBE clients generate their codecs with sbe-tool from marketdata-sbe-schema.xml in the spec package (schema id 1, version 7); its sample-data/ folder holds every message in both encodings for decoder tests. Wrap each decoder with the blockLength and version from the message header — the feed may encode an older schema version than the XML, newer versions only add optional fields. Prices and quantities are a custom decimal: an unscaled big-endian two's-complement byte array plus an int8 scale, see the SBE docs.

Instrument master data

Subscriptions take numeric instrument ids, so fetch the instrument directory of your market first: every trading or upcoming instrument with id, code, state, lifecycle times, tick and lot size and contract specs — the same list the account page offers as a download. Over HTTP it is GET https://cryptostruct.com/api/realtime/instruments?market=<code> (JSON) or with &format=csv (CSV); <code> is the market code from the endpoint table. The directory is part of your access: authenticate with your realtime API key as the header Authorization: Bearer <key> — the same key as the feed, sent as a header here, never as a URL parameter — and it lists the markets you hold a day for (active or upcoming, any endpoint). Fetch the snapshot once, then poll for changes with &since=<as_of> (below) — about every five minutes is the useful cadence; never per message.

instruments.sh
# Instrument master data for Binance USDT-M (binance_swap) as CSV — the same key as the feed,
# sent as a header (never in the URL). 404 = unknown market, key not accepted or no day
# held for this market; 429/503 = try again after the Retry-After seconds.
curl -sS -H "Authorization: Bearer $CRYPTOSTRUCT_REALTIME_API_KEY" \
  "https://cryptostruct.com/api/realtime/instruments?market=binance_swap&format=csv" -o realtime_instruments_binance_swap.csv
instruments.py
# Instrument master data for Binance USDT-M (binance_swap) — standard library only
import json, os, sys, urllib.error, urllib.request

URL = "https://cryptostruct.com/api/realtime/instruments?market=binance_swap"
KEY = os.environ["CRYPTOSTRUCT_REALTIME_API_KEY"]  # the same key as the WebSocket login

req = urllib.request.Request(URL, headers={"Authorization": f"Bearer {KEY}"})
try:
    with urllib.request.urlopen(req, timeout=30) as res:
        data = json.load(res)
except urllib.error.HTTPError as e:
    # 404 = unknown market, key not accepted or no day held for this market;
    # 429/503 = retry after the Retry-After seconds
    sys.exit(f"master data request failed: HTTP {e.code}")

# as_of is the mirror's cursor — hand it to ?since= to poll for changes later. Large markets
# (prediction venues) answer in pages: has_more / next_cursor -> repeat with &cursor=<next_cursor>.
print(data["market"]["code"], data["count"], "instruments, as_of", data["as_of"], "more pages:", data.get("has_more", False))
for row in data["instruments"][:5]:
    # ticksize / lot_size are decimal STRINGS — keep them as str or Decimal, never float
    # state: listed = scheduled, not yet trading; open / review = trading
    print(row["instrument_id"], row["code"], row["state"], "start", row["start"], "tick", row["ticksize"])
instruments-delta.py
# Instrument directory for Binance USDT-M (binance_swap) — snapshot once, then poll changes
# Standard library only. New contracts, state changes (listed -> open -> unlisted/settled) and
# spec changes arrive as rows with a newer update_ts; upsert them by instrument_id.
import json, os, sys, time, urllib.error, urllib.request

URL = "https://cryptostruct.com/api/realtime/instruments?market=binance_swap"
KEY = os.environ["CRYPTOSTRUCT_REALTIME_API_KEY"]  # the same key as the WebSocket login
POLL_SECONDS = 300  # the directory mirror refreshes about every 5 minutes


def fetch(url):
    req = urllib.request.Request(url, headers={"Authorization": f"Bearer {KEY}"})
    try:
        with urllib.request.urlopen(req, timeout=30) as res:
            return json.load(res)
    except urllib.error.HTTPError as e:
        if e.code in (429, 503):
            time.sleep(int(e.headers.get("Retry-After", "60")))
            return fetch(url)
        # 400 = cursor too old or malformed -> re-fetch the snapshot; 404 = market unknown,
        # key not accepted or no day held for this market
        sys.exit(f"directory request failed: HTTP {e.code}")


instruments = {}
snapshot = fetch(URL)  # listed + open + review by default (&state=all adds paused)
cursor = snapshot["as_of"]  # the FIRST page's as_of is where the delta starts
while True:
    for row in snapshot["instruments"]:
        instruments[row["instrument_id"]] = row
    if not snapshot.get("has_more"):  # large markets (prediction venues) come in pages
        break
    snapshot = fetch(f"{URL}&cursor={snapshot['next_cursor']}")
print("snapshot:", len(instruments), "instruments, as_of", cursor)

while True:
    time.sleep(POLL_SECONDS)
    page = fetch(f"{URL}&since={cursor}")
    while True:
        for row in page["instruments"]:
            before = instruments.get(row["instrument_id"])
            instruments[row["instrument_id"]] = row
            if before is None:
                print("new:", row["instrument_id"], row["code"], row["state"], "start", row["start"])
            elif before["state"] != row["state"]:
                print("state:", row["instrument_id"], row["code"], before["state"], "->", row["state"])
        if not page["has_more"]:
            break
        page = fetch(f"{URL}&since={page['next_cursor']}")
    cursor = page["as_of"]  # continue from the mirror's cursor (inclusive -> duplicates, never gaps)
ColumnTypeMeaning
instrument_idintegerNumeric id — the value you subscribe with ([11, instrument_id]).
codestringExchange symbol, e.g. BTCUSDT.
typestringInstrument class as recorded (perpetual, spot, …).
base_underlyingstring or nullBase asset, e.g. BTC.
counter_underlyingstring or nullQuote asset, e.g. USDT.
statestringLifecycle state: listed (announced, not yet trading), open, review, paused; a delta poll also carries unlisted and settled (finished).
ticksizedecimal string or nullMinimum price increment.
lot_sizedecimal string or nullMinimum quantity increment.
contract_valuedecimal string or nullContract value (derivatives).
multiplierdecimal string or nullContract multiplier.
min_orderdecimal string or nullMinimum order size.
max_orderdecimal string or nullMaximum order size.
exchange_idintegerNumeric id of the market (exchange).
exchange_codestringMarket code — the value of ?market=.
createdISO-8601 UTC or nullWhen the venue created the instrument.
listingISO-8601 UTC or nullWhen it was listed / announced; null where the venue does not publish it.
startISO-8601 UTC or nullTrading-window start (scheduled contracts); null for venues without a fixed window — e.g. Polymarket, where the window is part of the code.
expiryISO-8601 UTC or nullTrading-window end / expiry; null where not fixed (perpetuals, spot).
update_tsISO-8601 UTCLast change of this row in the master data — the delta cursor (?since=).
slugstring or nullVenue market slug where one exists (Polymarket); null elsewhere.

JSON snapshot: `{market: {exchange_id, code, name}, generated_at, as_of, count, instruments: [...]}` with these fields per row (a page of a large market adds `total`, `has_more`, `next_cursor`); delta (`since=`): the same plus `since`, `has_more`, `next_cursor`. CSV: one header line, these columns in this order.

  • Parameters: market=<code> (required); state= narrows the snapshot to a comma list of listed, open, review, paused (default listed,open,review; all adds paused); since=<cursor> switches to the delta — every row of the market whose update_ts is at or after the cursor, in **every** state, ordered by update_ts; limit= (default 1000, max 5000) pages the delta.
  • Large markets come in pages: a snapshot with more than 10,000 rows (the prediction venues list hundreds of thousands of contracts) answers one page at a time, ordered by state, then instrument_id, with total, has_more and an opaque next_cursor — repeat the request with &cursor=<next_cursor> until has_more is false (CSV: the response headers X-Has-More and X-Next-Cursor, plus Link: rel="next"). Keep the as_of of the FIRST page as your delta start. Smaller markets answer in one piece without these fields.
  • Lifecycle: listed = announced, not yet trading (scheduled contracts such as 15-minute or hourly up/down markets appear here hours before their window); open / review = trading; paused = halted; unlisted / settled = finished — the last two only ever appear in a delta, as the transition out of your book. start / expiry are the trading window where the venue fixes one; venues without a fixed window (Polymarket) carry it in code and slug and leave them null.
  • Cursor rules: as_of is the mirror’s sync cursor at answer time — store it and pass it as the next since. The cursor is inclusive, so the boundary row comes back once more — upsert by instrument_id. While has_more is true, continue with since=<next_cursor>; when it is false, your next poll starts from the latest as_of. A cursor older than 30 days answers 400 — re-fetch the snapshot and continue from its as_of.
  • Freshness: the directory mirrors the venues’ master data and refreshes about every five minutes; the venues themselves publish new instruments on their own schedule (check the current docs of your venue for listing times). instrument_id is the value you subscribe with, stable for the instrument’s lifetime.
  • Decimal columns (ticksize, lot_size, contract_value, multiplier, min_order, max_order) are strings — keep them as strings or a decimal type, never floats; absent values are null (JSON) or empty (CSV). Timestamps are ISO-8601 UTC at second precision. Use JSON for polling — the CSV form carries no has_more.
  • Responses: 200 with the list; 400 for a malformed state, since or limit; 404 when the market code is unknown, the key is not accepted OR you hold no day for that market (uniform on purpose — check all three); 429 and 503 with a Retry-After header — retry after that many seconds (the directory budget is 120 calls per minute per account).
  • Answers are Cache-Control: private — keep them to your own systems; do not proxy them to other accounts.

Quick start in three messages

Nothing flows after the WebSocket handshake by itself. Every session is the same sequence, and every message in both directions is a JSON array whose first element is a numeric type tag:

  1. Connect to your market's endpoint, e.g. ws://lon1.cryptostruct.com:14004/api/v6?apiKey=YOUR_API_KEY.
  2. Login — send [13, "my-org", "my-client", "1.0.0", "client-01"] and read the [14, …] response with the capabilities.
  3. Subscribe — send [11, 67824] per instrument. The server answers with a [5, …, "READY", ""] state event and the current state (book snapshot, last trade; top of book and mark/index/funding where the market has them), then streams live updates.
  4. Keep the connection open — the server pings every 5 s; every WebSocket library answers with a pong automatically. Subscribe to more instruments at any time — all of them on this one connection if you like.

Login (type 13 → 14)

The first message after connecting. The four strings identify your organization, application, its version and this process instance — they are free-form, only used for logging, and may even be empty. Authentication already happened through the apiKey in the URL.

login → response
[13, "my-org", "my-client", "1.0.0", "client-01"]

[14, "public-proxy-ldn-a", "3.12.1-rc", "6", {
  "depthTopic":    {"eventIdType": "ORDERED", "exchangeTimestampType": "MATCHING_ENGINE",
                    "exchangeTimestampPrecision": "MILLIS", "disabled": false},
  "topOfBookTopic": {…}, "tradesTopic": {…}, "markPriceTopic": {…}, "indexPriceTopic": {…},
  "fundingRateTopic": {…}, "liquidationsTopic": {…},
  "crossTopicBookEventId": true, "predictedFundingRate": true, "continuousFunding": false
}]
  • A subscribe before the login is answered with the text frame not logged in and the connection is closed; a second login gets already logged in.
  • There is no logout — reconnect to change the identifiers.
  • SBE clients: announce schema version 5 or newer, older schemas get no liquidationsTopic capability.

Subscribe and unsubscribe (types 11 / 12)

Subscriptions are per instrument and reference the numeric instrument id, not the symbol. Without the optional third element the default topics apply: depth, top of book, trades, index price, mark price and funding rate on; liquidations off.

subscribe variants
[11, 67824]

[11, 67824, {"depthTopic": false, "topOfBookTopic": true, "tradesTopic": true,
             "markPriceTopic": true, "indexPriceTopic": true, "fundingRateTopic": true,
             "liquidationsTopic": true, "topOfBookCoalescing": false}]

[12, 67824]
  • One connection carries as many subscriptions as you like: with a whole-market day, subscribe every instrument of the master-data export on that single connection — there is no per-connection subscription cap. The connection limit (below) counts parallel sockets, not instruments.
  • topOfBookCoalescing: true delivers only the latest top-of-book per flush instead of every change — fewer messages for slow consumers.
  • After every subscribe the server sends [5, id, ts, "READY", ""] and the current state (snapshot, top of book, last trade, mark/index/funding where available), then live updates. An instrument cannot be subscribed twice on one connection.
  • A whole-market day unlocks every instrument of that market; an instrument day unlocks exactly the bought ids. Anything else — an unknown id, an instrument of another market, an id you did not buy — does not drop the connection: you get a state event [5, id, ts, "ERROR", "…"] with instrument not available on this endpoint or instrument not permitted for this api key.
  • Unsubscribing ([12, id]) is acknowledged with a state event instrument unsubscribed.
Best practice: one connection per host

Take all your instruments on one connection. Racing several connections to the same host against each other (feed arbitrage) gains nothing: they are served by the same host from the same feed, so no copy arrives earlier, and the duplicate traffic tends to slow your connection down rather than speed it up. That optimization is already done for you: we run feed arbitrage internally, before the data reaches your endpoint, so every connection delivers the fastest copy we have. Use further connections for independent processes only.

Messages you receive

Every market-data event shares one header: [type, instrumentId, "prevEventId", "eventId", adapterTimestampNs, exchangeTimestampNs, …payload]. Timestamps are nanoseconds since the Unix epoch (0 = not available); adapterTimestamp is when our adapter received the event, exchangeTimestamp is the venue's own clock where it has one. Prices and quantities are decimal strings — keep them as strings or parse with a decimal type, never a float. Event ids are strings too; prevEventId chains an update to its predecessor — an event whose prevEventId does not equal the last eventId you saw means you missed data: resubscribe. Compare ids for equality only: on markets whose capabilities report "eventIdType": "UNORDERED" they are opaque values, not a sequence — never sort or subtract them. The state event (type 5) is the one exception with its own, shorter header.

TagMessageDirectionPayload after the headerExample (live, BTCUSDT perp)
13Loginclient → server[13, organization, appName, version, processId] — four free-form strings, logging only[13, "my-org", "my-client", "1.0.0", "client-01"]
14Login responseserver → client[14, service, serviceVersion, protocolVersion, {capabilities}][14, "public-proxy-ldn-a", "3.12.1-rc", "6", {"depthTopic": {…}, "tradesTopic": {…}, …}]
11Subscribeclient → server[11, instrumentId] or [11, instrumentId, {topic flags}][11, 67824]
12Unsubscribeclient → server[12, instrumentId][12, 67824]
0Book snapshotserver → clientheader + [[side, "price", "qty", orderCount], …] (full depth), trailing forceReset flag[0, 67824, "0", "11416095364323", …, [[0, "77979.1", "5.221", 1], [0, "77979", "0.39", 1], …]]
1Book updateserver → clientheader + [[side, "price", "qty", orderCount], …] — qty "0" deletes the level[1, 67824, "11416095364323", "11416095366135", …, [[0, "77907.2", "5.772", 1], …]]
2Tradesserver → clientheader + [[side, "price", "qty", "tradeId", timeNs], …][2, 67824, …, [[1, "77979.1", "0.012", "8030538547", 1787937748600000000]]]
5Instrument stateserver → client[5, instrumentId, timestampNs, "READY" | "ERROR", "message"] — own header[5, 67824, 1787937748548526562, "READY", ""]
6Top of bookserver → clientheader + [[0, "bidPrice", "bidQty", count], [1, "askPrice", "askQty", count]][6, 67824, …, [[0, "77979.1", "5.221", 1], [1, "77979.2", "1.481", 1]]]
7Mark priceserver → clientheader + "price"[7, 67824, …, "77979.1"]
8Index priceserver → clientheader + "price"[8, 67824, …, "78007.70152174"]
9Funding rateserver → clientheader + ["currentRate", nextFundingTimeNs, "predictedRate"][9, 67824, …, ["0.00005913", 1787961600000000000, "0.00008026"]]
17Liquidationsserver → clientheader + [[side, "price", "qty", timeNs], …] — off by default, opt in per subscribe[17, 67824, …, [[1, "77812.4", "0.31", 1787937750123456789]]]

Order-book levels are [side, "price", "quantity", orderCount] with side 0 = bid, 1 = ask. Ignore type tags you do not know — new message types may be added.

  • Book snapshot (0) carries the full depth. Rebuild your local book from it; a trailing forceReset: true means the venue restarted its feed — discard what you had first.
  • Book update (1) carries changed levels only; quantity "0" deletes the level. Apply updates in the order they arrive.
  • Trades (2) are [side, "price", "quantity", "tradeId", timeNs] with side 0 = buy (taker bought), 1 = sell.
  • Funding (9) is ["currentRate", nextFundingTimeNs, "predictedRate"] — the current period's rate, when it settles, and the venue's prediction for the next one; venues republish it periodically, it is not a payment event.
  • Liquidations (17) only arrive when subscribed with "liquidationsTopic": true and only for derivatives instruments (on a market that spans several instrument types, never for its spot instruments).

Prediction markets (Kalshi and Polymarket)

Kalshi and Polymarket are sold as whole markets only — a market day unlocks every contract of the venue (Kalshi on port 14005 and Polymarket on port 14007). Their contracts list and settle around the clock: a 15-minute contract is over before a paid UTC day begins, so there is nothing to buy per contract — you subscribe to the contracts you trade, one [11, id] each, all on one connection.

  • Prices are probabilities between 0 and 1 as decimal strings; quantities are contracts (Kalshi) or shares (Polymarket).
  • Kalshi: the book is the YES book — bids are YES bids, asks are the NO bids shown as YES asks at 1 − price. Every trade carries the YES price; side 0 = the taker bought YES, 1 = the taker bought NO.
  • Polymarket: one instrument per binary market; the book is the first outcome’s (yes, up, or the alphabetically first). Trades on the other outcome are converted to it — price 1 − p, side flipped — so one book and one tape describe the whole market.
  • Find the ids in the instrument directory (above): it is large on these markets and comes in pages (has_more, next_cursor, repeat with &cursor=); keep the as_of of the first page and follow &since=<as_of> every five minutes — new contracts appear as listed hours ahead, subscribe once they turn open, and settled ones leave through the delta. Filter by code (Kalshi series ticker, Polymarket slug) for the series you trade.
  • Topics: order-book depth and trades — these markets have no separate top-of-book stream and no mark, index, funding or liquidation topics. Event ids are opaque here ("eventIdType": "UNORDERED"): chain by equality only.

The script below follows one series of Kalshi (KXBTC15M-*) at the Tokyo endpoint: it pages the directory once, subscribes every open contract of the series on one connection, then polls the delta and subscribes each new window as it opens.

follow-series.py
# CryptoStruct realtime feed — follow the KXBTC15M-* contracts of Kalshi (kalshi) on ONE connection
# pip install websockets   (the directory calls are standard library)
import asyncio, json, urllib.parse, urllib.request, websockets

URL = "ws://tyo1.cryptostruct.com:14005/api/v6?apiKey=YOUR_API_KEY"
DIRECTORY = "https://cryptostruct.com/api/realtime/instruments?market=kalshi"  # instrument directory of kalshi, JSON
PREFIX = "KXBTC15M-"  # the series to follow — change to yours
KEY = urllib.parse.parse_qs(urllib.parse.urlparse(URL).query)["apiKey"][0]
TOPICS = {"depthTopic": True, "tradesTopic": True}  # no separate top-of-book stream on this market


def fetch(url):
    req = urllib.request.Request(url, headers={"Authorization": f"Bearer {KEY}"})
    with urllib.request.urlopen(req, timeout=60) as res:
        return json.load(res)


def snapshot():
    # The directory of a prediction market is large: it comes in pages (has_more /
    # next_cursor). Keep the as_of of the FIRST page — it is where the delta starts —
    # and only the rows of your series.
    page = fetch(DIRECTORY)
    as_of, rows = page["as_of"], []
    while True:
        rows += [r for r in page["instruments"] if r["code"].startswith(PREFIX)]
        if not page.get("has_more"):
            return rows, as_of
        page = fetch(f"{DIRECTORY}&cursor={page['next_cursor']}")


def delta(cursor):
    # Every change since the cursor (new contracts, listed -> open -> settled), any state.
    page, rows = fetch(f"{DIRECTORY}&since={cursor}"), []
    while True:
        rows += page["instruments"]
        if not page["has_more"]:
            return rows, page["as_of"]
        page = fetch(f"{DIRECTORY}&since={page['next_cursor']}")


def wanted(row):
    # Subscribe a contract once it is open (listed = announced, not yet trading).
    return row["code"].startswith(PREFIX) and row["state"] in ("open", "review")


async def recv(ws):
    raw = await ws.recv()
    if not isinstance(raw, str) or not raw.startswith("["):
        raise RuntimeError(f"server: {raw!r}")  # plain-text notice right before a close
    return json.loads(raw)


async def follow(ws, codes, cursor):
    # Windows roll every few minutes: poll the delta, subscribe what just opened.
    while True:
        await asyncio.sleep(300)
        rows, cursor = await asyncio.to_thread(delta, cursor)
        for row in rows:
            if wanted(row) and row["instrument_id"] not in codes:
                codes[row["instrument_id"]] = row["code"]
                await ws.send(json.dumps([11, row["instrument_id"], TOPICS]))
                print("subscribed", row["code"])


async def stream(ws):
    rows, cursor = await asyncio.to_thread(snapshot)
    codes = {r["instrument_id"]: r["code"] for r in rows if wanted(r)}
    await ws.send(json.dumps([13, "my-org", "my-client", "1.0.0", "client-01"]))
    await recv(ws)  # [14, ...] login response
    for iid in codes:  # every contract on this one connection — no per-connection cap
        await ws.send(json.dumps([11, iid, TOPICS]))
    print("subscribed", len(codes), "open", PREFIX, "contracts")
    asyncio.create_task(follow(ws, codes, cursor))
    while True:
        msg = await recv(ws)
        typ, name = msg[0], codes.get(msg[1], msg[1])
        if typ == 2:  # trades: [[side, price, qty, tradeId, timeNs], ...] — price = probability 0..1
            for side, px, qty, _tid, _ts in msg[6]:
                print(f"trade {name}: {'SELL' if side else 'BUY '} {qty} @ {px}")
        elif typ == 0:  # book snapshot (then updates, type 1, chained by prevEventId == last eventId)
            print(f"book {name}: {len(msg[6])} levels")
        elif typ == 5:  # state: READY / ERROR / settled contracts end here
            print(f"state {name}: {msg[3]} {msg[4]}")


async def main():
    try:
        async with websockets.connect(URL, max_size=None) as ws:
            await stream(ws)
    except websockets.exceptions.InvalidHandshake as e:
        # HTTP 401 "api key rejected: <reason>" — unknown key, or no paid day for this market today
        print("handshake rejected (check the key, the port = market, and your paid days):", e)


asyncio.run(main())

OKX: one market, every instrument type

OKX is one market on port 14008 at the London, Frankfurt, Ashburn and Ohio endpoints: spot, perpetual swaps, dated futures and options on one connection, one API key and one market day (okex in the instrument directory and every API). Single OKX instruments: spot and perpetual swaps — dated futures and options come with the whole market.

  • Instrument codes are OKX's own: BTC-USDT (spot), BTC-USDT-SWAP (perpetual swap), BTC-USD-<yymmdd> (dated future) and BTC-USD-<yymmdd>-<strike>-C / -P (options). The directory's type column (spot, perpetual, future, call, put) tells them apart; subscribe by instrument_id as everywhere.
  • Topics follow the instrument type: spot carries the order book, top of book and trades; derivatives add mark price, index price and liquidations, perpetual swaps also the funding rate — a topic a type does not carry never emits for it.
  • Event ids: the order book and top of book follow OKX's own sequence ("eventIdType": "ORDERED"), trade ids are opaque ("eventIdType": "UNORDERED") — chain both by equality only, in arrival order.

Keep-alive, errors and reconnecting

  • Heartbeat. The server sends a WebSocket ping every 5 s and closes the connection if no pong arrives within 30 s (pong check failed). Standard libraries answer pings automatically — you only have to keep reading from the socket.
  • Connection-level notices arrive as a plain-text frame (not a JSON array) right before the close — a running session only, rejected keys never get that far (see below): api key expired or revoked, not logged in, already logged in, protocol violation: <detail>, pong check failed, service shutdown. Log them — they tell you why.
  • Key rejected at the handshake. An unknown key, or a key without a paid day for the market behind that port, never gets a WebSocket: the upgrade request is answered with HTTP 401 (text/plain) and the body api key rejected: <reason>, where <reason> is one of unknown api key, no active realtime subscription for this market today (UTC), market not configured, invalid request. Your library reports it as a failed connection (Python websockets: InvalidHandshake; Node ws: the unexpected-response event carries status and body); curl -i with the Upgrade headers shows the text. The second reason is the common case: you have no paid day for this market today (UTC) — check the port you connected to and your subscriptions on /account/realtime.
  • Instrument-level errors come as state events [5, id, ts, "ERROR", "…"] and never drop the connection: instrument not available on this endpoint, instrument not permitted for this api key, instrument permission expired or revoked, instrument unsubscribed.
  • Connection limit. Each key may hold a limited number of parallel connections per endpoint, shared across the markets served there (shown on your access card, 3 by default; more can be added from /account/realtime in packs of 5 per day); further handshakes are refused. The limit is applied when you connect — a change takes effect on your next connection, running connections are not cut. It counts sockets, not subscriptions: a single connection can hold the whole market — buy extra connections for independent processes, not for more instruments and not for a faster feed. Racing several connections to the same host against each other (feed arbitrage) gains nothing: they are served by the same host from the same feed, so no copy arrives earlier, and the duplicate traffic tends to slow your connection down rather than speed it up. That optimization is already done for you: we run feed arbitrage internally, before the data reaches your endpoint, so every connection delivers the fastest copy we have. Use further connections for independent processes only.
  • Reconnect is simple: connect, login, subscribe again. Every subscribe starts with a fresh snapshot, so no state is lost — do not try to resume by event id.
  • When changes take effect. Days and instruments you buy apply at your next login (reconnect) — access starts the moment you pay (the rest of the purchase day is free, paid days begin at the next 00:00 UTC); a rotated key or a refund is enforced at the next login and by the proxy's periodic re-check (about hourly). A day ends at 00:00 UTC — extend before that to stay connected across midnight.

Complete examples

All three log in, subscribe to BTCUSDT (instrument 67824) on Binance USDT-M and print top of book, trades and state events. Replace YOUR_API_KEY with the key from /account/realtime — or copy the ready-made script from the access card there, which already carries your key and one of your instruments.

Python (websockets)

quickstart.py
# CryptoStruct realtime feed — BTCUSDT (instrument 67824) on Binance USDT-M
# pip install websockets
import asyncio, json, websockets

URL = "ws://lon1.cryptostruct.com:14004/api/v6?apiKey=YOUR_API_KEY"
INSTRUMENT = 67824  # BTCUSDT


async def recv(ws):
    raw = await ws.recv()
    if not isinstance(raw, str) or not raw.startswith("["):
        # plain-text server notice sent right before a close (e.g. the key was revoked)
        raise RuntimeError(f"server: {raw!r}")
    return json.loads(raw)


async def stream(ws):
    # 1) Login — the four strings are free-form identifiers (logging only)
    await ws.send(json.dumps([13, "my-org", "my-client", "1.0.0", "client-01"]))
    login = await recv(ws)
    print("logged in:", login[1], "capabilities:", sorted(login[4]))

    # 2) Subscribe — default topics: depth, top of book, trades, mark/index/funding
    await ws.send(json.dumps([11, INSTRUMENT]))

    # 3) Read events — the library answers the server's pings by itself
    while True:
        msg = await recv(ws)
        typ, instr = msg[0], msg[1]
        if typ == 6:  # top of book: [[side, price, qty, count], ...] (0 = bid, 1 = ask)
            bid, ask = msg[6][0], msg[6][1]
            print(f"TOB {instr}: bid {bid[1]} x {bid[2]} | ask {ask[1]} x {ask[2]}")
        elif typ == 2:  # trades: [[side, price, qty, tradeId, timeNs], ...]
            for side, px, qty, _tid, _ts in msg[6]:
                print(f"trade {instr}: {'SELL' if side else 'BUY '} {qty} @ {px}")
        elif typ == 0:  # full book snapshot
            print(f"snapshot {instr}: {len(msg[6])} levels")
        elif typ == 5:  # instrument state: READY / ERROR
            print(f"state {instr}: {msg[3]} {msg[4]}")


async def main():
    try:
        async with websockets.connect(URL, max_size=None) as ws:
            await stream(ws)
    except websockets.exceptions.InvalidHandshake as e:
        # HTTP 401 "api key rejected: <reason>" — unknown key, or no paid day for this market today
        print("handshake rejected (check the key, the port = market, and your paid days):", e)


asyncio.run(main())

Node.js (ws)

quickstart.mjs
// CryptoStruct realtime feed — BTCUSDT (instrument 67824) on Binance USDT-M
// npm install ws
import WebSocket from 'ws';

const URL = 'ws://lon1.cryptostruct.com:14004/api/v6?apiKey=YOUR_API_KEY';
const INSTRUMENT = 67824; // BTCUSDT

// `ws` answers the server's pings by itself (5 s cadence, 30 s tolerance).
const ws = new WebSocket(URL);

ws.on('open', () => {
  // 1) Login — the four strings are free-form identifiers (logging only)
  ws.send(JSON.stringify([13, 'my-org', 'my-client', '1.0.0', 'client-01']));
});

ws.on('unexpected-response', (_req, res) => {
  // HTTP 401 "api key rejected: <reason>" — unknown key, or no paid day for this market today
  let body = '';
  res.on('data', (chunk) => {
    body += chunk;
  });
  res.on('end', () => console.error('handshake rejected', res.statusCode, body));
});

ws.on('message', (data) => {
  const text = data.toString();
  if (!text.startsWith('[')) {
    // plain-text server notice sent right before a close (e.g. the key was revoked)
    console.error('server:', text);
    return;
  }
  const msg = JSON.parse(text);
  const [type, instr] = msg;
  if (type === 14) {
    console.log('logged in:', msg[1], 'capabilities:', Object.keys(msg[4]));
    // 2) Subscribe — default topics: depth, top of book, trades, mark/index/funding
    ws.send(JSON.stringify([11, INSTRUMENT]));
  } else if (type === 6) {
    // top of book: [[side, price, qty, count], ...] (0 = bid, 1 = ask)
    const [bid, ask] = msg[6];
    console.log(`TOB ${instr}: bid ${bid[1]} x ${bid[2]} | ask ${ask[1]} x ${ask[2]}`);
  } else if (type === 2) {
    // trades: [[side, price, qty, tradeId, timeNs], ...]
    for (const [side, px, qty] of msg[6]) {
      console.log(`trade ${instr}: ${side ? 'SELL' : 'BUY '} ${qty} @ ${px}`);
    }
  } else if (type === 5) {
    console.log(`state ${instr}: ${msg[3]} ${msg[4]}`);
  }
});

ws.on('close', (code, reason) => console.log('closed', code, reason.toString()));

Python — every instrument of the market on one connection

With a whole-market day you do not need one connection per instrument: fetch the master data once, then send one subscribe per id on the same socket — there is no per-connection subscription cap, the connection limit counts sockets. This script does exactly that for Binance USDT-M (top of book and trades with coalescing; drop the topic options for full depth). On instrument days the ids you did not buy answer with an ERROR state and the session stays up.

whole-market.py
# CryptoStruct realtime feed — EVERY instrument of Binance USDT-M (binance_swap) on ONE connection
# pip install websockets   (the master-data call is standard library)
import asyncio, json, urllib.parse, urllib.request, websockets

URL = "ws://lon1.cryptostruct.com:14004/api/v6?apiKey=YOUR_API_KEY"
INSTRUMENTS_URL = "https://cryptostruct.com/api/realtime/instruments?market=binance_swap"  # master data of binance_swap, JSON
# The same key as the feed; on the HTTP route it travels as a header, never in the URL
KEY = urllib.parse.parse_qs(urllib.parse.urlparse(URL).query)["apiKey"][0]


def instrument_ids():
    # At start-up (then poll ?since=<as_of> every 5 minutes for changes — see the spec's
    # master-data section; never per message): every trading or upcoming instrument
    req = urllib.request.Request(INSTRUMENTS_URL, headers={"Authorization": f"Bearer {KEY}"})
    with urllib.request.urlopen(req, timeout=30) as res:
        data = json.load(res)
    return {row["instrument_id"]: row["code"] for row in data["instruments"]}


async def recv(ws):
    raw = await ws.recv()
    if not isinstance(raw, str) or not raw.startswith("["):
        # plain-text server notice sent right before a close (e.g. the key was revoked)
        raise RuntimeError(f"server: {raw!r}")
    return json.loads(raw)


async def stream(ws, codes):
    # 1) Login — the four strings are free-form identifiers (logging only)
    await ws.send(json.dumps([13, "my-org", "my-client", "1.0.0", "client-01"]))
    login = await recv(ws)
    print("logged in:", login[1], "- subscribing", len(codes), "instruments on this one connection")

    # 2) Subscribe — one message per instrument, ALL on this connection: there is no
    #    per-connection subscription cap (the connection limit counts sockets). Top of
    #    book + trades with coalescing keep the fan-in sane; drop the options for full depth.
    topics = {"depthTopic": False, "topOfBookTopic": True, "tradesTopic": True, "topOfBookCoalescing": True}
    for iid in codes:
        await ws.send(json.dumps([11, iid, topics]))

    # 3) Read events — the library answers the server's pings by itself
    ready = 0
    while True:
        msg = await recv(ws)
        typ, instr = msg[0], msg[1]
        name = codes.get(instr, instr)
        if typ == 5:  # instrument state: READY / ERROR (an unbought id errors, the session stays up)
            if msg[3] == "ERROR":
                print(f"state {name}: ERROR {msg[4]}")
            else:
                ready += 1
                if ready % 100 == 0 or ready == len(codes):
                    print(f"ready: {ready}/{len(codes)}")
        elif typ == 6:  # top of book: [[side, price, qty, count], ...] (0 = bid, 1 = ask)
            bid, ask = msg[6][0], msg[6][1]
            print(f"TOB {name}: bid {bid[1]} x {bid[2]} | ask {ask[1]} x {ask[2]}")
        elif typ == 2:  # trades: [[side, price, qty, tradeId, timeNs], ...]
            for side, px, qty, _tid, _ts in msg[6]:
                print(f"trade {name}: {'SELL' if side else 'BUY '} {qty} @ {px}")


async def main():
    codes = instrument_ids()
    try:
        async with websockets.connect(URL, max_size=None) as ws:
            await stream(ws, codes)
    except websockets.exceptions.InvalidHandshake as e:
        # HTTP 401 "api key rejected: <reason>" — unknown key, or no paid day for this market today
        print("handshake rejected (check the key, the port = market, and your paid days):", e)


asyncio.run(main())

Smoke test without code (websocat)

websocat
# websocat (https://github.com/vi/websocat) — connect, then paste the two lines
websocat -t 'ws://lon1.cryptostruct.com:14004/api/v6?apiKey=YOUR_API_KEY'
[13,"my-org","websocat","1.0.0","smoke"]
[11,67824]

For coding agents

This guide is also served as one self-contained markdown document at https://cryptostruct.com/docs/realtime.md — hand that URL to Claude Code, Cursor or any coding agent and let it implement the feed for you. Copy the prompt below, or the personalized one under “Connect your system” on /account/realtime (your endpoint, market, the days and instruments you hold — shown in full before you copy it). Put your key into the agent's environment as CRYPTOSTRUCT_REALTIME_API_KEY — the same card copies the export line — never into the prompt.

prompt for your coding agent
Implement a client for the CryptoStruct realtime market-data feed (WebSocket, JSON encoding).

Read the spec completely before writing code — it is self-contained:
https://cryptostruct.com/docs/realtime.md

My setup:
- Market: Binance USDT-M (`binance_swap`) via the London endpoint, port 14004. The port selects the market — connect to ws://lon1.cryptostruct.com:14004/api/v6?apiKey=<key>
- The transport is plain ws:// — the endpoint has no TLS, so do not switch to a TLS scheme.
- Start with instrument 67824 (BTCUSDT).
- My API key is in the environment variable CRYPTOSTRUCT_REALTIME_API_KEY. Read it from there; never hardcode, print, log or commit it, and keep the full URL (it contains the key) out of logs and error messages.

Where to look:
- https://cryptostruct.com/docs/realtime.md — the connection guide as one markdown file: endpoints, login and subscribe, every message type, keep-alive and reconnect rules, Python and Node examples, and the "For coding agents" implementation checklist. This is your primary source. If it answers 404, request it with the HTTP header Authorization: Bearer <key>.
- https://cryptostruct.com/api/realtime/instruments?market=binance_swap&format=csv — the instrument directory of this market: numeric instrument ids, codes, state, lifecycle times, tick and lot sizes (decimal strings); drop format=csv for JSON. GET it once at start-up with the HTTP header Authorization: Bearer <key> (the same key, taken from the environment variable; never put it into that URL). The answer carries as_of; poll the same URL with &since=<as_of> every 5 minutes to learn new instruments and state changes (listed = scheduled, not yet trading; the spec's master-data section has the loop).
- http://lon1.cryptostruct.com:14004/api/info?all=true — self-check of the endpoint, no key needed: process id, endpoints and topic capabilities. Call it first to confirm this machine reaches the host.
- https://docs.cryptostruct.com/market-data-api/protocol/ — field-level protocol reference for every message. It describes CryptoStruct's full platform; host discovery (/api/v2/marketdata), the master-data API (/api/exchanges, /api/instruments) and IP whitelisting do not apply to my access — use the URL, the master-data export and the apiKey parameter above instead.
- Only if I ask for the binary encoding: https://docs.cryptostruct.com/market-data-api/sbe/ and the spec package https://cryptostruct.com/docs/marketdata-spec-package.zip (SBE schema XML for sbe-tool, sample messages).

What I want:
1. connect → login [13, "<org>", "<client-name>", "<client-version>", "<client-id>"] → wait for [14, …] → subscribe [11, 67824] → process events (book snapshot 0 / update 1, trades 2, state 5; top of book 6, mark 7, index 8 and funding 9 where the market has them — the login response lists its topics; liquidations 17 only if I ask for them).
   The four login fields are placeholders — they identify my organisation and this client instance to the server; see the spec for their meaning and format. Ask me for my values before implementing the login; do not invent them.
2. Keep prices, quantities, event ids and trade ids as decimal strings — never floats. Apply book updates in the order they arrive; each update's prevEventId must equal the eventId before it (compare for equality only — on some markets the ids are opaque, never sort or subtract them). On a mismatch or a snapshot with forceReset, resubscribe and rebuild the book from the fresh snapshot.
3. A text frame that does not start with "[" is a server notice — log it verbatim, then reconnect with exponential backoff (connect, login, subscribe again). An HTTP 401 at the WebSocket upgrade means my key is unknown or I have not bought access for this market for today (UTC) — show the response body and stop; do not retry in a loop.
4. If I have not bought the next day, expect the notice `api key expired or revoked` followed by a close at 00:00 UTC — treat that as a normal stop, not as a crash loop.
5. Connections: use one connection per host and put all instruments on it — multiple connections to the same host do not make the feed any faster.

Before you start, ask me what the workspace does not answer:
- the language and project the client belongs in,
- what it should do with the events (print, store, forward),
- the values for the four login fields (organisation, client name, client version, client id).
Ask all of this in one go, then wait for my answers.
Follow the "For coding agents" checklist in the spec. Use a standard WebSocket library for my language (Python `websockets` and Node `ws` are known to work) and ask me before adding other dependencies.
Keep the key out of the prompt

Prompts end up in agent transcripts, logs and sometimes in commits. The prompt names the environment variable only; the key itself goes into the shell the agent runs in.

Implementation checklist

  1. Read the API key from the environment variable CRYPTOSTRUCT_REALTIME_API_KEY; never hardcode, print, log or commit it, and keep the full URL (it carries the key) out of logs and error messages.
  2. The port selects the market — build the URL from the endpoint table above (ws://<host>:<port>/api/v6?apiKey=…, host and port of the endpoint and market you bought — not every endpoint serves every market); there is no market switch inside the protocol and no discovery endpoint. Transport is plain ws:// — there is no TLS in front of the proxy today, so do not try a TLS scheme.
  3. Instrument ids are numeric and come from the instrument directory https://cryptostruct.com/api/realtime/instruments?market=<code>&format=csv (or JSON without format) — request it with the header Authorization: Bearer <key> (the same key as the feed, read from CRYPTOSTRUCT_REALTIME_API_KEY; never in that URL), fetch it once at start-up, then poll the same URL with &since=<as_of> about every five minutes for new instruments and state changes (listed = scheduled, not yet trading), and never subscribe by symbol.
  4. Session order: connect → send login [13, org, app, version, processId] → wait for [14, …] → send [11, instrumentId] per instrument (optionally with topic flags) → read events. Nothing flows before the login, and a subscribe before it closes the connection.
  5. Every message in both directions is a JSON array whose first element is the numeric type tag; a text frame that does not start with [ is a plain-text server notice sent right before a close — log it verbatim.
  6. HTTP 401 at the WebSocket upgrade with the body api key rejected: <reason> means the key is unknown or has no paid day for this market today (UTC) — surface the body and stop; do not retry in a loop.
  7. Answer the server's WebSocket pings (every 5 s) with pongs — standard libraries do this by themselves as long as you keep reading from the socket; 30 s without a pong closes the connection (pong check failed).
  8. Keep prices, quantities, event ids and trade ids as decimal strings (or a decimal type) — never floats. Timestamps are integer nanoseconds since the Unix epoch (int64); 0 means not available.
  9. Maintain the book from snapshot (0) plus updates (1) in the order they arrive; quantity "0" deletes a level. Compare event ids for equality only — never sort or subtract them (on markets whose capabilities say "eventIdType": "UNORDERED" they are opaque). A prevEventId that does not equal your last eventId, or a snapshot with forceReset, means data was missed — unsubscribe [12, id], subscribe [11, id] and rebuild from the fresh snapshot.
  10. Reconnect with exponential backoff on any close: connect, login, subscribe again — every subscribe starts with a fresh snapshot; never try to resume by event id.
  11. Entitlement is per UTC calendar day: at 00:00 UTC a key without a paid next day gets the notice api key expired or revoked and the close — handle it as an expected stop, not an error loop. The same notice follows a key rotation or refund at the proxy's periodic re-check.
  12. Instrument-level problems arrive as state events [5, id, ts, "ERROR", "…"] and do not drop the connection — log them per instrument and keep the session.
  13. Ignore type tags you do not know — new message types may be added.
  14. Respect the connection limit per key and endpoint (3 by default, shared across the markets served there; more can be added from the account page in packs of 5): one connection per process, and every instrument you need on that one connection — a single connection can subscribe to the whole market, there is no per-connection subscription cap. Racing several connections to the same host against each other (feed arbitrage) gains nothing: they are served by the same host from the same feed, so no copy arrives earlier, and the duplicate traffic tends to slow your connection down rather than speed it up. That optimization is already done for you: we run feed arbitrage internally, before the data reaches your endpoint, so every connection delivers the fastest copy we have.

Quick reference

cheat sheet
Connect    ws://<host>:<port>/api/v6?apiKey=<key>        JSON
           ws://<host>:<port>/api/v6/sbe?apiKey=<key>    SBE (binary)
Port       14004 = binance_swap   14003 = binance_spot   14008 = okex   14006 = coinbase   14005 = kalshi   14007 = polymarket
Where      binance_swap: lon1 fra1 ash1 ohio1 · binance_spot: lon1 fra1 ash1 ohio1 · okex: lon1 fra1 ash1 ohio1 · coinbase: ohio1 tyo1 · kalshi: tyo1 · polymarket: tyo1
Self-check GET http://<host>:<port>/api/info?all=true
Instruments GET https://cryptostruct.com/api/realtime/instruments?market=<code>[&state=…][&since=<as_of>][&format=csv]   header: Authorization: Bearer <key>
Login      [13, org, app, version, processId]   ->  [14, service, version, "6", {capabilities}]
Subscribe  [11, instrumentId]                  ->  snapshot, top of book, … + [5, id, ts, "READY", ""]   (any number per connection)
Practice   one connection per host, all instruments on it — several connections to the same host never make the feed faster
           [11, instrumentId, {topic flags}]   (depth/topOfBook/trades/markPrice/indexPrice/fundingRate/liquidations)
Unsub      [12, instrumentId]
Events     [type, instrumentId, prevEventId, eventId, adapterTsNs, exchangeTsNs, payload]
Keep-alive server ping every 5 s -> answer with pong (libraries do); 30 s tolerance
Rejected   HTTP 401 at the handshake, body "api key rejected: <reason>" (unknown key / no paid day for this market today)
Notices    plain-text frame, then close: api key expired or revoked / not logged in / service shutdown …

Ports today: 14004 = Binance USDT-M (binance_swap, London, Frankfurt, Ashburn, Ohio), 14003 = Binance Spot (binance_spot, London, Frankfurt, Ashburn, Ohio), 14008 = OKX (okex, London, Frankfurt, Ashburn, Ohio), 14006 = Coinbase Spot (coinbase, Ohio, Tokyo), 14005 = Kalshi (kalshi, Tokyo), 14007 = Polymarket (polymarket, Tokyo). Field-level details for every message, including the SBE layout, are in the protocol reference and the SBE docs; the SBE schema XML for sbe-tool is in the spec package.

Security notes

  • The key is your only credential and on the WebSocket it travels in the URL query string — treat the full URL like a password: keep it out of shared configs, tickets, logs and screenshots.
  • Rotate the key on /account/realtime if it ever leaks; the old key stops validating at the next login and re-check. Rotation does not touch your purchased days.
  • Transport is plain ws:// today — run your client from a server you control rather than a shared network, and do not put the key into browser-side code.
  • Never share one key between systems that you may want to cut off separately — one key per account is the model; use the connection limit for parallelism instead.
  • On the HTTP master-data route the key travels as an Authorization: Bearer header — never as a query parameter there (that form is for the WebSocket only), so it stays out of URL logs.