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.
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.
/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).websockets, Node ws, websocat for a first smoke test, …).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.
Each market listens on its own port; there is no market switch inside the protocol and no discovery endpoint. Pick the URL of the market you bought, 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).
| Market | Code | Location | Port | JSON endpoint | SBE endpoint | Self-check |
|---|---|---|---|---|---|---|
| Binance USDT-M | binance_swap | London | 14004 | ws://lon1.cryptostruct.com:14004/api/v6 | ws://lon1.cryptostruct.com:14004/api/v6/sbe | /api/info |
| Binance Spot | binance_spot | London | 14003 | ws://lon1.cryptostruct.com:14003/api/v6 | ws://lon1.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.
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.
# 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# 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"])| Column | Type | Meaning |
|---|---|---|
instrument_id | integer | Numeric id — the value you subscribe with ([11, instrument_id]). |
code | string | Exchange symbol, e.g. BTCUSDT. |
type | string | Instrument class as recorded (perpetual, spot, …). |
base_underlying | string or null | Base asset, e.g. BTC. |
counter_underlying | string or null | Quote asset, e.g. USDT. |
state | string | open or review — only sellable instruments are listed. |
ticksize | decimal string or null | Minimum price increment. |
lot_size | decimal string or null | Minimum quantity increment. |
contract_value | decimal string or null | Contract value (derivatives). |
multiplier | decimal string or null | Contract multiplier. |
min_order | decimal string or null | Minimum order size. |
max_order | decimal string or null | Maximum order size. |
exchange_id | integer | Numeric id of the market (exchange). |
exchange_code | string | Market 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.
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).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.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.Cache-Control: private — keep them to your own systems; do not proxy them to other accounts.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:
ws://lon1.cryptostruct.com:14004/api/v6?apiKey=YOUR_API_KEY.[13, "my-org", "my-client", "1.0.0", "client-01"] and read the [14, …] response with the capabilities.[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.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.
[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
}]not logged in and the connection is closed; a second login gets already logged in.liquidationsTopic capability.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.
[11, 67824]
[11, 67824, {"depthTopic": false, "topOfBookTopic": true, "tradesTopic": true,
"markPriceTopic": true, "indexPriceTopic": true, "fundingRateTopic": true,
"liquidationsTopic": true, "topOfBookCoalescing": false}]
[12, 67824]topOfBookCoalescing: true delivers only the latest top-of-book per flush instead of every change — fewer messages for slow consumers.[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.[5, id, ts, "ERROR", "…"] with instrument not available on this endpoint or instrument not permitted for this api key.[12, id]) is acknowledged with a state event instrument unsubscribed.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.
| Tag | Message | Direction | Payload after the header | Example (live, BTCUSDT perp) |
|---|---|---|---|---|
13 | Login | client → server | [13, organization, appName, version, processId] — four free-form strings, logging only | [13, "my-org", "my-client", "1.0.0", "client-01"] |
14 | Login response | server → client | [14, service, serviceVersion, protocolVersion, {capabilities}] | [14, "public-proxy-ldn-a", "3.12.1-rc", "6", {"depthTopic": {…}, "tradesTopic": {…}, …}] |
11 | Subscribe | client → server | [11, instrumentId] or [11, instrumentId, {topic flags}] | [11, 67824] |
12 | Unsubscribe | client → server | [12, instrumentId] | [12, 67824] |
0 | Book snapshot | server → client | header + [[side, "price", "qty", orderCount], …] (full depth), trailing forceReset flag | [0, 67824, "0", "11416095364323", …, [[0, "77979.1", "5.221", 1], [0, "77979", "0.39", 1], …]] |
1 | Book update | server → client | header + [[side, "price", "qty", orderCount], …] — qty "0" deletes the level | [1, 67824, "11416095364323", "11416095366135", …, [[0, "77907.2", "5.772", 1], …]] |
2 | Trades | server → client | header + [[side, "price", "qty", "tradeId", timeNs], …] | [2, 67824, …, [[1, "77979.1", "0.012", "8030538547", 1787937748600000000]]] |
5 | Instrument state | server → client | [5, instrumentId, timestampNs, "READY" | "ERROR", "message"] — own header | [5, 67824, 1787937748548526562, "READY", ""] |
6 | Top of book | server → client | header + [[0, "bidPrice", "bidQty", count], [1, "askPrice", "askQty", count]] | [6, 67824, …, [[0, "77979.1", "5.221", 1], [1, "77979.2", "1.481", 1]]] |
7 | Mark price | server → client | header + "price" | [7, 67824, …, "77979.1"] |
8 | Index price | server → client | header + "price" | [8, 67824, …, "78007.70152174"] |
9 | Funding rate | server → client | header + ["currentRate", nextFundingTimeNs, "predictedRate"] | [9, 67824, …, ["0.00005913", 1787961600000000000, "0.00008026"]] |
17 | Liquidations | server → client | header + [[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.
forceReset: true means the venue restarted its feed — discard what you had first."0" deletes the level. Apply updates in order of eventId.[side, "price", "quantity", "tradeId", timeNs] with side 0 = buy (taker bought), 1 = sell.["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."liquidationsTopic": true and only on derivatives markets.pong check failed). Standard libraries answer pings automatically — you only have to keep reading from the socket.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.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.[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.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.
# 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())// 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()));# 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]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.
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.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.
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.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.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.[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.[ is a plain-text server notice sent right before a close — log it verbatim.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.pong check failed).0 means not available.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.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.[5, id, ts, "ERROR", "…"] and do not drop the connection — log them per instrument and keep the session.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
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", ""]
[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). Field-level details for every message, including the SBE layout, are in the protocol reference and the SBE docs.
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.Authorization: Bearer header — never as a query parameter there (that form is for the WebSocket only), so it stays out of URL logs.