Authentication
Minting an API key and signing every request with HMAC-SHA256, with worked Python, JavaScript and curl examples.
Market data is open. Everything that reads or moves an account is authenticated with an API key and an HMAC-SHA256 signature over each individual request. There are no wallet signatures in the hot path and no nonces to track.
Get a key
Two ways, same credential:
-
From the app — the API-keys page on 1024ex.com. Best for one-off and manual setups.
-
Headless —
POST /api/v1/oauth/onboard, one call, no browser and no hosted page. It creates the user and account for a brand-new wallet if needed, then mints the key. Identity proof is a wallet signature over a canonical message:- the message must start with
1024 Exchange - login -and end with the sametimestampyou send in the body, timestampis UTC ms, within ±300 s of server time,- body fields:
walletAddress,walletType,message,signature,timestamp.
Keys minted this way are read + trade; withdrawal rights are never granted over the OAuth surface.
- the message must start with
A credential is a pair:
| Part | Shape | Where it goes |
|---|---|---|
| API key | 1024_<64-hex>, 69 chars | X-API-KEY header |
| Secret key | 64-char lowercase hex | never transmitted — it is the HMAC key |
The secret is returned exactly once, at issuance. Lost secret means POST /api/v1/accounts/me/api-key/rotate, not a support ticket. GET /api/v1/accounts/me/api-key/introspect tells you what a key is and what it may do without revealing anything secret.
Sign a request
Every authenticated call carries three headers:
| Header | Value |
|---|---|
X-API-KEY | the public key (X-TRADING-API-KEY is a legacy alias) |
X-TIMESTAMP | UTC milliseconds, base-10 ASCII |
X-SIGNATURE | lowercase hex of HMAC_SHA256(secret, payload) |
The payload is a byte-wise concatenation — no separators, no JSON re-serialization, no URL encoding:
payload = X-TIMESTAMP || METHOD || PATH || BODY| Part | Rule |
|---|---|
X-TIMESTAMP | the exact digits sent in the header |
METHOD | uppercase (GET, POST, DELETE, PUT) |
PATH | path only — /api/v1/perp/orders. The query string is not signed |
BODY | the raw bytes you are about to send, verbatim. Empty string when there is no body |
Two consequences worth internalizing: sign the serialized body you actually send (re-encoding it after signing breaks the signature), and do not include ?market=BTC-USDC in the signed path even though you send it on the wire.
Worked example
import hashlib, hmac, json, time, requests
BASE = "https://api-mainnet.1024ex.com"
KEY = "1024_…"
SECRET = "…"
def call(method, path, body=None, params=None):
raw = json.dumps(body, separators=(",", ":")) if body is not None else ""
ts = str(int(time.time() * 1000))
msg = ts + method.upper() + path + raw
sig = hmac.new(SECRET.encode(), msg.encode(), hashlib.sha256).hexdigest()
r = requests.request(
method, BASE + path, params=params,
data=raw.encode() if raw else None,
headers={
"X-API-KEY": KEY,
"X-TIMESTAMP": ts,
"X-SIGNATURE": sig,
"Content-Type": "application/json",
}, timeout=20)
return r.json()
print(call("GET", "/api/v1/perp/positions"))
print(call("POST", "/api/v1/perp/orders", {
"market": "BTC-USDC", "side": "buy", "type": "limit",
"size": "0.01", "price": "80000", "leverage": 10,
}))import crypto from "node:crypto";
const BASE = "https://api-mainnet.1024ex.com";
async function call(method, path, body) {
const raw = body === undefined ? "" : JSON.stringify(body);
const ts = Date.now().toString();
const sig = crypto.createHmac("sha256", SECRET)
.update(ts + method.toUpperCase() + path + raw)
.digest("hex");
const res = await fetch(BASE + path, {
method,
body: raw || undefined,
headers: {
"X-API-KEY": KEY,
"X-TIMESTAMP": ts,
"X-SIGNATURE": sig,
"Content-Type": "application/json",
},
});
return res.json();
}TS=$(python3 -c 'import time;print(int(time.time()*1000))')
BODY='{"market":"BTC-USDC","side":"buy","type":"limit","size":"0.01","price":"80000"}'
SIG=$(printf '%s' "${TS}POST/api/v1/perp/orders${BODY}" \
| openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -s -X POST "https://api-mainnet.1024ex.com/api/v1/perp/orders" \
-H "X-API-KEY: $KEY" -H "X-TIMESTAMP: $TS" -H "X-SIGNATURE: $SIG" \
-H 'Content-Type: application/json' -d "$BODY"Replay, clock skew and IP allowlists
- A timestamp more than 30 s from server time is rejected with
AUTH_TIMESTAMP_EXPIRED(11004). Sync your clock; do not "fix" it with an offset that drifts. - Each signature is single-use inside a ~35 s window — resending an identical signed request returns
AUTH_REPLAY_DETECTED(11013). Genuine retries therefore need a fresh timestamp and a fresh signature. Pair that with an idempotency key (clientOrderId) so a retry cannot double-fill. - A key may carry an IP allowlist. It is enforced after the signature check — possessing the public key alone tells an attacker nothing about where the key is locked to. Violations are
PERM_IP_NOT_ALLOWED(11107). - Keys carry permissions (read / trade / withdraw). Withdrawal is off by default and is granted explicitly per key;
PERM_NO_TRADING(11101) andPERM_NO_READ(11102) mean the key is scoped narrower than the call.
When it fails
| Code | Num | Usual cause |
|---|---|---|
AUTH_MISSING | 11001 | one of the three headers absent |
AUTH_INVALID_KEY | 11002 | unknown, revoked or inactive key |
AUTH_INVALID_SIGNATURE | 11003 | payload built wrong — query string signed, body re-serialized, or lowercase method |
AUTH_TIMESTAMP_EXPIRED | 11004 | clock skew > 30 s |
AUTH_KEY_EXPIRED | 11007 | key past its expiry |
AUTH_REPLAY_DETECTED | 11013 | same signature sent twice |
AUTH_NO_ACCOUNT | 11010 | key exists but has no account bound |
Debugging a stubborn AUTH_INVALID_SIGNATURE: print the exact payload string you hashed. In practice it is nearly always a query string that leaked into PATH, or a body that was pretty-printed after signing.
When the signature is rejected
AUTH_INVALID_SIGNATURE almost always means one of three things, in order of
how often they happen:
- Your clock drifted. The timestamp is rejected outside a 30-second
window, and a stale clock looks exactly like a bad secret. Measure it:
subtractGET /api/v1/system/timefrom your own clock before you debug
anything else. - You signed a different body than you sent. Serialize once, sign that
exact string, send that exact string — re-serializing between the two steps
changes key order or spacing and invalidates the signature. - You signed the query string. Only the path is signed:
?market=BTC-USDCtravels on the wire but must not appear in the signing
string.
Sign your first request is a runnable
version of all of this, including the drift check.
Updated 9 days ago
