Spot WebSocket
Real-time push channels for the spot order-book venue (CLOB). Six channels are exposed: four public market-data channels and two authenticated per-user channels.
The spot feeds share the same WebSocket endpoint as the perp feeds — open one connection and subscribe to whichever mix of perp + spot channels you need. Auth, ping/pong, and subscribe / unsubscribe semantics match the perp WebSocket general info.
Endpoint
| Environment | URL |
|---|---|
| Testnet | wss://api-sepolia.p99.world/ws |
| Mainnet | (not deployed yet) |
All frames are JSON text. Send {"type":"ping"} periodically (every 30 s is plenty) to keep the connection alive.
Channels at a glance
| Channel | Auth | Snapshot on subscribe | Live |
|---|---|---|---|
spot:depth:{symbol} | — | yes (top 1000 levels) | diffs on book mutation |
spot:trade:{symbol} | — | — | one push per public fill |
spot:ticker:{symbol} | — | yes (current 24h row) | one push per fill (and the periodic 60 s recompute) |
spot:kline:{symbol}:{interval} | — | yes (latest candle) | one push per fill that touches the interval |
spot:user:orders | required | — | one push per place / fill / cancel / reject |
spot:user:balances | required | — | one push per (token) row that changed |
{interval} ∈ 1m / 5m / 15m / 1h / 4h / 1d. MVP only lists DFUSDT so {symbol} is DFUSDT.
Unknown spot channels are rejected with INVALID_CHANNEL instead of silently accepted.
Authentication
Private spot channels (spot:user:orders, spot:user:balances) use the same auth flow as private perp channels. The simplest path is JWT:
{"type": "auth_token", "token": "<JWT>"}
Or pass token directly in the subscribe frame:
{"type": "subscribe", "channel": "spot:user:orders", "token": "<JWT>"}
EIP-712 signature and Binance-style listenKey also work — see the perp auth section for the full payload shapes. Once authenticated, the server filters every spot user push by your wallet address before forwarding, so two users on the same connection (not currently supported) would only see their own rows.
Public channels
spot:depth:{symbol}
A snapshot is sent immediately on subscribe (type: "spot_depth_snapshot"). After that, only the affected price levels are pushed on each book mutation (place / cancel / fill).
Snapshot
{
"type": "spot_depth_snapshot",
"channel": "spot:depth:DFUSDT",
"data": {
"symbol": "DFUSDT",
"last_update_id": 12345,
"bids": [["0.5000", "100"], ["0.4999", "200"]],
"asks": [["0.5001", "150"], ["0.5002", "180"]]
}
}
Diff
{
"type": "spot_depth_diff",
"channel": "spot:depth:DFUSDT",
"data": {
"symbol": "DFUSDT",
"update_id_first": 12346,
"update_id_last": 12346,
"bids": [["0.5000", "70"]],
"asks": []
}
}
| Field | Notes |
|---|---|
last_update_id / update_id_* | Monotonic engine counter. Bumped on every place / cancel / fill. Use it to confirm no diffs were dropped. |
bids / asks | [price, total_qty_at_level]. total_qty == "0" means the level is empty and should be removed locally. |
Only price levels that changed are included in a diff — a fill that consumes one bid level and partially fills another sends two entries, not the whole book.
spot:trade:{symbol}
One push per public fill. No snapshot — call GET /spot/trades first if you need historical tape.
{
"type": "spot_trade",
"channel": "spot:trade:DFUSDT",
"data": {
"trade_id": "9f2a-…",
"symbol": "DFUSDT",
"side": "buy",
"price": "0.5000",
"quantity": "30",
"ts": 1778400000123
}
}
side is the taker side of the cross. ts is unix milliseconds.
spot:ticker:{symbol}
Snapshot of the current 24h row on subscribe (type: "spot_ticker"), then a spot_ticker update on every fill plus the periodic 60 s recompute.
{
"type": "spot_ticker",
"channel": "spot:ticker:DFUSDT",
"data": {
"symbol": "DFUSDT",
"last_price": "0.5",
"open_price": "0.48",
"high": "0.51",
"low": "0.47",
"volume": "10000",
"quote_volume": "5000",
"trade_count": 234,
"open_time": 1778313600,
"close_time": 1778400000,
"ts": 1778400000
}
}
open_time / close_time / ts are unix seconds. The window is [now-86400, now].
spot:kline:{symbol}:{interval}
Snapshot of the latest candle on subscribe (type: "spot_kline_snapshot"). After that, every fill that lands inside the candle pushes spot_kline_update with the new running OHLCV.
{
"type": "spot_kline_update",
"channel": "spot:kline:DFUSDT:1m",
"data": {
"symbol": "DFUSDT",
"interval": "1m",
"open_time": 1778313600,
"close_time": 1778313659,
"open": "0.48",
"high": "0.51",
"low": "0.47",
"close": "0.50",
"volume": "10000",
"quote_volume": "4900",
"trade_count": 234,
"is_closed": false
}
}
| Field | Notes |
|---|---|
open_time / close_time | Unix seconds. close_time = open_time + interval - 1. |
is_closed | true once the candle's window has elapsed. Watch for the transition to advance to the next candle. |
The aggregator updates one candle per interval per fill, so subscribing to 1m + 5m + 1h is six pushes (one per interval) on every fill.
Private channels
spot:user:orders
Pushed on order place, fill (one push per fill, for both maker and taker — but you only see your own), cancel, and reject.
{
"type": "spot_user_order",
"channel": "spot:user:orders",
"data": {
"id": "8b3d-…",
"symbol": "DFUSDT",
"side": "buy",
"type": "limit",
"tif": "GTC",
"price": "0.5",
"quantity": "100",
"quote_quantity": null,
"filled_qty": "30",
"avg_fill_price": "0.5",
"status": "partially_filled",
"reject_reason": null,
"updated_at": 1778400000,
"last_fill": {
"trade_id": "9f2a-…",
"price": "0.5",
"quantity": "30",
"fee": "0.015",
"fee_token": "DF"
}
}
}
| Field | Notes |
|---|---|
status | new / open / partially_filled / filled / canceled / rejected. Same vocabulary as GET /spot/orders. |
last_fill | Present only when the push was caused by a fill. Omitted on plain place / cancel / reject. |
last_fill.fee_token | The token the fee was deducted from — DF if you received DF (you're the taker on a buy or maker on a sell), USDT if you received USDT. |
quote_quantity | Set when the order was a market BUY priced in quote (quoteQuantity in the REST request); null otherwise. |
updated_at is unix seconds.
spot:user:balances
Pushed when the running spot balance for one of your (token) rows changes — fills, admin credit, internal transfer between perp margin and spot wallet. One push per affected (token), so a fill that moves both DF and USDT sends two messages.
{
"type": "spot_user_balance",
"channel": "spot:user:balances",
"data": {
"token": "DF",
"available": "10000",
"frozen": "100",
"ts": 1778400000
}
}
| Field | Notes |
|---|---|
available | Free balance (matches GET /spot/balances). |
frozen | Reserved for in-flight withdrawals or open orders. |
ts | Unix seconds. |
Errors
{ "type": "error", "code": "INVALID_CHANNEL", "message": "Unknown channel: spot:wat" }
| Code | When |
|---|---|
INVALID_CHANNEL | Unknown channel name (any spot prefix typo, e.g. spot:depths:…). |
AUTH_REQUIRED | Subscribed to spot:user:* without authenticating first. |
INVALID_MESSAGE | Frame failed to parse. |
Snapshot ↔ live sequencing
For spot:depth:*:
- Subscribe → server queues a
spot_depth_snapshotand immediately starts buffering livespot_depth_diffevents for that symbol. - Apply the snapshot.
- Apply each subsequent diff in arrival order. The snapshot's
last_update_idis the diff'supdate_id_last - 1(or earlier if you subscribed mid-burst — discard diffs whoseupdate_id_last <= last_update_id).
For spot:ticker:* and spot:kline:* the snapshot and subsequent updates are idempotent — a missed update is overwritten by the next one within seconds, so no resync logic is needed.
For spot:user:* channels, no initial snapshot is sent. Call GET /spot/orders and GET /spot/balances once after authenticating to seed your local state, then keep it in sync from the live pushes.
Backpressure
Each connection has a per-socket queue capped at 256 frames. If a client can't drain quickly enough, frames are silently dropped on overflow. Watch your application logs for lagged warnings server-side; client-side, treat any large gap in update_id_* as "snapshot needed" and re-subscribe to depth.
Code example
const ws = new WebSocket("wss://api-sepolia.p99.world/ws");
ws.onopen = () => {
// Auth (required only for spot:user:*)
ws.send(JSON.stringify({ type: "auth_token", token: "<JWT>" }));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "auth_result" && msg.success) {
ws.send(JSON.stringify({ type: "subscribe", channel: "spot:depth:DFUSDT" }));
ws.send(JSON.stringify({ type: "subscribe", channel: "spot:trade:DFUSDT" }));
ws.send(JSON.stringify({ type: "subscribe", channel: "spot:ticker:DFUSDT" }));
ws.send(JSON.stringify({ type: "subscribe", channel: "spot:kline:DFUSDT:1m" }));
ws.send(JSON.stringify({ type: "subscribe", channel: "spot:user:orders" }));
ws.send(JSON.stringify({ type: "subscribe", channel: "spot:user:balances" }));
}
if (msg.type === "spot_depth_snapshot") { /* seed local book */ }
if (msg.type === "spot_depth_diff") { /* apply changed levels */ }
if (msg.type === "spot_trade") { /* tape print */ }
if (msg.type === "spot_ticker") { /* update header stats */ }
if (msg.type === "spot_kline_update") { /* repaint live candle */ }
if (msg.type === "spot_user_order") { /* reflect own order state */ }
if (msg.type === "spot_user_balance") { /* update own wallet display */ }
};
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
}, 30000);