WebSocket
The WebSocket delivers your account’s events in real time: order lifecycle, balance changes, position changes, bot status. One authenticated socket carries all channels.
| Endpoint | wss://api.lexx-trade.com/api/v2/ws |
| Delivery | Strictly per-user — a socket only ever receives events for the account that owns the key |
| Market data | None. No orderbook/ticker/candles — this socket is about your account (why) |
| Message format | JSON objects with an op discriminator; every server message carries ts (Unix ms); client messages may carry an id that is echoed back on the reply |
Prefer the WebSocket over REST polling for anything continuous — it is faster and does not consume rate limits.
Connect and authenticate
Authentication is a challenge–response with the same Ed25519 key pair you use for REST (key setup):
1. On connect, the server immediately sends a one-time challenge:
{ "op": "challenge", "data": { "challenge": "3f9a1c7e..." }, "ts": 1743360060000 }
2. You sign the concatenation "WSAuth" + timestamp + challenge with your private key and
reply:
{ "op": "auth", "id": "1",
"args": { "key": "x7Qb0Zr8kSxJ2rC9m0aB1nD3fE5gH7iJ9kL1mN3oP5q",
"sign": "k3rP9vQ2sT5w…==", "timestamp": 1743360060000 } }
3. The server confirms (errors arrive as error messages instead):
{ "op": "auth", "id": "1", "data": { "authenticated": true, "userId": "42" }, "ts": 1743360060050 }
Rules worth pinning:
key— your 43-char Base64url public key;timestamp— Unix ms, within ±5000 ms of server time.signis standard Base64 here (Base64url is also accepted) — note the difference from REST, which uses Base64url.- The challenge is single-use and connection-bound: a captured auth frame cannot be replayed, and re-authentication requires a reconnect.
- Auth must complete within 5 s of connecting and within 5 attempts, otherwise the
socket closes with code
4001. - The key’s IP whitelist is enforced; subscribing requires at least
readpermission.
A complete client
Reconnect with backoff, re-auth and re-subscribe built in — this skeleton is production-shaped:
// lexx-ws.mjs — Node 18+; npm i ws
import crypto from "node:crypto";
import fs from "node:fs";
import WebSocket from "ws";
const URL = "wss://api.lexx-trade.com/api/v2/ws";
const PUBLIC_KEY = process.env.LEXX_PUBLIC_KEY;
const PRIVATE_KEY = fs.readFileSync("lexx-api-key.pem", "utf8");
const SUBSCRIPTIONS = [
{ channel: "orders" },
{ channel: "balance", exchange: "binance-futures" },
{ channel: "positions", exchange: "binance-futures" },
{ channel: "botStatus" },
];
let attempt = 0;
const backoff = () => Math.min(30_000, 1000 * 2 ** attempt++) + 1000 * Math.random();
function connect() {
const ws = new WebSocket(URL);
let seq = 0;
const send = (op, args) => ws.send(JSON.stringify({ op, id: String(++seq), args }));
ws.on("message", (raw) => {
const msg = JSON.parse(raw);
switch (msg.op) {
case "challenge": {
const timestamp = Date.now();
const sign = crypto
.sign(null, Buffer.from(`WSAuth${timestamp}${msg.data.challenge}`), PRIVATE_KEY)
.toString("base64");
send("auth", { key: PUBLIC_KEY, sign, timestamp });
break;
}
case "auth":
attempt = 0; // connected — reset backoff
console.log("authenticated as user", msg.data.userId);
SUBSCRIPTIONS.forEach((args) => send("subscribe", args));
break;
case "subscribed":
console.log("subscribed:", msg.channel);
break;
case "update":
handleUpdate(msg.channel, msg.data); // ← your logic
break;
case "error":
console.error("WS error:", msg.data.code, msg.data.message);
break;
}
});
ws.on("close", (code) => {
if (code === 4003) { console.error("API key revoked — stopping"); return; }
setTimeout(connect, backoff()); // fresh challenge → re-auth → re-subscribe
});
ws.on("error", () => { /* 'close' follows — reconnect happens there */ });
}
function handleUpdate(channel, data) {
console.log(channel, JSON.stringify(data));
}
connect();
# lexx_ws.py — Python 3.9+; pip install websockets cryptography
import asyncio, base64, json, os, random, time
import websockets
from cryptography.hazmat.primitives.serialization import load_pem_private_key
URL = "wss://api.lexx-trade.com/api/v2/ws"
PUBLIC_KEY = os.environ["LEXX_PUBLIC_KEY"]
PRIVATE_KEY = load_pem_private_key(open("lexx-api-key.pem", "rb").read(), password=None)
SUBSCRIPTIONS = [
{"channel": "orders"},
{"channel": "balance", "exchange": "binance-futures"},
{"channel": "positions", "exchange": "binance-futures"},
{"channel": "botStatus"},
]
async def run():
attempt = 0
while True:
try:
async with websockets.connect(URL) as ws:
seq = 0
async for raw in ws:
msg = json.loads(raw)
op = msg.get("op")
if op == "challenge":
ts = int(time.time() * 1000)
payload = f"WSAuth{ts}{msg['data']['challenge']}".encode()
sign = base64.b64encode(PRIVATE_KEY.sign(payload)).decode()
seq += 1
await ws.send(json.dumps({"op": "auth", "id": str(seq),
"args": {"key": PUBLIC_KEY, "sign": sign, "timestamp": ts}}))
elif op == "auth":
attempt = 0 # connected — reset backoff
print("authenticated as user", msg["data"]["userId"])
for args in SUBSCRIPTIONS:
seq += 1
await ws.send(json.dumps({"op": "subscribe",
"id": str(seq), "args": args}))
elif op == "update":
handle(msg["channel"], msg["data"]) # ← your logic
elif op == "error":
print("WS error:", msg["data"]["code"], msg["data"]["message"])
except websockets.ConnectionClosed as e:
if e.code == 4003:
raise SystemExit("API key revoked — stopping")
except OSError:
pass # network error → reconnect
delay = min(30, 2 ** attempt) + random.random()
attempt += 1
await asyncio.sleep(delay)
def handle(channel, data):
print(channel, data)
asyncio.run(run())
Subscriptions and filters
Subscribe after authenticating; the ack echoes your id:
→ { "op": "subscribe", "id": "2", "args": { "channel": "orders", "exchange": "binance-spot", "symbol": "BTCUSDT" } }
← { "op": "subscribed", "id": "2", "channel": "orders", "ts": 1743360060060 }
unsubscribe takes the same args — they identify the subscription.
Filter support is per-channel:
| Channel | Allowed filters |
|---|---|
orders |
none · exchange · exchange + symbol |
balance |
none · exchange |
positions |
none · exchange |
botStatus |
none |
🕳️ The silent-subscription trap. An unsupported filter combination is still ACKed with
subscribed— but it will never deliver a single event (the server matches subscription keys literally).symbolwithoutexchange,symbolonbalance/positions, or any filter onbotStatusgives you a well-confirmed, permanently silent subscription. If a channel is quiet, check your filters first.
The four channels
orders — order lifecycle
ORDER_CREATE fires once per order; every later change — partial fills, fills,
cancellations — arrives as ORDER_UPDATE with the new status, executedQty, fillPrice:
{ "op": "update", "channel": "orders",
"data": { "event": "ORDER_UPDATE",
"order": { "id": "x-MZ8FjHFm-abc", "exchange": "binance-spot",
"symbol": "BTCUSDT", "side": "BUY", "type": "LIMIT",
"status": "CLOSED", "price": "65000.00", "origQty": "0.001",
"executedQty": "0.001", "fillPrice": "64998.50",
"orderTime": 1743360060000 } },
"ts": 1743360060123 }
The order object is the same PublicOrder
as in REST. There are no per-fill events — for execution-level history use
GET /orders/trades.
balance — balance changes
{ "op": "update", "channel": "balance",
"data": { "event": "BALANCE_UPDATE", "exchange": "binance-futures", "marketType": "FUTURES",
"changes": { "USDT": { "free": "10000.00", "wallet": "10500.00",
"locked": "500.00", "usd": "10500.00" } } },
"ts": 1743360060123 }
changes is keyed by asset ticker; each entry carries
{free, wallet, locked, lockedByBot?, usd?} — all string decimals. A changes.total entry may
accompany them with a wallet summary (ts, and on futures also mmr, imr, upnl,
crossUpnl, crossWallet). Spot and futures share this shape — tell them apart by
marketType on the envelope.
positions — position changes (futures)
{ "op": "update", "channel": "positions",
"data": { "event": "POSITION_UPDATE", "exchange": "binance-futures",
"changes": { "BTCUSDT": { "side": "LONG", "amount": "0.1",
"entryPrice": "64000.00",
"unrealizedProfit": "125.40",
"updateTime": 1743360060000 } } },
"ts": 1743360060123 }
changes is keyed by symbol, and each value is a partial position — only the fields
that changed are present. Field names mirror the REST
PublicPosition model. Bootstrap
your state from REST, then merge these deltas by symbol.
botStatus — bot lifecycle
{ "op": "update", "channel": "botStatus",
"data": { "event": "BOT_STOPPED", "bot": { "id": "bot123", "type": "FIBO", "status": "STOPPED" } },
"ts": 1743360060123 }
Events: BOT_STARTED, BOT_STOPPED, BOT_ERROR, BOT_UPDATE. The bot object is the
sanitized bot record (same deny-list as REST’s PublicBot) — an open object that commonly
carries id, type, status, exchange, symbol and may include runId and other
lifecycle fields.
Staying connected
- Heartbeat. The server sends a WebSocket control-ping every 20 s; standard clients
auto-reply. A socket that misses it is terminated within ~20–40 s. An application-level
{"op":"ping"}→{"op":"pong"}also exists if your stack needs it. - Read promptly. If unread data piles past the 4 MiB send-buffer backlog, the socket is
dropped without a close frame (surfaces as abnormal
1006). Slow consumers must offload processing, not block the read loop. - Unknown
ops are silently ignored after auth — no error reply. Never block waiting on theidof an op the server might not support.
Limits and close codes
DoS backstops — fixed, not scaled by subscription tier:
| Limit | Value | On breach |
|---|---|---|
| Connections per client IP | 50 | upgrade rejected 429 |
| Total connections per node | 10 000 | upgrade rejected 503 |
| Max frame size | 64 KiB | close 1009 (may surface as 1006 on some clients) |
| Messages per socket | 200 / 10 s | RATE_LIMITED error, then close 1008 |
| Auth attempts per socket | 5 | TOO_MANY_ATTEMPTS, then close 4001 |
| Send-buffer backlog | 4 MiB | terminated without close frame (1006) |
| Close code | Meaning | Reaction |
|---|---|---|
1008 |
Message-rate limit exceeded | Reduce your send rate, then reconnect |
1009 |
Frame exceeded 64 KiB | Fix the oversized frame |
4001 |
Auth timeout or too many attempts | Authenticate faster; check clock skew (±5 s) |
4003 |
API key revoked mid-session | Do not auto-reconnect — obtain a new key |
Reconnecting
Subscriptions are not persisted across connections, and the challenge is single-use — after any disconnect:
- Open a new socket → receive a fresh challenge → authenticate again.
- Re-subscribe to everything you need (keep your subscription list client-side, as the complete client does).
- Back off exponentially with jitter: 1 s → 2 s → 4 s → … capped at 30 s; reset after a successful auth.
- Special cases:
1008→ slow your message rate first;4001→ complete auth within 5 s and check your clock;4003→ stop, the key is revoked. - After reconnecting, reconcile state via REST (
GET /orders,GET /portfolio,GET /portfolio/positions) — events emitted while you were offline are not replayed.
Error codes
Delivered as { "op": "error", "data": { "code", "message" }, "ts" }; some are followed by a
close (see above).
| Code | Meaning |
|---|---|
AUTH_REQUIRED |
A non-auth op was sent before authenticating |
AUTH_TIMEOUT |
No successful auth within 5 s (→ close 4001) |
AUTH_IN_PROGRESS |
A second auth frame arrived while one was being processed |
CHALLENGE_MISSING |
Auth sent with no active challenge (consumed or never issued) — reconnect |
TIMESTAMP_INVALID / TIMESTAMP_EXPIRED |
Timestamp not a number / outside the ±5 s window |
INVALID_ARGS |
Missing key/sign/timestamp, unknown channel, or non-string exchange/symbol |
INVALID_KEY / INVALID_SIGNATURE |
Malformed key / Ed25519 verification failed |
UNAUTHORIZED |
Invalid or revoked key |
IP_REJECTED |
Client IP not in the key’s whitelist (REST equivalent: IP_NOT_WHITELISTED) |
INSUFFICIENT_PERMISSIONS |
Key lacks read permission for streaming |
TOO_MANY_ATTEMPTS |
More than 5 auth attempts (→ close 4001) |
KEY_REVOKED |
Key revoked mid-session (→ close 4003) |
RATE_LIMITED |
Message-rate cap exceeded (→ close 1008) |
INVALID_JSON |
Payload was not valid JSON |
INTERNAL_ERROR |
Unexpected server-side error |
Verified against AsyncAPI spec v2.0.0 · 2026-07-11