ZetariumZetariumDex

WebSocket

Public market-data channels, message shapes, and reconnect rules.

Endpoint

wss://api.zetariumdex.com/ws

Connection 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)

ChannelPayloadFrequency
orderbooktop 50 bids / asks~250 ms
tradesrecent market tradesreal-time
ticker24h stats + lastPricereal-time delta + periodic full
kline1-minute candles (last 300)5 s
markpricemark 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. subscribe doesn't grant you anything you weren't already getting; treat it as a no-op for the default-set channels. It only matters for markprice and the private account channel.
  • 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:

  1. Cookie session — automatic in browsers. The zd_session HttpOnly cookie (set by POST /v2/auth/verify) is sent on the WS upgrade and the server binds the connection on connect.
  2. 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):

  • timestamp must be within ±5s of server time
  • signature is 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-orders every 1 – 3 s (rate limit: 1000/min default)
  • GET /v2/positions every 3 – 5 s
  • GET /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, 16s capped at 30s, 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:
    1. {action:"auth", token} (only if you use the private account channel) — and re-mint the JWT first if it might have expired during the outage.
    2. Every explicit subscribe you depend on (markprice and any account-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 is full_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();

On this page