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
opensslmay be LibreSSL — checkopenssl 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 ishttps://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/timeat 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:
- On connect the server sends a one-time
challenge(hex string). - You sign the concatenation
"WSAuth" + timestamp + challengeand send anauthmessage. - 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.pemout of the repo (.gitignore),chmod 600, or store it in a secrets manager. - One key per application/bot. Blast radius stays small and
lastUsedAtstays meaningful. - Minimal tier. Monitoring services get
read; only trading services gettrade. - IP whitelist in production. A stolen key without your IP is useless.
- Risk shields on trading keys.
allowedExchangescaps the damage even if atradekey 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