WebSocket General Info — Spot
Basic connection guide, authentication, channel listing, and protocol rules for the ZTDX spot WebSocket.
Base URL
| Environment | WebSocket URL |
|---|---|
| Testnet (Sepolia) | wss://api-sepolia.p99.world/ws |
| Mainnet | (not deployed yet) |
The spot WebSocket shares the same endpoint as the perp WebSocket. A single connection can subscribe to any mix of spot and perp channels simultaneously.
Connection
- All messages are JSON text frames (RFC 6455).
- Send a
pingevery 30 seconds to keep the connection alive. - A single connection supports multiple concurrent subscriptions.
- Unknown spot channels are rejected with
INVALID_CHANNEL; the connection stays open.
Connection Example
- JavaScript
- Python
const ws = new WebSocket('wss://api-sepolia.p99.world/ws');
ws.onopen = () => {
console.log('Connected');
// Public channel — no auth needed
ws.send(JSON.stringify({
type: 'subscribe',
channel: 'spot:depth:DFUSDT',
}));
// Keep-alive
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
console.log('Received:', msg);
};
ws.onerror = (err) => console.error('WS error:', err);
ws.onclose = () => console.log('Disconnected');
import asyncio
import json
import websockets
async def main():
uri = 'wss://api-sepolia.p99.world/ws'
async with websockets.connect(uri) as ws:
print('Connected')
# Public channel — no auth needed
await ws.send(json.dumps({
'type': 'subscribe',
'channel': 'spot:depth:DFUSDT',
}))
async def heartbeat():
while True:
await asyncio.sleep(30)
await ws.send(json.dumps({'type': 'ping'}))
asyncio.create_task(heartbeat())
async for message in ws:
msg = json.loads(message)
print('Received:', msg)
asyncio.run(main())
Message Format
Client → Server
type | Description | Auth required |
|---|---|---|
auth | Authenticate via EIP-712 signature or listenKey | — |
auth_token | Authenticate via JWT token | — |
subscribe | Subscribe to a channel (may include inline token) | Private channels only |
unsubscribe | Unsubscribe from a channel | No |
ping | Heartbeat | No |
Server → Client
type | Description |
|---|---|
auth_result | Authentication outcome (success: true/false) |
subscribed | Subscription confirmed |
unsubscribed | Unsubscription confirmed |
pong | Response to ping |
error | Error frame (code + message) |
spot_depth_snapshot | Full order-book snapshot (on subscribe to spot:depth:*) |
spot_depth_diff | Changed levels since last update |
spot_trade | Single public fill |
spot_ticker | 24 h rolling statistics |
spot_kline_snapshot | Latest candle (on subscribe to spot:kline:*) |
spot_kline_update | Running OHLCV update for the current interval |
spot_user_order | Order lifecycle event for the authenticated user |
spot_user_balance | Balance row change for the authenticated user |
All push frames are wrapped as:
{
"type": "spot_xxx",
"channel": "spot:depth:DFUSDT",
"data": { ... }
}
Authentication
Authentication is only required for private channels (spot:user:orders, spot:user:balances). Public market-data channels work without it.
Three methods are supported (identical to the perp WebSocket — see perp WebSocket general info for full payload shapes). The simplest is JWT:
JWT (auth_token)
{ "type": "auth_token", "token": "eyJhbGciOiJIUzI1..." }
Server response:
{ "type": "auth_result", "success": true, "message": null }
On failure:
{ "type": "auth_result", "success": false, "message": "Invalid or expired token" }
EIP-712 (auth)
{
"type": "auth",
"address": "0xYourWalletAddress",
"signature": "0x...",
"timestamp": 1778400000
}
timestamp is UNIX seconds, must be within 5 minutes of server time.
listenKey (auth)
{ "type": "auth", "listenKey": "a1b2c3d4e5f6..." }
Obtain a listenKey via POST /fapi/v1/listenKey. A long-running WebSocket connection acts as implicit keepalive — no periodic PUT /fapi/v1/listenKey needed while the socket is open.
Inline token on subscribe
{ "type": "subscribe", "channel": "spot:user:orders", "token": "eyJhbGciOiJIUzI1..." }
The server authenticates automatically before processing the subscription.
Channels at a Glance
| Channel | Auth | Snapshot on subscribe | Live updates |
|---|---|---|---|
spot:depth:{symbol} | — | yes — top 1 000 levels | diffs on every book mutation |
spot:trade:{symbol} | — | — | one push per public fill |
spot:ticker:{symbol} | — | yes — current 24 h row | on fill + 60 s recompute |
spot:kline:{symbol}:{interval} | — | yes — latest candle | on every fill in the interval |
spot:user:orders | required | — | place / fill / cancel / reject |
spot:user:balances | required | — | per affected (token) row |
{symbol} is DFUSDT in MVP. {interval} ∈ 1m / 5m / 15m / 1h / 4h / 1d — see Enums.
Subscribe / Unsubscribe / Ping
Subscribe
{ "type": "subscribe", "channel": "spot:depth:DFUSDT" }
Response:
{ "type": "subscribed", "channel": "spot:depth:DFUSDT" }
If a snapshot is available for this channel, it is sent immediately after the subscribed ack.
Unsubscribe
{ "type": "unsubscribe", "channel": "spot:depth:DFUSDT" }
Response:
{ "type": "unsubscribed", "channel": "spot:depth:DFUSDT" }
Ping / Pong
{ "type": "ping" }
Response:
{ "type": "pong" }
Snapshot ↔ Live Sequencing
spot:depth:* — must sync
- Send subscribe → server queues a
spot_depth_snapshotand starts buffering livespot_depth_diffevents. - Apply the snapshot to seed your local order book.
- Discard any buffered diff whose
update_id_last <= snapshot.last_update_id. - Apply remaining diffs in arrival order. Each diff's
update_id_firstshould equal the previous diff'supdate_id_last + 1. A gap means frames were dropped — re-subscribe to get a fresh snapshot.
spot:ticker:* and spot:kline:* — idempotent, no resync needed
Each push replaces the previous value completely. A missed update is overwritten by the next one within seconds.
spot:user:* — seed from REST, then keep in sync
No initial snapshot is sent. After authenticating:
- Call
GET /spot/ordersto seed your local order state. - Call
GET /spot/balancesto seed your local balance state. - Apply live
spot_user_orderandspot_user_balancepushes to keep state current.
Backpressure
Each connection has a per-socket send queue capped at 256 frames. If the client cannot drain the queue fast enough, frames are silently dropped on overflow.
Client-side indicator: a gap in update_id_first / update_id_last on depth diffs signals dropped frames — re-subscribe to depth to resync.
Server-side indicator: a lagged warning appears in server logs when the internal broadcast channel overflows.
Errors
{ "type": "error", "code": "INVALID_CHANNEL", "message": "Unknown channel: spot:wat" }
| Code | Trigger |
|---|---|
INVALID_CHANNEL | Unknown channel name (e.g. spot:depths:DFUSDT — note the typo). |
AUTH_REQUIRED | Subscribed to spot:user:* without authenticating first. |
INVALID_MESSAGE | Frame could not be parsed as a valid client message. |
See also Error Codes for REST-level codes.