API documentation

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.
  • sign is 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 read permission.

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). symbol without exchange, symbol on balance/positions, or any filter on botStatus gives 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 the id of 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:

  1. Open a new socket → receive a fresh challenge → authenticate again.
  2. Re-subscribe to everything you need (keep your subscription list client-side, as the complete client does).
  3. Back off exponentially with jitter: 1 s → 2 s → 4 s → … capped at 30 s; reset after a successful auth.
  4. Special cases: 1008 → slow your message rate first; 4001 → complete auth within 5 s and check your clock; 4003 → stop, the key is revoked.
  5. 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

Cookie Preferences

Manage your cookie preferences below. Necessary cookies are always active as they are essential for the website to function properly.

Strictly Necessary

Essential for the website to function. These cookies cannot be disabled.

Analytics

Help us understand how visitors interact with our website by collecting and reporting information anonymously.

Marketing

Used to track visitors across websites to display relevant advertisements.

Preferences

Allow the website to remember choices you make, such as language or region.