Skip to content
Trade

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.

  1. 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.
  2. Sign the derivation message once in MetaMask — your browser derives the API key locally and sends only the signature on-chain.
  3. Copy the displayed API key. It is shown only once; the chain never stores it. Lose it and you must create a new one.
  4. 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.
  5. 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 requests
from eth_account import Account
from eth_utils import keccak
USER = "0xABC..." # your MAIN wallet — also userAddress on the session
API_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())

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.

FlagCovers
place_ordersPOST /session/order — place limit / market / post-only / IOC / FOK orders
cancel_ordersPOST /session/cancel + /session/modify — modify implies cancel, so they share the bit
change_leveragePOST /session/leverage — change per-asset leverage and isolated/cross mode
set_triggersPOST /session/trigger + /session/cancel_trigger — TP/SL trigger orders

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, requests
import websockets
from eth_account import Account
from 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, 0
def _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())

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:

CodeMeaningFix
invalid-signaturer/s/v could not be parsed as a valid secp256k1 signatureEnsure r and s are 32-byte hex (0x-prefixed) and v ∈ {0,1}
signature-address-mismatchRecovered address is neither the wallet nor any registered API keyWrong 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_sigUse v ∈ {27,28} (not {0,1}) when computing the dk hash
invalid-challenge-idchallengeId is unknown or already consumedChallenges are one-shot — call /session/start to get a fresh one
challenge-expiredChallenge older than 60sRestart from /session/start
session-not-foundsessionId expired, revoked, or never existedRe-run the session handshake (the script above does this automatically)
api-key-permission-deniedAPI key lacks the permission flag for this actionRe-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 walletUse 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-readyNode still loading initial oracle prices on bootPoll GET /ready first; retry after a few seconds
insufficient-marginOrder would violate margin requirements at admission preflightTop up balance, reduce size, or use a tighter limit price
invalid-assetasset id unknown to this nodeFetch the current universe from /info { type: “meta” }

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 requests
from eth_account import Account
from eth_utils import keccak
WALLET_KEY = "0x..." # MAIN wallet private key (NOT the API key)
USER = "0xABC..." # MAIN wallet address, lower-case 0x + 40 hex
API_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..." }