Esc

    Integrate

    Web proxy API

    The JSON WebSocket that oracle-proxy serves to browsers. Endpoints, every message, how to keep a book, limits, and a TypeScript client.

    A browser cannot keep a client API key secret, so it cannot use the node transport. oracle-proxy is one listed client. It runs the Rust SDK with its signature checks and its quorum. It serves the result as JSON over one plain WebSocket. A reverse proxy in front of it terminates TLS. Run the web proxy tells an operator how to set it up.

    Important

    The browser trusts the proxy operator, through TLS. The proxy did the signature, registry, replay, staleness and quorum checks. The node signatures stop at the proxy, so the proxy operator can change prices. A consumer that must not trust an operator uses the Rust SDK directly. See Trust when you use the web proxy.

    Endpoints

    Path Method Purpose
    /v1/ws GET, WebSocket upgrade The data socket. Open to anyone, within the limits.
    /v1/catalog GET The prices, venue feeds and books that the proxy serves now.
    /v1/nodes GET The latest node reports: the nodes message.
    /v1/history GET One feed's past prices from the archive, for a chart.
    /health GET The state of the SDK client inside the proxy.

    A browser connects to wss://<operator host>/v1/ws. The proxy itself speaks plain ws, by default on 127.0.0.1:8792. The reverse proxy adds TLS.

    The GET routes send Access-Control-Allow-Origin: *, because their data is public and read-only. A page on any origin can read them. The socket does not check Origin. Each refusal of a GET route is an error body.

    /v1/catalog

    json
    {
      "prices": ["CRYPTO.BTC/USD", "CRYPTO.BTC/USDT", "CRYPTO.ETH/USD"],
      "venues": ["CRYPTO.BTC/USDT"],
      "books": ["binance:spot:BTCUSDT", "bybit:spot:BTCUSDT", "okx:spot:BTC-USDT"],
      "funding": ["bybit:linear_perp:BTCUSDT", "okx:linear_perp:BTC-USDT-SWAP"],
      "nodes": true
    }
    Field Contents
    prices Feed symbols whose prices the proxy serves, sorted.
    venues Feed symbols whose venue reports the proxy serves, sorted.
    books Book names, venue:market:symbol, sorted.
    funding Perpetual names, venue:market:symbol, sorted.
    nodes true: the proxy serves the node reports.

    The operator configures prices and venues. The books and funding lists grow as frames arrive from the nodes. The venue symbol is the venue's own, so it differs between venues. A book that has not arrived yet is not in the list. The funding of a node that leaves the registry goes from the list.

    /health

    json
    {
      "status": "ok",
      "registry_version": 7,
      "quorum": 3,
      "nodes": 3,
      "connected": 3,
      "last_frame_ns": "1791299498663252000",
      "clients": 12,
      "accepted": 10452,
      "venue_reports": 881,
      "book_updates": 20311,
      "rejected": 0
    }
    Field Contents
    status How the nodes deliver now. See the list below.
    registry_version The registry version that the SDK client trusts.
    quorum Nodes that must agree on a price.
    nodes Nodes in the registry.
    connected Nodes with a connection now.
    last_frame_ns When the newest frame from any node arrived. null before the first.
    clients Open web sockets.
    accepted Price frames accepted.
    venue_reports Venue report frames accepted.
    book_updates Book frames accepted.
    rejected Frames refused: untrusted, replayed, out of time, incoherent or from a retired key.
    archive The archive's counters, only on a proxy that runs the archive.

    The status values:

    • ok: a quorum of nodes is connected, and the newest frame is at most 10 s old.
    • degraded: the newest frame is fresh, but fewer nodes than the quorum are connected.
    • starting: no frame has arrived yet.
    • down: no frame has arrived in the last 10 s.

    The HTTP status is 200 for ok and degraded, and 503 for starting and down. The body is the same in each case. The fields update once a second.

    /v1/nodes

    The latest nodes message, as the socket sends it. Before the first report, the proxy answers 503 with the error unavailable.

    /v1/history

    One feed's past prices and venue values, from the proxy's archive. Only a proxy with a [history] section has this route. Another proxy answers 404 with the error not_found.

    text
    GET /v1/history?feed=CRYPTO.BTC/USD&from=1791619200000&to=1791621000000
    Parameter Contents
    feed A symbol from the catalog's prices or venues.
    from The start, unix milliseconds. Default: 30 minutes before to.
    to The end, unix milliseconds. Default: now. A later time stops at now.

    One request covers at most 24 hours (max_range_hours). The proxy picks the step, from 1 s to 1 day, so that each line has at most 2000 values. Each step divides a day, and steps start on whole multiples of the step since the unix epoch. So the first step can start before from, and two requests share their step times. The archive keeps 7 days by default (retention_days). A step before that has no value.

    json
    {
      "type": "history",
      "feed": "CRYPTO.BTC/USD",
      "from_ms": 1791619200000,
      "to_ms": 1791621000000,
      "step_ms": 1000,
      "times_ms": [1791619200000, 1791619201000],
      "price": ["82776.45", null],
      "conf": ["11.7064", null],
      "sources": [
        {
          "venue": "binance",
          "market": "spot",
          "symbol": "BTCUSDT",
          "bid": ["82858.53", "82858.6"],
          "ask": ["82858.54", "82858.61"]
        }
      ]
    }
    Field Contents
    step_ms The length of one step.
    times_ms The start of each step. The first step holds from_ms. Each line below has one value for each step.
    price The quorum price at the end of the step. null when the feed was halted or the archive has no row.
    conf The confidence half-width at the end of the step.
    sources The feed's venues, in the order of the venue report. Each has bid, ask, mark, index and funding at the end of the step. A field the venue never sent is absent.

    The venue values are the venue report's own, before any peg conversion. To draw a USDT venue on a USD chart, multiply it by the CRYPTO.USDT/USD price of the same step.

    The proxy reads the archive with a database role that can only read. It refuses a role that can write, so the process that faces the internet holds no credential that can change the archive. Each address can send 30 requests a minute (requests_per_minute). Errors use the error format: bad_request (400), not_found (404), rate_limited (429, Retry-After: 60), busy (503, Retry-After: 1) and unavailable (503).

    Message format

    Every message is a JSON text frame. A web client sends ops with an op field. The proxy sends messages with a type field. A client message can be at most 64 KiB.

    Why numbers are strings

    Every 64-bit time and every decimal is a JSON string. A JavaScript number is a 64-bit float, so it loses precision past 2^53. A nanosecond timestamp is about 1.8 × 10^18, far past that limit. A decimal as a string also never goes through a float on the proxy side.

    • Parse a time such as publish_time_ns with BigInt.
    • Keep a price as a string, or parse it with a decimal library.
    • Small counts stay JSON numbers: nodes, halted_nodes, weight_bps, expo, the book seq, the pong id, and the counts and microseconds in a nodes message.
    • Venue report values are integer mantissas as strings, not decimals. The value is the mantissa × 10^exponent.

    Client ops

    subscribe

    json
    {
      "op": "subscribe",
      "prices": ["CRYPTO.BTC/USD"],
      "venues": ["CRYPTO.BTC/USDT"],
      "books": ["binance:spot:BTCUSDT"],
      "funding": ["okx:linear_perp:BTC-USDT-SWAP"],
      "nodes": true
    }
    Field Type Contents
    op string "subscribe"
    prices array of strings, optional Feed symbols whose prices to send.
    venues array of strings, optional Feed symbols whose venue reports to send.
    books array of strings, optional Book names, venue:market:symbol.
    funding array of strings, optional Perpetual names, venue:market:symbol, or "*" for every perpetual.
    nodes boolean, optional true to get the node reports, once a second.

    The proxy answers with subscribed, before any data of the new topics. Then it sends at once, for the names in the request:

    • the latest price message of each price feed;
    • the latest venues message of each node, for each venue feed;
    • a snapshot of each book;
    • the latest funding message of each node, for each perpetual;
    • the latest nodes message, when nodes is true.

    The node reports count as one subscription, and so does "*" for funding. The limit counts the topics that the socket would hold, each once. A name that the socket holds already, or that the request repeats, does not count again.

    A book or a perpetual counts once its name is valid, also before its first frame: the data starts when a node sends it. So a client that subscribes just after a proxy restart loses nothing. A funding name must be a perpetual market. A name that the proxy does not serve goes to unknown in the reply. A request that would pass the subscription limit changes nothing. The proxy answers with the error too_many_subscriptions.

    unsubscribe

    json
    {
      "op": "unsubscribe",
      "prices": ["CRYPTO.BTC/USD"],
      "venues": [],
      "books": ["binance:spot:BTCUSDT"]
    }

    The fields are the same as for subscribe. "nodes": true stops the node reports, and "funding": ["*"] stops all funding. The proxy drops the messages that wait for those topics, and then it answers with unsubscribed. After that reply, no message for those topics comes.

    ping

    json
    { "op": "ping", "id": 1 }
    Field Type Contents
    op string "ping"
    id unsigned integer, optional Sent back in the pong.

    The id is at most 2^53 − 1, so a JavaScript number holds it. An unknown op, an unknown field or a larger id is a bad_request error.

    Server messages

    subscribed

    json
    {
      "type": "subscribed",
      "prices": ["CRYPTO.BTC/USD"],
      "venues": [],
      "books": ["binance:spot:BTCUSDT"],
      "funding": [],
      "nodes": false,
      "unknown": ["CRYPTO.X/USD"]
    }
    Field Contents
    type "subscribed"
    prices, venues, books, funding The names from the request that the proxy serves, each once, in canonical form.
    nodes The nodes flag of the request.
    unknown The names from the request that the proxy does not serve. Present only when not empty.

    unsubscribed

    json
    {
      "type": "unsubscribed",
      "prices": ["CRYPTO.BTC/USD"],
      "venues": [],
      "books": ["binance:spot:BTCUSDT"],
      "funding": [],
      "nodes": false
    }
    Field Contents
    type "unsubscribed"
    prices, venues, books, funding The names from the request that the proxy serves, each once, in canonical form.
    nodes The nodes flag of the request.
    unknown The names from the request that the proxy does not serve. Present only when not empty.

    price

    The Resolved value of the SDK, with type added. Resolved explains each field.

    json
    {
      "type": "price",
      "feed_id": "0x0523e79c7ed211911a19afaa4def68c896bc72d674bf33c5c64d4d7089e320e2",
      "symbol": "CRYPTO.BTC/USD",
      "status": "ok",
      "price": "86599.95",
      "conf": "19.71359135",
      "nodes": 3,
      "halted_nodes": 0,
      "concerns": [],
      "publish_time_ns": "1791299498663252000",
      "spread_bps": "0"
    }
    Field Type Contents
    type string "price"
    feed_id string The feed ID, 0x hex.
    symbol string The feed symbol.
    status string "ok", "degraded" or "halted".
    price decimal string or null The median of the node prices. null when halted.
    conf decimal string or null The half-width of the confidence interval. null when halted.
    nodes number Nodes that contributed.
    halted_nodes number Nodes left out because they reported their own feed halted.
    concerns array of strings insufficient_nodes, node_divergence, node_reported_degraded, node_reported_halted, registry_stale.
    publish_time_ns string The newest contributing publish time, unix nanoseconds. "0" when halted.
    spread_bps decimal string or null The spread between nodes, in basis points of the median.

    What to do for each status and concern is in Status and concerns. In short: use ok and degraded prices and act on conf. Never settle or liquidate on halted.

    venues

    One node's verified venue report for one feed. The proxy does not combine reports across nodes.

    json
    {
      "type": "venues",
      "symbol": "CRYPTO.BTC/USDT",
      "node": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
      "report": {
        "feed_id": "0xf8e0c6b396a831650eced3ac35b166f564b62a172ce533ce8671d8629ceb6b52",
        "publish_time_ns": "1791299498663252000",
        "seq": "1791299498663",
        "expo": -8,
        "entries": [
          {
            "venue": "binance",
            "market": "spot",
            "symbol": "BTCUSDT",
            "status": "used",
            "weight_bps": 3400,
            "bid": "8659995000000",
            "ask": "8659996000000",
            "bid_qty": "150000000",
            "ask_qty": "42000000",
            "impact_bid": "8659980000000",
            "impact_ask": "8660010000000",
            "mark": null,
            "index": null,
            "funding": null,
            "exchange_time_ns": "1791299498623252000",
            "local_recv_ns": "1791299498661252000"
          }
        ]
      }
    }
    Field Type Contents
    type string "venues"
    symbol string The feed symbol.
    node string The address of the node that signed the report.
    report.feed_id string The feed ID, 0x hex.
    report.publish_time_ns string When the node signed the report.
    report.seq string The report sequence of this node and feed. It is separate from the price sequence.
    report.expo number The exponent of every price in the entries.
    report.entries array One entry for each configured source, in config order.

    Each entry:

    Field Type Contents
    venue string binance, bybit, okx, coinbase, kraken or deribit.
    market string spot, linear_perp or inverse_perp.
    symbol string The venue's own symbol for the instrument, such as BTCUSDT.
    status string used, outlier, stale, crossed, no_data, invalid, feed_halted or peg_unavailable.
    weight_bps number The share of the total weight of the price, 0 to 10,000. It is 0 unless the status is used.
    bid, ask string or null Mantissas at report.expo.
    bid_qty, ask_qty string or null Mantissas at exponent −8, in base units.
    impact_bid, impact_ask string or null Mantissas at report.expo, for the impact notional of the feed.
    mark, index string or null Mantissas at report.expo.
    funding string or null The venue's own rate over its own interval, a mantissa at exponent −9.
    exchange_time_ns string or null The venue's own timestamp on its newest observation.
    local_recv_ns string When the node received the newest observation, or "0" with no data.

    In the example, "bid": "8659995000000" at expo −8 is 86599.95.

    book

    A snapshot or a delta of one book.

    json
    {
      "type": "book",
      "book": "binance:spot:BTCUSDT",
      "seq": 1,
      "snapshot": true,
      "available": true,
      "node": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
      "publish_time_ns": "1791299498663252000",
      "local_recv_ns": "1791299498662252000",
      "exchange_time_ns": "1791299498660000000",
      "bids": [["86599.95", "1.5"]],
      "asks": [["86599.96", "0.42"]]
    }

    A delta for the same book. The level at 86599.95 left, and a level at 86599.9 arrived:

    json
    {
      "type": "book",
      "book": "binance:spot:BTCUSDT",
      "seq": 2,
      "snapshot": false,
      "available": true,
      "node": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
      "publish_time_ns": "1791299498763252000",
      "local_recv_ns": "1791299498761252000",
      "exchange_time_ns": "1791299498760000000",
      "bids": [
        ["86599.95", "0"],
        ["86599.9", "2.1"]
      ],
      "asks": []
    }
    Field Type Contents
    type string "book"
    book string The book name, venue:market:symbol.
    seq number The proxy's own sequence for this book.
    snapshot boolean true for the whole book. false for the changed levels only.
    available boolean false when no node has a valid book. The levels are then empty.
    node string or null The node whose book this is. null when not available.
    publish_time_ns string When the node signed its last book frame. "0" when not available.
    local_recv_ns string When the node received the newest book data. "0" when not available.
    exchange_time_ns string or null The venue's own time on the newest book data. null when the venue sends none, or when not available.
    bids array of [price, size] Decimal strings, best (highest) price first.
    asks array of [price, size] Decimal strings, best (lowest) price first.

    Sizes are in base units. In a delta, a size of "0" removes the level.

    funding

    One node's funding of one perpetual. Each message carries the whole state, so only the latest one counts. The proxy keeps one slot for each node and perpetual.

    json
    {
      "type": "funding",
      "funding": "okx:linear_perp:BTC-USDT-SWAP",
      "node": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
      "update": {
        "venue": "okx",
        "market": "linear_perp",
        "symbol": "BTC-USDT-SWAP",
        "publish_time_ns": "1791542600000000000",
        "seq": "1791542400003",
        "basis": "interval",
        "rate": "-30339016",
        "predicted_rate": null,
        "interval_ns": "28800000000000",
        "next_funding_time_ns": "1791561600000000000",
        "settled": { "rate": "6729944", "time_ns": "1791532800000000000" },
        "open_interest": null,
        "exchange_time_ns": "1791542585113000000",
        "local_recv_ns": "1791542585120000000"
      }
    }
    Field Type Contents
    type string "funding"
    funding string The perpetual, venue:market:symbol.
    node string The node that signed the update.
    update object The signed funding update. FundingUpdate v1 lists its fields.

    Rates are mantissas at exponent −12: "-30339016" is −0.000030339016. basis is interval for a fraction paid at each funding time, or eight_hours for Deribit's funding_8h. Open interest is a mantissa at −8, with its unit: base, contracts or quote. A field that the venue does not send is null.

    nodes

    Every node in the registry, as the SDK client inside the proxy sees it. The proxy sends one each second to a client that subscribed with "nodes": true.

    json
    {
      "type": "nodes",
      "time_ns": "1791469482805717000",
      "registry_version": 3,
      "quorum": 3,
      "nodes": [
        {
          "address": "0x02acaf5c6723d2ffaa8600647e32a227b22e4425",
          "weight": 1,
          "endpoints": ["ws://127.0.0.1:8803/v2/ws"],
          "connection": { "state": "connected", "since_ns": "1791469442141312000" },
          "connects": 1,
          "failures": 2,
          "last_error": "IO error: Connection refused (os error 61)",
          "last_frame_ns": "1791469482797795000",
          "frames": {
            "accepted": 831,
            "venue_reports": 7199,
            "book_updates": 2798,
            "replayed": 0,
            "out_of_time": 0,
            "incoherent": 0
          },
          "delivery": { "count": 831, "p50_us": 193, "p90_us": 342, "p99_us": 606, "max_us": 24927 },
          "feeds": [
            {
              "symbol": "CRYPTO.BTC/USD",
              "state": "live",
              "price": "82437.35104361",
              "conf": "11.65840197",
              "deviation_bps": "0",
              "publish_time_ns": "1791469482765948959",
              "seq": "1791469441964"
            }
          ]
        }
      ]
    }
    Field Type Contents
    type string "nodes"
    time_ns string When the proxy took the report, unix nanoseconds.
    registry_version number The registry version that the SDK client trusts.
    quorum number Nodes that must agree on a price.
    nodes array One report for each node in the registry, in its order.

    Each node:

    Field Type Contents
    address string The node's signing address, its id in the registry.
    weight number The registry's weight hint.
    endpoints array of strings The registry's endpoints for the node.
    connection object state: idle, connecting, connected with since_ns, or backoff with retry_at_ns.
    connects number Handshakes that passed.
    failures number Connection attempts and sessions that ended with an error.
    last_error string or null The last such error. It stays after the node connects again.
    last_frame_ns string or null When the last accepted frame from the node arrived.
    frames object Accepted price, venue and book frames, and rejections as replayed, out_of_time and incoherent.
    delivery object or null Node signature to receipt at the proxy, in microseconds, over the last full minute: p50_us to max_us.
    feeds array The node's latest update for each price feed of the proxy.

    Each feed of a node:

    Field Type Contents
    symbol string The feed symbol.
    state string or null live, degraded, halted or stale, by the quorum rules. null before the first update.
    price, conf decimal string or null The node's own price and confidence.
    deviation_bps decimal string or null The node's price against the median, in signed basis points. null without a median.
    publish_time_ns string or null When the node signed its latest update.
    seq string or null The sequence number of that update.

    The report is for monitoring. Only live and degraded updates count toward the quorum, and the quorum alone decides a price. The delivery times compare the node clock with the proxy clock, so they are exact only with clocks in sync.

    pong

    json
    { "type": "pong", "id": 1 }
    Field Contents
    type "pong"
    id The id of the ping, or null if the ping had none.

    error

    json
    { "type": "error", "code": "rate_limited", "message": "at most 10 messages a second" }
    Field Contents
    type "error"
    code A stable code. See Limits and errors.
    message Text for a person. Do not parse it.

    Keep a book

    The proxy keeps its own snapshot and delta stream for each book. It builds the stream from the freshest valid book of the SDK, which is always one node's whole book.

    1. Subscribe to the book. The first book message after subscribed is a snapshot.
    2. On a snapshot, clear the book and load its levels. Keep its seq.
    3. On a delta, check that seq is the kept seq plus 1. Then apply each level: set the new size, or remove the level when the size is "0". Keep the new seq.
    4. On a gap in seq, drop the book. Ignore deltas until the next snapshot. Send unsubscribe and then subscribe for the book: the proxy answers a subscribe with a fresh snapshot.
    5. On available: false, show that there is no book. No node has a valid book now. A later snapshot with available: true brings the book back.

    The seq belongs to the proxy, not to a node. Snapshots and deltas of one book share it. The proxy sends a snapshot only at these times:

    • when a client subscribes to the book;
    • when the source node changes, because two nodes' books are not deltas of each other;
    • when the queue of a slow client for the book fills: the queue becomes one snapshot;
    • when the book becomes unavailable, and when it comes back.

    The proxy compares each book at most every book_interval_ms (100 ms by default) and sends a delta only when a level changed. It sends no periodic snapshot, so after a gap you must ask for one.

    Prices and venue reports

    • Each socket has one slot for each price feed. A newer price replaces the one that waits. A slow client gets the newest state, not a backlog.
    • Each socket has one slot for each pair of venue feed and node.
    • Each socket has one slot for the node reports.
    • You observe state, not transitions. Read status on each message.
    • The proxy resolves each price feed again every second. A silent upstream thus shows as halted, not as the last good price.
    • The proxy does not send a message again when its content is the same as the last one. A price that does not change thus comes once. Use publish_time_ns for the age of a price, not the time of the message.
    • The venue reports and the funding of a node that leaves the registry go. A new subscriber does not get them.

    Limits and errors

    The socket is open to anyone. These limits bound the cost of one client. The operator sets them in [limits]. The values below are the defaults.

    Limit Config key Default When a client passes it
    Open sockets, in total max_connections 1024 HTTP 503 at connect, busy, Retry-After: 5
    Open sockets from one IP address connections_per_ip 8 HTTP 429 at connect, rate_limited, Retry-After: 60
    New sockets from one IP address each minute connects_per_minute 30 HTTP 429 at connect, rate_limited, Retry-After: 60
    Messages from one socket each second, pings and pongs too messages_per_second 10 Error rate_limited, then the proxy closes the socket
    GET requests to /v1/catalog and /v1/nodes each minute routes_per_minute 120 HTTP 429, rate_limited, Retry-After: 60
    Subscriptions on one socket max_subscriptions 256 Error too_many_subscriptions. Nothing changes.
    Book messages that wait, for one book max_book_queue 64 The queue becomes one snapshot
    Size of one client message fixed 64 KiB Error bad_request, then the proxy closes the socket
    Replies that wait, when the client does not read fixed 64 The proxy closes the socket
    Time for one batch of messages to send fixed 30 s The proxy closes the socket
    Time with no message from the client, pongs included fixed 75 s The proxy closes the socket

    Each limit is more than 0; the proxy refuses a config with a 0. An IPv6 address counts by its /64, because one client usually owns a whole /64. The proxy pings each socket every 30 s. A browser answers each ping by itself, so a page that is open never reaches the 75 s limit.

    Error codes:

    Code Cause The socket
    bad_request The message is not valid JSON, has an unknown op, or has an unknown field. Stays open
    bad_request The message is binary, or larger than 64 KiB. Closes
    rate_limited More than messages_per_second messages in one second. Closes
    too_many_subscriptions The request would pass max_subscriptions. Stays open
    internal The proxy cannot encode a message. This is a bug in the proxy. Stays open

    A refused HTTP request has the same error body, with the codes bad_request, not_found, rate_limited, busy and unavailable.

    A browser WebSocket does not show the HTTP status of a refused upgrade. A refused connect looks like a socket that closes. Wait before you connect again, and wait longer after each failure.

    Behind a reverse proxy, the per-IP limits use the last X-Forwarded-For entry when the operator sets trust_forwarded_for. See trust_forwarded_for.

    A browser client in TypeScript

    The JavaScript SDK does all of the above for you. This section shows the protocol without it.

    This client uses the plain WebSocket API and no library. It subscribes to one price and one book, keeps the book, and connects again after a close.

    ts
    // A minimal browser client for oracle-proxy: one price and one book.
    
    const ENDPOINT = 'wss://oracle.example.com/v1/ws';
    const PRICE = 'CRYPTO.BTC/USD';
    const BOOK = 'binance:spot:BTCUSDT';
    
    type Level = [price: string, size: string];
    
    interface PriceMessage {
      type: 'price';
      feed_id: string;
      symbol: string | null;
      status: 'ok' | 'degraded' | 'halted';
      price: string | null;
      conf: string | null;
      nodes: number;
      halted_nodes: number;
      concerns: string[];
      publish_time_ns: string;
      spread_bps: string | null;
    }
    
    interface BookMessage {
      type: 'book';
      book: string;
      seq: number;
      snapshot: boolean;
      available: boolean;
      node: string | null;
      publish_time_ns: string;
      local_recv_ns: string;
      bids: Level[];
      asks: Level[];
    }
    
    type ServerMessage =
      | PriceMessage
      | BookMessage
      | {
          type: 'subscribed' | 'unsubscribed';
          prices: string[];
          venues: string[];
          books: string[];
          funding: string[];
          unknown?: string[];
        }
      | { type: 'venues'; symbol: string; node: string; report: unknown }
      | { type: 'funding'; funding: string; node: string; update: unknown }
      | { type: 'pong'; id: number | null }
      | { type: 'error'; code: string; message: string };
    
    class LocalBook {
      bids = new Map<string, string>();
      asks = new Map<string, string>();
      available = false;
      node: string | null = null;
      // null means: wait for a snapshot.
      private seq: number | null = null;
    
      /** Applies one message. Returns false on a seq gap. */
      apply(message: BookMessage): boolean {
        if (message.snapshot) {
          this.bids.clear();
          this.asks.clear();
          this.available = message.available;
          this.node = message.node;
        } else if (this.seq === null) {
          return true; // A delta before the first snapshot: ignore it.
        } else if (message.seq !== this.seq + 1) {
          this.seq = null;
          return false;
        }
        this.seq = message.seq;
        const sides = [
          [this.bids, message.bids],
          [this.asks, message.asks],
        ] as const;
        for (const [side, levels] of sides) {
          for (const [price, size] of levels) {
            if (size === '0') side.delete(price);
            else side.set(price, size);
          }
        }
        return true;
      }
    
      best(): { bid?: Level; ask?: Level } {
        // Number() only orders the levels. Keep the strings for display and maths.
        const sorted = (side: Map<string, string>, direction: number): Level[] =>
          [...side].sort((a, b) => direction * (Number(a[0]) - Number(b[0])));
        return { bid: sorted(this.bids, -1)[0], ask: sorted(this.asks, 1)[0] };
      }
    }
    
    function onPrice(message: PriceMessage): void {
      if (message.price === null) {
        console.log(`${message.symbol} halted`, message.concerns);
        return;
      }
      // BigInt keeps the nanoseconds exact; milliseconds fit a Date.
      const publishedMs = Number(BigInt(message.publish_time_ns) / 1000000n);
      console.log(
        `${message.symbol} ${message.price} ± ${message.conf} (${message.status})`,
        new Date(publishedMs).toISOString(),
        message.concerns,
      );
    }
    
    function onBook(book: LocalBook): void {
      if (!book.available) {
        console.log(`${BOOK}: no node has a valid book`);
        return;
      }
      const { bid, ask } = book.best();
      console.log(
        `${BOOK} from ${book.node}: bid ${bid?.[0]} x ${bid?.[1]}, ask ${ask?.[0]} x ${ask?.[1]}`,
      );
    }
    
    function connect(delayMs = 1000): void {
      const socket = new WebSocket(ENDPOINT);
      const book = new LocalBook();
      const send = (op: object) => socket.send(JSON.stringify(op));
    
      socket.onopen = () => {
        delayMs = 1000;
        send({ op: 'subscribe', prices: [PRICE], books: [BOOK] });
      };
    
      socket.onmessage = (event: MessageEvent<string>) => {
        const message = JSON.parse(event.data) as ServerMessage;
        switch (message.type) {
          case 'subscribed':
            if (message.unknown?.length) console.warn('not served:', message.unknown);
            break;
          case 'price':
            onPrice(message);
            break;
          case 'book':
            if (message.book !== BOOK) break;
            if (book.apply(message)) {
              onBook(book);
            } else {
              // A gap. A new subscribe brings a fresh snapshot.
              send({ op: 'unsubscribe', books: [BOOK] });
              send({ op: 'subscribe', books: [BOOK] });
            }
            break;
          case 'error':
            console.error(message.code, message.message);
            break;
        }
      };
    
      // A refused connect (HTTP 429) also ends here. Wait longer each time.
      socket.onclose = () => {
        setTimeout(() => connect(Math.min(delayMs * 2, 30000)), delayMs);
      };
    }
    
    connect();

    Notes on the client:

    • A new socket starts with an empty book. Its first book message is a snapshot.
    • Two messages after a gap stay well inside the default limit of 10 messages a second.
    • Book names come from /v1/catalog. Take them from there, not from a guess.