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.

Public beta

Self-serve realtime access is in public beta. The feed and the protocol are the production platform behind our enterprise feeds; the self-serve path — the shop, API keys and this guide — is new. Every day bought while the beta runs is 20 % below the list price, see pricing. Found a gap in this guide or the feed? Tell us.

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

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

GET http://lon1.cryptostruct.com:<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: spot markets have no mark price, index price, funding or liquidations (those topics are null there and never emit); USDT-M perpetuals have all of them.

Instrument master data

Subscriptions take numeric instrument ids, so fetch the master data of your market first: every sellable (open/review) instrument with id, code, state, tick and lot size and contract specs — the same list the access card 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. From a script or coding agent, 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.

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 or key not accepted,
# 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 or key not accepted; 503 = retry after the Retry-After seconds
    sys.exit(f"master data request failed: HTTP {e.code}")

print(data["market"]["code"], data["count"], "instruments as of", data["generated_at"])
for row in data["instruments"][:5]:
    # ticksize / lot_size are decimal STRINGS — keep them as str or Decimal, never float
    print(row["instrument_id"], row["code"], row["state"], "tick", row["ticksize"], "lot", row["lot_size"])
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.
statestringopen or review — only sellable instruments are listed.
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=.

JSON: `{market: {exchange_id, code, name}, generated_at, count, instruments: [...]}` with these fields per row; CSV: one header line, these columns in this order.

  • 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).
  • Only open and review instruments are listed — the sellable set. The list is refreshed server-side about hourly; fetch it once at start-up and re-fetch daily, not per message.
  • Responses: 200 with the list; 404 when the market code is unknown OR the key is not accepted (uniform on purpose — check both); 503 with a Retry-After header when the access check is temporarily unavailable — retry after that many seconds.
  • 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, top of book, last trade, 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.

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, so a gap means you missed data — resubscribe. 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 order of eventId.
  • 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 on derivatives markets.

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, 10 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.
  • 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():
    # One HTTP call at start-up (re-fetch daily, not per message): every sellable 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 from your access card on /account/realtime (your market, port and an instrument you bought). Put your key into the agent's environment as CRYPTOSTRUCT_REALTIME_API_KEY — use “Copy key” on the access card — 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`) at our London endpoint, port 14004. The port selects the market — connect to ws://lon1.cryptostruct.com:14004/api/v6?apiKey=<key>
- Transport is plain ws:// — there is no TLS in front of the proxy today; do not switch to a TLS scheme.
- Start with instrument 67824 (BTCUSDT). More ids (numeric, with tick/lot sizes): https://cryptostruct.com/api/realtime/instruments?market=binance_swap&format=csv — GET it with the HTTP header Authorization: Bearer <key> (the same key, taken from the environment variable below; never put it into that URL). If the spec URL above answers 404, request it with the same header.
- 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.

What I want:
1. connect → login [13, "my-org", "my-client", "1.0.0", "client-01"] → 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, funding 9; liquidations 17 only if I ask for them).
2. Keep prices, quantities, event ids and trade ids as decimal strings — never floats. Apply book updates in eventId order; on a prevEventId gap 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 the key is unknown or has no paid day for this market today (UTC) — show the response body and stop, do not retry in a loop.
4. Expect the notice `api key expired or revoked` followed by a close at 00:00 UTC when the next day is not bought — treat it as a normal stop, not a crash loop.

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://lon1.cryptostruct.com:<port>/api/v6?apiKey=…); 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 master-data export 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, 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 eventId order; quantity "0" deletes a level. A prevEventId that does not match 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 (10 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.

Quick reference

cheat sheet
Connect    ws://lon1.cryptostruct.com:<port>/api/v6?apiKey=<key>        JSON
           ws://lon1.cryptostruct.com:<port>/api/v6/sbe?apiKey=<key>    SBE (binary)
Port       14004 = binance_swap   14003 = binance_spot   14004 = binance_swap   14003 = binance_spot   14004 = binance_swap   14003 = binance_spot
Self-check GET http://lon1.cryptostruct.com:<port>/api/info?all=true
Instruments GET https://cryptostruct.com/api/realtime/instruments?market=<code>[&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)
           [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), 14003 = Binance Spot (binance_spot), 14004 = Binance USDT-M (binance_swap), 14003 = Binance Spot (binance_spot), 14004 = Binance USDT-M (binance_swap), 14003 = Binance Spot (binance_spot). Field-level details for every message, including the SBE layout, are in the protocol reference and the SBE docs.

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.