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:

  1. From the app — the API-keys page on 1024ex.com. Best for one-off and manual setups.

  2. 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 same timestamp you send in the body,
    • timestamp is 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.

A credential is a pair:

PartShapeWhere it goes
API key1024_<64-hex>, 69 charsX-API-KEY header
Secret key64-char lowercase hexnever 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:

HeaderValue
X-API-KEYthe public key (X-TRADING-API-KEY is a legacy alias)
X-TIMESTAMPUTC milliseconds, base-10 ASCII
X-SIGNATURElowercase 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
PartRule
X-TIMESTAMPthe exact digits sent in the header
METHODuppercase (GET, POST, DELETE, PUT)
PATHpath only — /api/v1/perp/orders. The query string is not signed
BODYthe 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) and PERM_NO_READ (11102) mean the key is scoped narrower than the call.

When it fails

CodeNumUsual cause
AUTH_MISSING11001one of the three headers absent
AUTH_INVALID_KEY11002unknown, revoked or inactive key
AUTH_INVALID_SIGNATURE11003payload built wrong — query string signed, body re-serialized, or lowercase method
AUTH_TIMESTAMP_EXPIRED11004clock skew > 30 s
AUTH_KEY_EXPIRED11007key past its expiry
AUTH_REPLAY_DETECTED11013same signature sent twice
AUTH_NO_ACCOUNT11010key 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:

  1. Your clock drifted. The timestamp is rejected outside a 30-second
    window, and a stale clock looks exactly like a bad secret. Measure it:
    subtract GET /api/v1/system/time from your own clock before you debug
    anything else.
  2. 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.
  3. You signed the query string. Only the path is signed:
    ?market=BTC-USDC travels 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.


Did this page help you?