API documentation

Authentication

Every private endpoint of the LEXX API is authenticated with an Ed25519 digital signature. There is no API secret that travels with each request: you keep a private key on your machine, LEXX stores only the matching public key, and every request carries a one-time signature that proves it came from you. Even a full leak of LEXX’s database could not produce a single valid request on your behalf — that is the practical advantage over classic HMAC secrets.

Three headers accompany every private request:

Header What it is Format
X-LEXX-KEY Your public key — also serves as the key ID Base64url, exactly 43 characters ([A-Za-z0-9_-])
X-LEXX-SIGN Ed25519 signature of this specific request Base64url (no = padding)
X-LEXX-TIMESTAMP When you built the request Unix time in milliseconds, as a string; must be within ±5000 ms of server time

If you just want working code, jump to the reference implementation or start from the Quickstart.


Creating keys

Generate the key pair yourself — the private key never leaves your machine and LEXX never sees it. Any of the three tools below produces the same thing: a PEM file with the private key and a 43-character public key to register.

OpenSSL 3+

openssl genpkey -algorithm ed25519 -out lexx-api-key.pem
chmod 600 lexx-api-key.pem

# The 43-char public key (register this string with LEXX):
openssl pkey -in lexx-api-key.pem -pubout -outform DER | tail -c 32 \
  | python3 -c "import sys,base64;print(base64.urlsafe_b64encode(sys.stdin.buffer.read()).decode().rstrip('='))"

On macOS the system openssl may be LibreSSL — check openssl version. If so, use one of the generators below instead.

Node.js

// generate-key.mjs — Node 18+, zero dependencies
import crypto from "node:crypto";
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
console.log(privateKey.export({ type: "pkcs8", format: "pem" }));
console.log("Public key:",
  publicKey.export({ type: "spki", format: "der" }).subarray(-32).toString("base64url"));

Python

# generate_key.py — pip install cryptography
import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
    Encoding, PrivateFormat, PublicFormat, NoEncryption)

key = Ed25519PrivateKey.generate()
open("lexx-api-key.pem", "wb").write(
    key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()))
raw_pub = key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
print("Public key:", base64.urlsafe_b64encode(raw_pub).decode().rstrip("="))

All three produce a standard PKCS#8 PEM private key — one key file works across OpenSSL, Node and Python.

Registering and managing keys

Keys are managed only in the LEXX Terminal UI (Settings → API Keys) — the Public API deliberately has no key-management endpoints, so a leaked API key can never mint new keys.

When registering you choose:

Setting Meaning
Label A name for your own reference (e.g. prod-bot-1)
Tier read — read-only. trade — read + orders and bots. full — superset of trade; today no public endpoint requires more than trade
IP whitelist (optional, recommended) Up to 10 CIDR entries; requests from other addresses are rejected with 403 IP_NOT_WHITELISTED
Risk shields (optional) Per-key trading guard: allowedExchanges (the key may touch only these exchanges) — enforced server-side. Details in Security

Limits and lifecycle: up to 10 active keys per account · API-key availability depends on your plan (see billing in the terminal) · the tier scales rate limits · a revoked key is rejected immediately (open WebSocket sessions are closed with code 4003).


The signing rule

Every request is signed over one string — the concatenation, with no separators, of four parts:

message = timestamp + METHOD + path + body
# Part Rule
1 timestamp The exact string you send in X-LEXX-TIMESTAMP (Unix ms)
2 METHOD HTTP method, UPPERCASE: GET, POST, DELETE, PATCH
3 path The path relative to /api — it starts at /v2/...including the full query string exactly as sent
4 body Compact JSON (JSON.stringify, no extra whitespace) for requests with a body; the empty string otherwise

Sign the message with your Ed25519 private key and put the signature in X-LEXX-SIGN encoded Base64url without padding.

🔑 The golden rule: the bytes you sign must be the bytes you send. Build the path-with-query string and the body string once, sign that, and send exactly that. Never let your HTTP library re-serialize the body after signing.

Worked example — GET with query string

timestamp = 1743360060000
METHOD    = GET
path      = /v2/orders?exchange=binance-futures&symbol=BTCUSDT&status=open
body      = (empty)

message   = 1743360060000GET/v2/orders?exchange=binance-futures&symbol=BTCUSDT&status=open

Worked example — POST with body

timestamp = 1743360060000
METHOD    = POST
path      = /v2/orders
body      = {"exchange":"binance-spot","symbol":"BTCUSDT","side":"BUY","type":"LIMIT","quantity":"0.001","price":"60000.00","timeInForce":"GTC","marketType":"spot"}

message   = 1743360060000POST/v2/orders{"exchange":"binance-spot","symbol":"BTCUSDT","side":"BUY","type":"LIMIT","quantity":"0.001","price":"60000.00","timeInForce":"GTC","marketType":"spot"}

⚠️ The #1 mistake: signing the path from /api/v2/.... The signed path starts at /v2/... even though the URL you call is https://api.lexx-trade.com/api/v2/.... Mistake #2: forgetting the query string. Mistake #3: signing a pretty-printed body but sending a compact one (or vice versa).


Reference implementation

Production-grade helpers with clock-offset handling and typed errors. Both return the full { data, meta } envelope (the meta.requestId is what LEXX support will ask for).

Node.js (18+, zero dependencies)

// lexx-client.mjs
import crypto from "node:crypto";
import fs from "node:fs";

export class LexxClient {
  constructor({ publicKey, privateKeyPath = "lexx-api-key.pem",
                base = "https://api.lexx-trade.com/api" } = {}) {
    this.publicKey = publicKey;
    this.privateKey = fs.readFileSync(privateKeyPath, "utf8");
    this.base = base;
    this.timeOffset = 0;                       // serverTime − localTime, ms
  }

  /** Optional but recommended once at startup: absorbs local clock drift. */
  async syncTime() {
    const res = await fetch(`${this.base}/v2/server/time`);
    const { data } = await res.json();
    this.timeOffset = data.serverTime - Date.now();
    return this.timeOffset;
  }

  async request(method, path, { query, body, headers } = {}) {
    method = method.toUpperCase();
    const qs = query ? "?" + new URLSearchParams(query) : "";
    const pathWithQuery = `/v2${path}${qs}`;
    const timestamp = (Date.now() + this.timeOffset).toString();
    const payload = body ? JSON.stringify(body) : "";
    const message = timestamp + method + pathWithQuery + payload;
    const sign = crypto.sign(null, Buffer.from(message), this.privateKey)
                       .toString("base64url");

    const res = await fetch(this.base + pathWithQuery, {
      method,
      headers: {
        "X-LEXX-KEY": this.publicKey,
        "X-LEXX-SIGN": sign,
        "X-LEXX-TIMESTAMP": timestamp,
        ...(payload && { "Content-Type": "application/json" }),
        ...headers,                        // extra headers, e.g. X-Idempotency-Key
      },
      body: payload || undefined,
    });

    const json = await res.json();
    if (!res.ok) {
      const e = new Error(`${json.error?.code} (${res.status}): ${json.error?.message}`);
      e.code = json.error?.code;
      e.status = res.status;
      e.requestId = json.meta?.requestId;
      e.retryAfter = Number(res.headers.get("retry-after")) || undefined;  // seconds, on 429
      throw e;
    }
    return json;                               // { data, meta }
  }
}

// --- usage ---------------------------------------------------------------
// const lexx = new LexxClient({ publicKey: process.env.LEXX_PUBLIC_KEY });
// await lexx.syncTime();
// const { data } = await lexx.request("GET", "/portfolio",
//                                     { query: { exchange: "binance-spot" } });
// // Places a REAL order — use a tiny amount first:
// const order = await lexx.request("POST", "/orders", { body: {
//   exchange: "binance-spot", symbol: "BTCUSDT", side: "BUY", type: "LIMIT",
//   quantity: "0.001", price: "60000.00", timeInForce: "GTC", marketType: "spot",
// }});

Python (3.9+)

# lexx_client.py — pip install cryptography requests
import base64, json, time
import requests
from urllib.parse import urlencode
from cryptography.hazmat.primitives.serialization import load_pem_private_key


class LexxError(RuntimeError):
    def __init__(self, code, status, message, request_id=None, retry_after=None):
        super().__init__(f"{code} ({status}): {message}")
        self.code, self.status, self.request_id = code, status, request_id
        self.retry_after = retry_after          # seconds, set on 429


class LexxClient:
    def __init__(self, public_key, private_key_path="lexx-api-key.pem",
                 base="https://api.lexx-trade.com/api"):
        self.public_key = public_key
        self.private_key = load_pem_private_key(
            open(private_key_path, "rb").read(), password=None)
        self.base = base
        self.time_offset = 0                    # serverTime − local, ms
        self.session = requests.Session()

    def sync_time(self):
        """Optional but recommended once at startup: absorbs local clock drift."""
        data = self.session.get(f"{self.base}/v2/server/time").json()["data"]
        self.time_offset = data["serverTime"] - int(time.time() * 1000)
        return self.time_offset

    def request(self, method, path, query=None, body=None, headers=None):
        method = method.upper()
        qs = "?" + urlencode(query) if query else ""
        path_with_query = f"/v2{path}{qs}"
        timestamp = str(int(time.time() * 1000) + self.time_offset)
        payload = json.dumps(body, separators=(",", ":")) if body is not None else ""
        message = f"{timestamp}{method}{path_with_query}{payload}"
        sign = base64.urlsafe_b64encode(
            self.private_key.sign(message.encode())).decode().rstrip("=")

        hdrs = {"X-LEXX-KEY": self.public_key,
                "X-LEXX-SIGN": sign,
                "X-LEXX-TIMESTAMP": timestamp}
        if payload:
            hdrs["Content-Type"] = "application/json"
        if headers:
            hdrs.update(headers)                # extra headers, e.g. X-Idempotency-Key

        resp = self.session.request(method, self.base + path_with_query,
                                    headers=hdrs, data=payload or None)
        data = resp.json()
        if not resp.ok:
            err = data.get("error", {})
            ra = resp.headers.get("Retry-After")
            raise LexxError(err.get("code"), resp.status_code, err.get("message"),
                            data.get("meta", {}).get("requestId"),
                            retry_after=int(ra) if ra else None)
        return data                             # {"data": ..., "meta": ...}


# --- usage ----------------------------------------------------------------
# lexx = LexxClient(public_key=os.environ["LEXX_PUBLIC_KEY"])
# lexx.sync_time()
# balance = lexx.request("GET", "/portfolio", query={"exchange": "binance-spot"})

Test vector

Ed25519 is deterministic: the same key and message always produce the same signature. Use this vector to verify your implementation byte-for-byte — it was cross-checked with OpenSSL 3, Node.js crypto, and Python cryptography, all three matching exactly.

⚠️ This key is a published test key. Never register it or use it for anything real.

Test private key (PKCS#8 PEM):

-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIGAf8cYG1f37eERGKrZTUOyAiu57EXE9nTAON6bT347j
-----END PRIVATE KEY-----

Derived public key (43 chars):

anHTiUK7owEqqkep6ZvJ0ijwDUfb-w-3DnVeIeT-FE4

Vector 1 — GET, no body:

message   = 1743360060000GET/v2/exchange/supported
signature = D3GCQ8x-kE_lBxh4XhS3xRrO_cCWBwBX7KgOXg5E90ICu96a4Wut8wihJP3UDmCkYyyp0Hvm5daESHWFc2fLDw

Vector 2 — POST with compact JSON body:

message   = 1743360060000POST/v2/orders{"exchange":"binance-spot","symbol":"BTCUSDT","side":"BUY","type":"LIMIT","quantity":"0.001","price":"60000.00","timeInForce":"GTC","marketType":"spot"}
signature = i_7Uk4vOfBXFAHoHq6GQYJRCIuw5WH-W3XkAuF99IYRl5zUaZ-opuxHJb68gFhtVXzDcAaddm44Onf2x5_v_AA

If your code reproduces both signatures, your signing pipeline is correct — any remaining 401s are configuration (key not registered, clock, IP), not cryptography.


Timestamps and replay protection

  • The timestamp must be within ±5000 ms of server time. Outside the window → 401 TIMESTAMP_EXPIRED; not a number → 401 TIMESTAMP_INVALID.
  • Treat every signature as single-use: reusing the same timestamp+signature pair is rejected as a replay (401 UNAUTHORIZED, Duplicate request signature). So every attempt, including automatic retries, must be re-signed with a fresh timestamp — never reuse a timestamp+signature pair across requests.
  • Don’t trust the local clock blindly: fetch GET /v2/server/time at startup and keep an offset (both reference clients above do this).

WebSocket authentication

The WebSocket at wss://api.lexx-trade.com/api/v2/ws uses the same key pair but a challenge–response flow:

  1. On connect the server sends a one-time challenge (hex string).
  2. You sign the concatenation "WSAuth" + timestamp + challenge and send an auth message.
  3. The server replies auth { authenticated: true, userId }.

Differences from REST worth noting: the signature there is sent standard Base64 (Base64url is also accepted); the challenge is single-use and bound to the connection — after any disconnect you reconnect and authenticate again; auth must complete within 5 s and within 5 attempts. Full protocol, channels and close codes: WebSocket.


Troubleshooting

Code (HTTP) What happened How to fix
UNAUTHORIZED (401), message mentions Duplicate request signature A repeat of the same timestamp+signature (usually a retry loop). Rejection is best-effort — may not fire behind the load balancer Re-sign every attempt with a fresh timestamp regardless
UNAUTHORIZED (401), generic Auth headers missing/malformed, or the key is unknown/revoked Send all three X-LEXX-* headers; check the key is active
INVALID_KEY (401) Public key malformed, not registered, or deactivated Exactly 43 Base64url chars; registered in the Terminal; isActive
INVALID_SIGNATURE (401) The signed message ≠ the actual request Path starts at /v2 (not /api/v2); query string included; body compact and byte-identical to what was sent; method UPPERCASE; Base64url without =
TIMESTAMP_EXPIRED (401) Clock off by > 5 s NTP; use the syncTime() offset pattern
TIMESTAMP_INVALID (401) Timestamp isn’t a number (e.g. ISO date) Unix milliseconds as an integer string
IP_NOT_WHITELISTED (403) Caller IP not in the key’s CIDR list Add your server’s egress IP; mind NAT/proxies
FORBIDDEN (403) Key tier too low for the endpoint Use a trade key for orders/bots endpoints
EXCHANGE_NOT_ALLOWED (403) Risk shield: exchange outside allowedExchanges Extend the key’s shield or use the allowed exchange

Still stuck? Write to support (or support@lexx-trade.com) and include the meta.requestId from the error response — it lets us find your exact request.

Security best practices

  • Never commit the private key. Keep lexx-api-key.pem out of the repo (.gitignore), chmod 600, or store it in a secrets manager.
  • One key per application/bot. Blast radius stays small and lastUsedAt stays meaningful.
  • Minimal tier. Monitoring services get read; only trading services get trade.
  • IP whitelist in production. A stolen key without your IP is useless.
  • Risk shields on trading keys. allowedExchanges caps the damage even if a trade key leaks.
  • Rotate and revoke. Revoking in the Terminal is instant (WS sessions close with 4003); registering a fresh key takes a minute.

Verified against API spec v2.0.0 · test vector cross-checked OpenSSL 3.6 / Node 24 / Python cryptography · 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.