API keys
An API key is a deterministic delegate ECDSA signing credential derived from your main wallet via a one-time signature. Once registered on-chain via /account/link, the API key authenticates sessions on behalf of your main wallet without further MetaMask popups — exactly what you want for bots, scripts, and indexers. Multiple API keys per wallet are supported via labels; each can be scoped to a subset of actions and revoked independently.
- Open the API Keys page from the “More” menu, name your API key (e.g. “trading-bot-1”), and pick which actions it can perform.
- Sign the derivation message once in MetaMask — your browser derives the API key locally and sends only the signature on-chain.
- Copy the displayed API key. It is shown only once; the chain never stores it. Lose it and you must create a new one.
- In your script, use the API key as your signer and pass your MAIN wallet address (0xABC) as userAddress when opening a session. The chain checks recovered_addr against authorized_signers and lets the API key through.
- Revoke any time from the API Keys page or by POSTing /account/unlink with a fresh MAIN wallet signature.
Minimal Python example using the chain HTTP interface directly (no SDK). The same flow with the TypeScript SDK is one line: await session.order(...) — see the SDK section below.
Python — place a limit order with an API key
Section titled “Python — place a limit order with an API key”import requestsfrom eth_account import Accountfrom eth_utils import keccak
USER = "0xABC..." # your MAIN wallet — also userAddress on the sessionAPI_KEY = "0x..." # API key (shown once in the create modal)BASE = "https://api.bitnxs.com"
# 1. Request a challenge for the wallet.chal = requests.post(f"{BASE}/session/start", json={"user": USER}).json()
# 2. Sign keccak256(challenge_bytes) DIRECTLY with the API key.# The chain verifies via raw secp256k1 ECDSA over the keccak hash —# NOT eth_sign / personal_sign / EIP-191. Adding the# "\x19Ethereum Signed Message:\n" prefix produces a different hash# and the chain returns signature-address-mismatch.sig = Account.unsafe_sign_hash( keccak(chal["challenge"].encode()), private_key=API_KEY,)
ses = requests.post(f"{BASE}/session/complete", json={ "challengeId": chal["challengeId"], "signature": { "r": hex(sig.r), "s": hex(sig.s), "v": sig.v - 27, # eth_account returns 27/28; the chain expects {0,1} },}).json()SID = ses["sessionId"]
# 3. Place an order — no per-request signature; sessionId is the auth.print(requests.post(f"{BASE}/session/order", json={ "sessionId": SID, "requestId": 10001, # idempotency key (optional, non-zero) "asset": 1, # numeric asset id, from /info { type: "meta" } "isBuy": True, "limitPx": "65000.00", "sz": "0.01", "reduceOnly": False, "orderType": {"type": "limit", "tif": "Gtc"},}).json())
# 4. Reads need no session — /info is public.print(requests.post(f"{BASE}/info", json={ "type": "clearinghouseState", "user": USER,}).json())Permission Flags
Section titled “Permission Flags”Each API key carries four boolean flags set at creation. Disabled flags are rejected by the chain BEFORE the action enters the matcher — useful for limiting blast radius if an API key leaks. Direct wallet-signed sessions (no API key) always have full permissions.
| Flag | Covers |
|---|---|
place_orders | POST /session/order — place limit / market / post-only / IOC / FOK orders |
cancel_orders | POST /session/cancel + /session/modify — modify implies cancel, so they share the bit |
change_leverage | POST /session/leverage — change per-asset leverage and isolated/cross mode |
set_triggers | POST /session/trigger + /session/cancel_trigger — TP/SL trigger orders |
Rate Limits
Section titled “Rate Limits”Production Trading Loop
Section titled “Production Trading Loop”A real bot needs three things on top of the minimal example: (1) re-auth before the 1-hour session TTL expires, (2) requestId-based idempotency so a network retry never double-places an order, and (3) a WebSocket subscription for sub-second fill events instead of polling /info. The script below does all three.
import asyncio, json, time, requestsimport websocketsfrom eth_account import Accountfrom eth_utils import keccak
USER, API_KEY = "0xABC...", "0x..."BASE = "https://api.bitnxs.com"WS = BASE.replace("http", "ws") + "/ws"
# ─── session helper: open once, refresh before 1h TTL ────────────_sid, _sid_exp = None, 0def _open_session(): chal = requests.post(f"{BASE}/session/start", json={"user": USER}).json() sig = Account.unsafe_sign_hash(keccak(chal["challenge"].encode()), private_key=API_KEY) ses = requests.post(f"{BASE}/session/complete", json={ "challengeId": chal["challengeId"], "signature": {"r": hex(sig.r), "s": hex(sig.s), "v": sig.v - 27}, }).json() if ses.get("status") != "ok": raise RuntimeError(f"session/complete failed: {ses}") return ses["sessionId"], time.time() + 3500 # refresh ~100s before 1h
def session_id(): global _sid, _sid_exp if not _sid or time.time() >= _sid_exp: _sid, _sid_exp = _open_session() return _sid
# ─── idempotent order placement ─────────────────────────────────# Monotonic requestId — safe to retry. Same (sessionId, requestId)# always returns the cached result, never double-places._req_seq = int(time.time() * 1000)def next_req_id(): global _req_seq _req_seq += 1 return _req_seq
def place(asset, is_buy, px, sz, req_id=None): return requests.post(f"{BASE}/session/order", json={ "sessionId": session_id(), "requestId": req_id or next_req_id(), "asset": asset, "isBuy": is_buy, "limitPx": str(px), "sz": str(sz), "reduceOnly": False, "orderType": {"type": "limit", "tif": "Gtc"}, }).json()
# ─── WebSocket fill stream — sub-second event-driven fills ─────async def watch_fills(): async with websockets.connect(WS) as ws: await ws.send(json.dumps({ "method": "subscribe", "subscription": {"type": "executionReports", "user": USER}, })) async for raw in ws: msg = json.loads(raw) if msg.get("channel") == "subscription": for ev in msg.get("data", []): print("fill requestId=%s sz=%s" % (ev.get("requestId"), ev.get("filledSz")))
async def main(): asyncio.create_task(watch_fills()) print(place(asset=1, is_buy=True, px=65000, sz=0.01)) await asyncio.sleep(60) # let fills stream for a minute
asyncio.run(main())Error Codes
Section titled “Error Codes”Both /account/* and /session/* return JSON of shape { status: "err", response: "<code>" } (HTTP 400 unless noted). Quick reference for the codes you are most likely to hit while writing a client:
| Code | Meaning | Fix |
|---|---|---|
invalid-signature | r/s/v could not be parsed as a valid secp256k1 signature | Ensure r and s are 32-byte hex (0x-prefixed) and v ∈ {0,1} |
signature-address-mismatch | Recovered address is neither the wallet nor any registered API key | Wrong API key, OR you signed with EIP-191/personal_sign instead of raw keccak |
signer-derivation-mismatch | /account/link only: claimed signer ≠ keccak256(r‖s‖v_norm) of derivation_sig | Use v ∈ {27,28} (not {0,1}) when computing the dk hash |
invalid-challenge-id | challengeId is unknown or already consumed | Challenges are one-shot — call /session/start to get a fresh one |
challenge-expired | Challenge older than 60s | Restart from /session/start |
session-not-found | sessionId expired, revoked, or never existed | Re-run the session handshake (the script above does this automatically) |
api-key-permission-denied | API key lacks the permission flag for this action | Re-create the API key with broader permissions, e.g. enable cancel_orders |
signer-already-linked-to-different-user | /account/link: that API key address is already bound to another wallet | Use a different label so MetaMask derives a fresh API key |
rate-limited (HTTP 429) | Per-signer token bucket exhausted (50 burst / 20 rps) | Back off ~50ms and retry — the bucket refills 20 tokens/sec |
oracle-not-ready | Node still loading initial oracle prices on boot | Poll GET /ready first; retry after a few seconds |
insufficient-margin | Order would violate margin requirements at admission preflight | Top up balance, reduce size, or use a tighter limit price |
invalid-asset | asset id unknown to this node | Fetch the current universe from /info { type: “meta” } |
Revoking an API Key
Section titled “Revoking an API Key”Only the MAIN wallet can revoke an API key — by design, a leaked API key cannot revoke itself. The wallet signs over keccak256("nexus-delegate-unlink:" + user + ":" + signer); on success the API key entry is removed from authorized_signers and any in-flight session it owned will fail on the next request with session-not-found.
import requestsfrom eth_account import Accountfrom eth_utils import keccak
WALLET_KEY = "0x..." # MAIN wallet private key (NOT the API key)USER = "0xABC..." # MAIN wallet address, lower-case 0x + 40 hexAPI_KEY_ADDR = "0xXYZ..." # API key address to revoke (see /info userApiKeys)BASE = ""
msg = f"nexus-delegate-unlink:{USER.lower()}:{API_KEY_ADDR.lower()}"sig = Account.unsafe_sign_hash(keccak(msg.encode()), private_key=WALLET_KEY)
print(requests.post(f"{BASE}/account/unlink", json={ "user": USER, "signer": API_KEY_ADDR, "revocationSig": {"r": hex(sig.r), "s": hex(sig.s), "v": sig.v - 27},}).json())# → { "status": "ok", "user": "0xABC...", "signer": "0xXYZ..." }