WebSocket
Public market-data channels, message shapes, and reconnect rules.
Endpoint
wss://api.zetariumdex.com/wsConnection lifecycle
1. Client → connect
2. Server → { event: "connected", channels: ["orderbook","trades","ticker","kline"], symbols: [...], pollIntervalMs: 2000 }
3. Client → { action: "subscribe", channel: "orderbook", symbol: "BTCUSDT" }
4. Server → { event: "subscribed", channels: [...], symbols: [...] }
5. Server (periodic) → { channel: "orderbook", symbol: "BTCUSDT", data: { ... }, ts: 1714... }
6. Client (≤ 25 s) → { action: "ping" }
7. Server → { event: "pong", ts: 1714... }Public channels (no auth)
| Channel | Payload | Frequency |
|---|---|---|
orderbook | top 50 bids / asks | ~250 ms |
trades | recent market trades | real-time |
ticker | 24h stats + lastPrice | real-time delta + periodic full |
kline | 1-minute candles (last 300) | 5 s |
markprice | mark price stream | ~1 s |
The connect-default channel set is ["orderbook","trades","ticker","kline"].
markprice exists but is not auto-subscribed — you have to explicitly
{action:"subscribe", channel:"markprice", symbol:"..."}.
Auto-subscribe behavior — heads-up. As soon as you complete the WS
upgrade the server begins pushing orderbook, trades, ticker, and
kline for every active symbol (≈ 144 today), regardless of any
subscribe action you do or don't send. Sustained throughput hovers
around 300 – 400 msg/s purely from the auto-stream. There is no opt-out.
Operational guidance:
- Filter client-side. A connected socket is a paid bandwidth cost — drop
messages whose
(channel, symbol)you don't care about as early as possible (in your WS message handler, before any deserialisation work beyond the channel/symbol fields). - Don't use a subscribe-only mental model.
subscribedoesn't grant you anything you weren't already getting; treat it as a no-op for the default-set channels. It only matters formarkpriceand the privateaccountchannel. - One socket per process. Multiplexing many traders' streams over one socket is fine; opening one socket per symbol multiplies the spam without delivering anything new.
- Heavy work off the message hot path. Even simple JSON.parse calls start to add up at 400 msg/s; if your handler does anything beyond a cheap filter, fan out to a bounded queue.
Server-side opt-in / per-symbol gating is tracked under Known Limitations → WebSocket auto-subscribe.
Subscribe / unsubscribe
{ "action": "subscribe", "channel": "orderbook", "symbol": "BTCUSDT" }
{ "action": "subscribe", "channel": "ticker", "symbol": "*" }
{ "action": "unsubscribe", "channel": "trades", "symbol": "BTCUSDT" }symbol: "*" subscribes to every active symbol on that channel.
Private channel (account)
The account channel pushes per-trader state in real time — positions, open
orders, balances — sourced from the venue WebSocket (sub-second latency from
fill to client). Strictly preferable to REST polling for any algo / bot
that cares about timeliness.
Two ways to authenticate:
- Cookie session — automatic in browsers. The
zd_sessionHttpOnly cookie (set byPOST /v2/auth/verify) is sent on the WS upgrade and the server binds the connection on connect. - API key → short-lived JWT — for bots, scripts, and non-browser clients.
Mint a JWT from your API-key signature, then send
{action:"auth",token}on the WS connection.
API-key auth flow
POST /v2/auth/api-key-jwt?timestamp=<ms>&signature=<hex>
Headers: x-api-key: <YOUR_API_KEY>
Body: {}
# signature = HMAC_SHA256(rawSecret, sortedQueryString_excluding_signature).hex
# Same scheme as REST endpoints — if you can sign a REST request you can
# sign this one.Response:
{
"ok": true,
"token": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 86400
}The JWT is valid for expiresIn seconds (24h by default). Re-mint when it
nears expiry; the server will push {event:"auth_expired"} on the WS and
drop the account binding when the token expires, keeping public channels
flowing.
Constraints (same as REST):
timestampmust be within ±5s of server timesignatureis single-use within 60s (replay-resistant)- IP whitelist on the API key, if set, applies
End-to-end example (Node.js)
import crypto from "crypto";
import WebSocket from "ws";
const API_KEY = process.env.ZD_API_KEY;
const API_SECRET = process.env.ZD_API_SECRET;
async function mintJwt() {
const timestamp = Date.now();
const params = new URLSearchParams({ timestamp: String(timestamp) });
params.sort();
const signature = crypto
.createHmac("sha256", API_SECRET)
.update(params.toString())
.digest("hex");
const res = await fetch(
`https://api.zetariumdex.com/v2/auth/api-key-jwt?${params}&signature=${signature}`,
{ method: "POST", headers: { "x-api-key": API_KEY }, body: "{}" },
);
const json = await res.json();
if (!json.ok) throw new Error(json.error);
return json.token;
}
const token = await mintJwt();
const ws = new WebSocket("wss://api.zetariumdex.com/ws");
ws.on("open", () => {
ws.send(JSON.stringify({ action: "auth", token }));
});
ws.on("message", (raw) => {
const msg = JSON.parse(raw);
switch (msg.event) {
case "authenticated":
console.log("Logged in. Awaiting full_snapshot…");
break;
case "full_snapshot":
// Initial state — replace local cache wholesale
console.log("snapshot", msg.data);
break;
case "position_update":
// Single position delta
console.log("position", msg.data);
break;
case "order_update":
// Single order delta
console.log("order", msg.data);
break;
case "balance_update":
console.log("balance", msg.data);
break;
case "auth_expired":
// JWT expired — re-mint and resume
mintJwt().then((t) => ws.send(JSON.stringify({ action: "auth", token: t })));
break;
}
});
setInterval(() => ws.send(JSON.stringify({ action: "ping" })), 25_000);Account event shapes
After {event:"authenticated"} the server pushes a full_snapshot once,
then deltas as state changes.
full_snapshot
{
"channel": "account",
"event": "full_snapshot",
"data": {
"balances": { /* venue futures-balance payload — typically { assets: [...] } */ },
"spotBalances": { /* venue spot-wallet payload — separate field, NOT nested under balances */ },
"openOrders": [ /* Order[] — backend canonical shape */ ],
"positions": [ /* Position[] — backend canonical shape */ ]
},
"ts": 1714123456789
}balances and spotBalances are two top-level fields, not a nested
{ futures, spot } object. Reading data.balances.futures returns
undefined — read data.balances.assets (futures wallet) and
data.spotBalances.assets (spot wallet) instead. The exact key inside
each balances payload depends on the venue's response shape; defensive
parsing is recommended.
order_new and order_canceled (API-emitted)
When the trader places or cancels an order through the REST API, the API process emits one of these events:
{
"channel": "account",
"event": "order_new",
"data": {
"orderId": "cmod...",
"venueOrderId": "darkex_order_id",
"symbol": "BTCUSDT",
"side": "BUY",
"type": "LIMIT",
"quantity": "0.01",
"price": "30000",
"status": "NEW"
},
"ts": 1714123456789
}
{
"channel": "account",
"event": "order_canceled",
"data": { "orderId": "darkex_order_id", "symbol": "BTCUSDT" },
"ts": 1714123456789
}These cover the REST-side lifecycle only — submission and explicit cancel. They do not carry fill information.
order_update (worker-emitted)
The background worker emits order_update when it sees the venue
flip an order's status (e.g. NEW → PARTIALLY_FILLED → FILLED). The
payload is the canonical Zetarium order shape:
{
"channel": "account",
"event": "order_update",
"data": {
"id": "cuid…",
"venueOrderId": "darkex_order_id",
"symbol": "BTCUSDT",
"side": "BUY",
"type": "LIMIT",
"status": "PARTIALLY_FILLED",
"price": "30000",
"quantity": "0.01",
"filledQty": "0.005"
},
"ts": 1714123456789
}Known limitation — order_update does not fire for MAKER fills. The
upstream venue does not push fill events for resting LIMIT orders (only
for TAKER aggressions), so the worker has nothing to relay. To detect
MAKER fills, watch position_update: any change in quantity while no
order_new was just sent is a MAKER fill. See
Known Limitations → MAKER fill visibility.
position_update
The shape depends on which side emitted it:
API-emitted (after a manual close, batch close, or close-and-switch flow):
{
"channel": "account",
"event": "position_update",
"data": {
"positionId": "venue_pos_123",
"id": "cmpos...",
"symbol": "BTCUSDT",
"quantity": "0",
"closed": true,
"closeReason": "MANUAL"
},
"ts": 1714123456789
}Worker-emitted (on every detected venue position delta — opens, adjustments, liquidations):
{
"channel": "account",
"event": "position_update",
"data": {
"positionId": "venue_pos_123",
"symbol": "BTCUSDT",
"quantity": "0.02",
"side": "LONG"
},
"ts": 1714123456789
}Neither emitter carries avgEntryPrice, markPrice, liquidationPrice,
leverage, or unrealizedPnl — pull those from GET /v2/positions (or
the next full_snapshot) when you need them. closed: true means
quantity: "0" and closeReason ∈ { MANUAL, LIQUIDATED, RECONCILED }.
balance_update
{
"channel": "account",
"event": "balance_update",
"data": {
"subAccountId": "darkex_sub_123",
"balances": [
{ "asset": "USDT", "free": "1500.50", "locked": "200.00" }
]
},
"ts": 1714123456789
}futures_account_update
Sent on margin/leverage/wallet changes — the venue's full futures account
delta passed through verbatim. Use this when you need fields beyond what
balance_update and position_update carry (e.g. cross-margin wallet
balances, isolated-margin per-position margin moves).
{
"channel": "account",
"event": "futures_account_update",
"data": { /* venue payload */ },
"ts": 1714123456789
}auth_expired
{ "event": "auth_expired" }Public channels continue. Re-mint JWT and re-send {action:"auth", token}
to resume account events.
Don't poll if you can subscribe. The account channel is sub-second from venue fill to your client. REST endpoints below are kept for completeness but should be a fallback, not a primary path.
Fallback REST polling (only if WS isn't an option):
GET /v2/futures/open-ordersevery 1 – 3 s (rate limit: 1000/min default)GET /v2/positionsevery 3 – 5 sGET /v2/futures/myTrades?fromId=<last_id>every 1 – 2 s (incremental)
Public channel message shapes
orderbook
{
"channel": "orderbook",
"symbol": "BTCUSDT",
"data": {
"lastUpdateId": 1234567,
"bids": [["30000.50", "0.5"]],
"asks": [["30001.00", "0.3"]]
},
"ts": 1714123456789
}trades
{
"channel": "trades",
"symbol": "BTCUSDT",
"data": [
{ "id": 12345, "price": "30000", "qty": "0.01", "time": 1714123456789, "isBuyerMaker": true }
],
"ts": 1714123456789
}ticker
{
"channel": "ticker",
"symbol": "BTCUSDT",
"data": {
"symbol": "BTCUSDT",
"lastPrice": "30100",
"priceChange": "+150",
"priceChangePercent": "0.5",
"highPrice": "30500",
"lowPrice": "29800",
"volume": "12345.67",
"quoteVolume": "370000000"
},
"ts": 1714123456789
}kline
{
"channel": "kline",
"symbol": "BTCUSDT",
"data": [
[
"openTime","open","high","low","close","volume",
"closeTime","quoteVolume","trades","takerBuyBase","takerBuyQuote","0"
]
],
"ts": 1714123456789
}markprice
{
"channel": "markprice",
"symbol": "BTCUSDT",
"data": { "symbol": "BTCUSDT", "markPrice": "30050.25" },
"ts": 1714123456789
}Subscribe — examples
import WebSocket from 'ws';
const ws = new WebSocket('wss://api.zetariumdex.com/ws');
ws.on('open', () => {
ws.send(JSON.stringify({
action: 'subscribe', channel: 'ticker', symbol: 'BTCUSDT',
}));
});
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.channel === 'ticker') {
console.log(`${msg.symbol} last=${msg.data.lastPrice}`);
}
});
// Heartbeat every 25s
setInterval(() => ws.send(JSON.stringify({ action: 'ping' })), 25_000);# pip install websockets
import asyncio, json, websockets
async def main():
async with websockets.connect('wss://api.zetariumdex.com/ws') as ws:
await ws.send(json.dumps({
'action': 'subscribe', 'channel': 'ticker', 'symbol': 'BTCUSDT',
}))
async for raw in ws:
msg = json.loads(raw)
if msg.get('channel') == 'ticker':
print(msg['symbol'], msg['data']['lastPrice'])
asyncio.run(main())// go get github.com/gorilla/websocket
import "github.com/gorilla/websocket"
conn, _, _ := websocket.DefaultDialer.Dial("wss://api.zetariumdex.com/ws", nil)
defer conn.Close()
_ = conn.WriteJSON(map[string]string{
"action": "subscribe", "channel": "ticker", "symbol": "BTCUSDT",
})
for {
_, raw, err := conn.ReadMessage()
if err != nil { return }
fmt.Println(string(raw))
}// Cargo.toml: tokio-tungstenite, futures-util, serde_json
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
let (mut ws, _) = tokio_tungstenite::connect_async("wss://api.zetariumdex.com/ws").await?;
ws.send(Message::Text(serde_json::json!({
"action": "subscribe", "channel": "ticker", "symbol": "BTCUSDT",
}).to_string())).await?;
while let Some(Ok(msg)) = ws.next().await {
if let Message::Text(txt) = msg {
println!("{txt}");
}
}// composer require textalk/websocket
use WebSocket\Client;
$client = new Client('wss://api.zetariumdex.com/ws');
$client->send(json_encode([
'action' => 'subscribe', 'channel' => 'ticker', 'symbol' => 'BTCUSDT',
]));
while (true) {
echo $client->receive(), PHP_EOL;
}Disconnect / reconnect
The connection is not intrinsically stable today — disconnects under sustained load (the auto-stream + your own activity) happen often enough that a robust reconnect strategy is mandatory for any algo / bot. Treat the socket as ephemeral and assume it will drop.
Server behavior
- Idle timeout — no explicit server-side timeout; Fastify WebSocket plugin defaults apply. There is no mandatory ping/pong cycle from the server, but staleness GC may cooperate with one.
- JWT expiry — when the account-channel auth expires, the server pushes
{ "event": "auth_expired" }and drops the account binding. Public channels keep flowing on the same socket. Re-auth (mint a new JWT, send{action:"auth", token}) to resume private events.
Client checklist
- Heartbeat. Send
{"action":"ping"}every 20 – 30 s. The server responds with{"event":"pong", "ts": ...}. Treat two consecutive missed pongs (≈ 60 s of silence after pings) as a dead socket and force-reconnect — don't wait for the OS to surface the TCP RST. - Exponential back-off with jitter. On disconnect, retry at
1s, 2s, 4s, 8s, 16scapped at30s, with±25 %random jitter. Hammering reconnect every 100 ms during an outage will make the cascade worse — the API does not currently apply WS-side rate limiting, so self-throttle. - Idempotent re-subscribe + re-auth. After every reconnect, replay:
{action:"auth", token}(only if you use the privateaccountchannel) — and re-mint the JWT first if it might have expired during the outage.- Every explicit
subscribeyou depend on (markpriceand anyaccount-channel binding). The default-set channels (orderbook,trades,ticker,kline) auto-resume — don't re-subscribe to them, it's a no-op.
- Rebuild local state from
full_snapshot. The first message after a successful re-auth isfull_snapshot. Discard your in-memory orderbook / position / open-order tables and replace them wholesale from this payload. Don't try to "merge in" stale data from before the disconnect. - Avoid blocking the read loop. A slow handler ⇒ TCP back-pressure ⇒ server-side disconnect. Drain the message stream into a bounded queue if your processing isn't strictly faster than the inbound rate (see the Auto-Subscribe callout above).
- Watch for connect storms. If you operate multiple bots, stagger their WS connects on process start (e.g. random delay 0 – 5 s); a thundering herd of fresh connections during a deploy is the most common cause of the "first reconnect fails repeatedly, then catches up" pattern.
Reconnect template (Node.js)
function connect() {
const ws = new WebSocket('wss://api.zetariumdex.com/ws');
let lastPongTs = Date.now();
let attempts = 0;
const heartbeat = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) return;
if (Date.now() - lastPongTs > 60_000) {
ws.terminate(); // two missed pongs → kill, retrigger reconnect
return;
}
ws.send(JSON.stringify({ action: 'ping' }));
}, 25_000);
ws.on('open', async () => {
attempts = 0;
const token = await mintJwt();
ws.send(JSON.stringify({ action: 'auth', token }));
// Re-subscribe to anything outside the default set:
ws.send(JSON.stringify({ action: 'subscribe', channel: 'markprice', symbol: 'BTCUSDT' }));
});
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.event === 'pong') lastPongTs = Date.now();
if (msg.event === 'full_snapshot') resetLocalState(msg.data);
if (msg.event === 'auth_expired') reauth();
// ...your normal handlers
});
ws.on('close', () => {
clearInterval(heartbeat);
const delayMs = Math.min(30_000, 1_000 * 2 ** attempts++) * (0.75 + Math.random() * 0.5);
setTimeout(connect, delayMs);
});
ws.on('error', () => ws.terminate()); // funnel into 'close'
}
connect();