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())
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:
- Parse the two level shapes separately.
{"px": ..., "sz": ..., "n": ...}in a snapshot,[px, sz, n]in an update, a bare price string indel. The two sides are also arranged differently: a snapshot carries onelevelslist of two halves, exactly likel2Book, while an update carries separatebidsandasksobjects. - Never apply an update whose
prevHeightdoes 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. - 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"}.