Documentation

Examples

Working code for the Hyperliquid WebSocket feed: a smoke test, a minimal client, several markets on one connection, keeping a book in sync from l2Diff, and reconnecting.

// updated 2026-09-09

Every example below uses the same endpoint and the same header.

wss://toy.hl.supanode.xyz:48080/ws
x-token: YOUR_KEY

Smoke test

One command, no code. It opens a connection, subscribes to the BTC book and prints frames until you stop it.

{ echo '{"method":"subscribe","subscription":{"type":"l2Book","coin":"BTC"}}'; sleep infinity; } \
  | websocat wss://toy.hl.supanode.xyz:48080/ws -H='x-token: YOUR_KEY'

If the handshake fails, the status code tells you why: 401 means the header did not arrive, 403 means the key is not valid for this endpoint.

Minimal client

import asyncio, json, websockets

URL = "wss://toy.hl.supanode.xyz:48080/ws"
HEADERS = {"x-token": "YOUR_KEY"}

async def main():
    async with websockets.connect(URL, additional_headers=HEADERS, ping_interval=20) as ws:
        await ws.send(json.dumps({
            "method": "subscribe",
            "subscription": {"type": "l2Book", "coin": "BTC"},
        }))
        async for raw in ws:
            msg = json.loads(raw)
            if msg.get("channel") != "l2Book":
                continue
            bids, asks = msg["data"]["levels"]
            print(msg["data"]["time"], bids[0]["px"], asks[0]["px"], len(bids))

asyncio.run(main())
WARNING

Run this on a server, not in a browser. The key travels as a handshake header, and browser JavaScript cannot set one.

Several markets on one connection

Send one subscribe message per market and read the coin field to tell the frames apart.

for coin in ("BTC", "ETH", "SOL", "HYPE"):
    await ws.send(json.dumps({
        "method": "subscribe",
        "subscription": {"type": "l2Book", "coin": coin, "nLevels": 100},
    }))

Spot pairs use their index from spotMeta instead of a symbol:

await ws.send(json.dumps({
    "method": "subscribe",
    "subscription": {"type": "l2Book", "coin": "@107"},
}))

Deep book without the bandwidth: l2Diff

At 1000 levels a full snapshot on every block is a large frame. l2Diff sends the snapshot once and then only what changed. The client keeps the book and applies updates in order.

Two shapes to handle: a snapshot level is an object, an update level is a three-value array, and a removal is a bare price string.

book = {"bids": {}, "asks": {}, "height": None}

def apply(msg):
    data = msg["data"]

    # A snapshot arrives first, and again if the feed restarts your view.
    if "Snapshot" in data:
        snap = data["Snapshot"]
        bids, asks = snap["levels"]                    # one list of two halves
        book["bids"] = {l["px"]: (l["sz"], l["n"]) for l in bids}
        book["asks"] = {l["px"]: (l["sz"], l["n"]) for l in asks}
        book["height"] = snap["height"]
        return True

    upd = data["Updates"]

    # Heights skip blocks that changed nothing, so compare - never count.
    if upd["prevHeight"] != book["height"]:
        book["height"] = None            # our copy is stale: wait for a snapshot
        return False

    for side in ("bids", "asks"):
        changes = upd.get(side, {})
        for px, sz, n in changes.get("upd", []):      # array, not an object
            book[side][px] = (sz, n)
        for px in changes.get("del", []):             # a plain price string
            book[side].pop(px, None)

    book["height"] = upd["height"]
    return True

Three rules make this safe:

  1. Parse the two level shapes separately. {"px": ..., "sz": ..., "n": ...} in a snapshot, [px, sz, n] in an update, a bare price string in del. The two sides are also arranged differently: a snapshot carries one levels list of two halves, exactly like l2Book, while an update carries separate bids and asks objects.
  2. Never apply an update whose prevHeight does not match the height you last applied. A mismatch means your copy has diverged. Drop it and rebuild from the next snapshot. Do not expect heights to increase by one — blocks that changed nothing inside your window are not sent.
  3. A snapshot can arrive at any time, not only as the first message. Treat it as a fresh start rather than an error: drop the book you were keeping and rebuild from it.

Unsubscribing

The unsubscribe object must repeat the subscription exactly, including nLevels.

# Subscribed with nLevels: 100 - so unsubscribe with nLevels: 100.
await ws.send(json.dumps({
    "method": "unsubscribe",
    "subscription": {"type": "l2Book", "coin": "BTC", "nLevels": 100},
}))

Reconnecting

Subscriptions are not restored for you. Keep the list you sent and replay it after every reconnect.

SUBS = [
    {"type": "l2Book", "coin": "BTC", "nLevels": 100},
    {"type": "trades", "coin": "BTC"},
]

async def run_forever():
    while True:
        try:
            async with websockets.connect(URL, additional_headers=HEADERS, ping_interval=20) as ws:
                for sub in SUBS:
                    await ws.send(json.dumps({"method": "subscribe", "subscription": sub}))
                async for raw in ws:
                    handle(json.loads(raw))
        except Exception:
            await asyncio.sleep(1)       # then reconnect and replay SUBS

Keep-alive

ping_interval in the examples above uses standard WebSocket ping frames, which the server answers. If your client library does not send them, use the application ping instead:

{ "method": "ping" }

The answer is {"channel":"pong"}.

See also

  • Streams — the shape of every frame.
  • Limits — depth ceiling and subscriptions per connection.