Skip to main content

WebSocket General Info — Spot

Basic connection guide, authentication, channel listing, and protocol rules for the ZTDX spot WebSocket.

Base URL

EnvironmentWebSocket 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 ping every 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

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');

Message Format

Client → Server

typeDescriptionAuth required
authAuthenticate via EIP-712 signature or listenKey
auth_tokenAuthenticate via JWT token
subscribeSubscribe to a channel (may include inline token)Private channels only
unsubscribeUnsubscribe from a channelNo
pingHeartbeatNo

Server → Client

typeDescription
auth_resultAuthentication outcome (success: true/false)
subscribedSubscription confirmed
unsubscribedUnsubscription confirmed
pongResponse to ping
errorError frame (code + message)
spot_depth_snapshotFull order-book snapshot (on subscribe to spot:depth:*)
spot_depth_diffChanged levels since last update
spot_tradeSingle public fill
spot_ticker24 h rolling statistics
spot_kline_snapshotLatest candle (on subscribe to spot:kline:*)
spot_kline_updateRunning OHLCV update for the current interval
spot_user_orderOrder lifecycle event for the authenticated user
spot_user_balanceBalance 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

ChannelAuthSnapshot on subscribeLive updates
spot:depth:{symbol}yes — top 1 000 levelsdiffs on every book mutation
spot:trade:{symbol}one push per public fill
spot:ticker:{symbol}yes — current 24 h rowon fill + 60 s recompute
spot:kline:{symbol}:{interval}yes — latest candleon every fill in the interval
spot:user:ordersrequiredplace / fill / cancel / reject
spot:user:balancesrequiredper 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

  1. Send subscribe → server queues a spot_depth_snapshot and starts buffering live spot_depth_diff events.
  2. Apply the snapshot to seed your local order book.
  3. Discard any buffered diff whose update_id_last <= snapshot.last_update_id.
  4. Apply remaining diffs in arrival order. Each diff's update_id_first should equal the previous diff's update_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:

  1. Call GET /spot/orders to seed your local order state.
  2. Call GET /spot/balances to seed your local balance state.
  3. Apply live spot_user_order and spot_user_balance pushes 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" }
CodeTrigger
INVALID_CHANNELUnknown channel name (e.g. spot:depths:DFUSDT — note the typo).
AUTH_REQUIREDSubscribed to spot:user:* without authenticating first.
INVALID_MESSAGEFrame could not be parsed as a valid client message.

See also Error Codes for REST-level codes.