Lesson 4.4.4 · 75 min

Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase

ccxt signs requests for you, but when an order is rejected because of the signature, the time or the nonce, you need to know what it signs. This lesson implements the four authentication schemes of Binance, Bybit, Kraken and Coinbase, checks them against the vectors in their documentation and runs them against a local server with a logical clock: time windows, shared nonces and the Binance user stream with the example order.

Fig. — Requests accepted and rejected by the simulator by client clock offset, under each exchange's documented rules
Level 4 lessons
  1. 4.1 · Portfolio theory and optimisation

    1. 4.1.1 Markowitz: efficient frontier and real constraints
    2. 4.1.2 Covariance estimation: shrinkage, factor models and denoising
    3. 4.1.3 Black-Litterman: from market equilibrium to views
    4. 4.1.4 Risk parity, minimum variance, HRP and HERC
    5. 4.1.5 Optimization with CVaR, drawdown, turnover and costs
    6. 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
    7. 4.1.7 Rebalancing and strategy portfolios
    8. 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
  2. 4.2 · Risk management and position sizing

    1. 4.2.1 Risk per trade, stops and risk of ruin
    2. 4.2.2 Kelly criterion and volatility targeting: how much to risk
    3. 4.2.3 VaR, Expected Shortfall and VaR backtesting
    4. 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
    5. 4.2.5 Drawdown control and kill switch
    6. 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
  3. 4.3 · Microstructure and execution

    1. 4.3.1 Implementation shortfall, VWAP, TWAP and Almgren-Chriss
    2. 4.3.2 Order flow imbalance, microstructure signals and latency
  4. 4.4 · Brokers and APIs

    1. 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
    2. 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
    4. 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
    5. 4.4.5 Secure credentials: environment variables, secret managers and least privilege
  5. 4.5 · Live trading system

    1. 4.5.1 Live system architecture: from data to reconciliation
    2. 4.5.2 Order management: states, partial fills, cancellations and reconnections
    3. 4.5.3 Paper trading, shadow trading and slippage monitoring
    4. 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
    5. 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
    6. 4.5.6 NautilusTrader or LEAN live: from backtest to production with the same code

What ccxt does underneath

Lesson 3.7.4 used ccxt against two simulated exchanges: markets, orders, typed errors, limits, idempotency with clientOrderId and order books over websocket. ccxt translates each call into the exchange's native API: it builds the path, adds the time, signs with the key and reads the response. It is almost always enough. You need to know the native API in three cases: when a request is rejected because of the signature, the time or the nonce and you have to understand why; when the exchange offers something ccxt does not unify (each exchange's user stream, self-trade prevention modes, orders over websocket); and when latency or control over every byte matters.

The sign method of a ccxt class builds the request without sending it, so you can see what it would send to Binance with no network or account. The keys are fake and the time is fixed:

IN [01]python
import ccxt

ex = ccxt.binance({"apiKey": "FALSA-CLAVE", "secret": "FALSA-SECRETO"})
ex.nonce = lambda: 1_760_449_080_805       # fixed time, in ms
orden = {"symbol": "XSNUSDT", "side": "BUY", "type": "LIMIT",
         "timeInForce": "GTC", "quantity": "2", "price": "61.25"}
peticion = ex.sign("order", ["private"], "POST", dict(orden))
print(peticion["method"], peticion["url"])
print("headers:", sorted(peticion["headers"]))
campos = dict(kv.split("=") for kv in peticion["body"].split("&"))
print("body fields:", list(campos))
print("recvWindow:", campos["recvWindow"],
      "| prefix of the injected id:", campos["newClientOrderId"][:10])
propia = ex.sign("order", ["private"], "POST",
                 orden | {"newClientOrderId": "R4-cripto-0001"})
print("with own id:", "newClientOrderId=R4-cripto-0001"
      in propia["body"])
OUT [01]What ccxt signs for a Binance order
POST https://api.binance.com/api/v3/order
headers: ['Content-Type', 'X-MBX-APIKEY']
body fields: ['timestamp', 'symbol', 'side', 'type', 'timeInForce', 'quantity', 'price', 'newClientOrderId', 'recvWindow', 'signature']
recvWindow: 10000 | prefix of the injected id: x-TKT5PX2F
with own id: True

ccxt 4.5.89 sends to api.binance.com/api/v3/order a form with the time (timestamp), the parameters, a validity window (recvWindow of 10,000 ms, this class's default option) and the signature at the end, and puts the key in the X-MBX-APIKEY header. It adds something that was not requested: a newClientOrderId with the prefix x-TKT5PX2F, which is ccxt's broker identifier at Binance (ex.options["broker"]) followed by a random part. If you give your own identifier, ccxt respects it and injects nothing. For a system, your own identifier is not optional: it is what lets you look up an order after a timeout without duplicating it (lesson 3.7.4).

Four exchanges, four authentication schemes

Binance spotBybit V5Kraken spotCoinbase Advanced Trade
What is signedParameter string (query + body)timestamp + key + recv_window + query or JSON bodyPath + SHA-256(nonce + data)A JWT with method, host and path
AlgorithmHMAC-SHA256 (hex), RSA or Ed25519 (base64)HMAC-SHA256 (hex) or RSA (base64)HMAC-SHA512 with the decoded secret (base64)ES256 (ECDSA P-256)
Where it goessignature parameter; key in X-MBX-APIKEYHeaders X-BAPI-API-KEY, X-BAPI-TIMESTAMP, X-BAPI-RECV-WINDOW, X-BAPI-SIGNHeaders API-Key and API-SignAuthorization: Bearer <JWT>
Time controlrecvWindow (5,000 ms by default, maximum 60,000)recv_window (5,000 ms by default)nonce always increasing per keynbf and exp = nbf + 120 s
Test environmenttestnet.binance.vision and demo mode (demo-api.binance.com)api-testnet.bybit.com and demo (api-demo.bybit.com)No spot test environment in the documentation consultedSandbox with static responses

The sources are in each section's notes1Note 1 · Binance, official spot API documentation (source repository of developers.binance.com): rest-api.md (signed endpoints, HMAC, RSA, Ed25519, recvWindow, limits), web-socket-api.md (userDataStream.subscribe.signature, ping), user-data-stream.md (executionReport), errors.md (-1021, -1022), faqs/api_key_types.md and CHANGELOG.md (entries of 2025-04-07, 2025-12-17 and 2026-01-21). Accessed on October 22, 2026.2Note 2 · Bybit, Integration Guidance (authentication), WebSocket Connect, Error codes, Rate limit and Demo trading; official example code in api-usage-examples. Accessed on October 22, 2026.3Note 3 · Kraken, Spot REST authentication, API keys, spot errors, executions (WebSocket v2) and GetWebSocketsToken. Accessed on October 22, 2026.4Note 4 · Coinbase, API key authentication, legacy keys, WebSocket overview, user channel and sandbox. Accessed on October 22, 2026.. Two recent Binance changes break code that used to work: since January 15, 2026 the payload is percent-encoded before signing (requests that do not do this are rejected with -1022 INVALID_SIGNATURE), and the key types documentation says that HMAC keys are deprecated and recommends Ed25519. Coinbase, for its part, invalidated its old (“legacy”) keys in February 2025: Advanced Trade only accepts the JWT, and its documentation asks for ECDSA (ES256) keys, not Ed25519.

ParametersExact stringClock or nonceSign with secretHeaders or parameterServer recomputesValid and on time
Mermaid source
flowchart TB
  P[Parameters] --> C[Exact string]
  R[Clock or nonce] --> C
  C --> F[Sign with secret]
  F --> H[Headers or parameter]
  H --> S[Server recomputes]
  S --> V{Valid and on time}

The server decrypts nothing: it recomputes the signature with its copy of the secret (or with the public key, for RSA, Ed25519 and ES256) over the bytes it receives and compares. Any difference between the signed string and the one sent (parameter order, a space, an encoding) gives a different signature.

The signatures, against the documentation's vectors

A test vector is an input with its published output: if the code reproduces the output, it implements the scheme correctly. Binance and Kraken publish complete examples for HMAC, with a sample key and secret that belong to no account. The cells do not print those secrets: they use them and compare the signature.

Binance: HMAC-SHA256 of the parameter string

The Binance endpoint security documentation gives the string of a LTCBTC limit order, its secret and the expected signature, and a second example with a symbol made of non-ASCII characters (six full-width digits, from U+FF11 to U+FF16) that is percent-encoded before signing. The WebSocket API has another rule: parameters are sorted alphabetically and the UTF-8 text is signed, unencoded1.

IN [02]python
import hashlib
import hmac
from urllib.parse import quote

SECRETO_DOC = ("NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XA"
               "bvqqM6A7H5fATj0j")     # published sample secret
CLAVE_DOC = ("vmPUZE6mv9SD5VNHk4HlWFsOr6aKE2zvsw0MuIgwCIPy6utIco14"
             "y7Ju91duEh8A")           # published sample key

def firma_binance(secreto, carga):
    return hmac.new(secreto.encode(), carga.encode(),
                    hashlib.sha256).hexdigest()

NO_ASCII = "\uff11\uff12\uff13\uff14\uff15\uff16"   # full-width digits

def carga_rest(params):
    """Pairs in the given order, values percent-encoded."""
    return "&".join(f"{k}={quote(str(v), safe='')}"
                    for k, v in params.items())

rest = {"symbol": "LTCBTC", "side": "BUY", "type": "LIMIT",
        "timeInForce": "GTC", "quantity": "1", "price": "0.1",
        "recvWindow": "5000", "timestamp": "1499827319559"}
ws = {"apiKey": CLAVE_DOC, "price": "52000.00",
      "quantity": "0.01000000", "recvWindow": "100", "side": "SELL",
      "symbol": "BTCUSDT", "timeInForce": "GTC",
      "timestamp": "1645423376532", "type": "LIMIT"}
vectores = [
    ("REST ASCII", carga_rest(rest), "c8db56825ae71d6d79447849e617115f"
     "4a920fa2acdcab2b053c4b2838bd6b71"),
    ("REST non-ASCII", carga_rest(rest | {"symbol": NO_ASCII}),
     "e1353ec6b14d888f1164ae9af8228a3d"
     "bd508bc82eb867db8ab6046442f33ef3"),
    ("WS API", "&".join(f"{k}={v}" for k, v in sorted(ws.items())),
     "aa1b5712c094bc4e57c05a1a5c1fd8d8"
     "8dcd628338ea863fec7b88e59fe2db24"),
]
for nombre, carga, esperada in vectores:
    firma = firma_binance(SECRETO_DOC, carga)
    ok = hmac.compare_digest(firma, esperada)
    print(f"{nombre:<14} {carga[:44]}… matches: {ok}")
no_ascii = rest | {"symbol": NO_ASCII}
sin_codificar = firma_binance(
    SECRETO_DOC, "&".join(f"{k}={v}" for k, v in no_ascii.items()))
print("non-ASCII unencoded matches:",
      sin_codificar == vectores[1][2])
OUT [02]Binance: the documentation's HMAC vectors
REST ASCII     symbol=LTCBTC&side=BUY&type=LIMIT&timeInForc… matches: True
REST non-ASCII symbol=%EF%BC%91%EF%BC%92%EF%BC%93%EF%BC%94%… matches: True
WS API         apiKey=vmPUZE6mv9SD5VNHk4HlWFsOr6aKE2zvsw0Mu… matches: True
non-ASCII unencoded matches: False

All three signatures match the documentation. The last line is the January 2026 change seen from the code: the same symbol unencoded gives a different signature, and Binance would reject the request. hmac.compare_digest compares in constant time, as any server that verifies signatures should (on the client it is not necessary, but it costs nothing either).

Kraken: HMAC-SHA512 with the SHA-256 of the nonce

At Kraken, API-Sign is the HMAC-SHA512 of the path (/0/private/AddOrder) followed by the binary SHA-256 of nonce + POST data, with the secret decoded from base64 as the key, and the result in base64. The authentication guide gives a complete example with its signature3:

IN [03]python
import base64

SECRETO_KRAKEN_DOC = ("kQH5HW/8p1uGOVjbgWA7FunAmGO8lsSUXNsu3eow76sz84Q"
                      "18fWxnyRzBHCd3pd5nE9qa99HAZtuZuj6F1huXg==")

def firma_kraken(secreto_b64, ruta, nonce, datos):
    resumen = hashlib.sha256((str(nonce) + datos).encode()).digest()
    mac = hmac.new(base64.b64decode(secreto_b64),
                   ruta.encode() + resumen, hashlib.sha512)
    return base64.b64encode(mac.digest()).decode()

datos = ("nonce=1616492376594&ordertype=limit&pair=XBTUSD"
         "&price=37500&type=buy&volume=1.25")
esperada = ("4/dpxb3iT4tp/ZCVEwSnEsLxx0bqyhLpdfOpc6fn7OR8+UClSV5n9E6"
            "aSS8MPtnRfp32bAb0nmbRn6H8ndwLUQ==")
firma = firma_kraken(SECRETO_KRAKEN_DOC, "/0/private/AddOrder",
                     1616492376594, datos)
print("matches:", firma == esperada, "| length:", len(firma))
OUT [03]Kraken: the authentication guide's vector
matches: True | length: 88

Bybit: the signature depends on the body's bytes

Bybit V5 signs timestamp + api_key + recv_window + payload, where the payload is the query in a GET and the JSON body in a POST, with lowercase hex HMAC-SHA2562. The guide shows two examples of string and signature, but does not publish the secret they were computed with, so they cannot serve as a vector: the implementation is checked against the local server in the next section. What you can see without a vector is the POST trap: the body is text, and json.dumps with its default separators does not produce the same bytes as with separators=(",", ":"), which is what Bybit's official example code uses.

IN [04]python
import json

def firma_bybit(secreto, ts, clave, ventana, carga):
    texto = f"{ts}{clave}{ventana}{carga}"
    return hmac.new(secreto.encode(), texto.encode(),
                    hashlib.sha256).hexdigest()

cuerpo = {"category": "spot", "symbol": "XSNUSDT", "side": "Buy",
          "orderType": "Limit", "qty": "2", "price": "61.25",
          "orderLinkId": "R4-cripto-0001"}
compacto = json.dumps(cuerpo, separators=(",", ":"))
con_espacios = json.dumps(cuerpo)
for nombre, texto in [("compact", compacto),
                      ("with spaces", con_espacios)]:
    f = firma_bybit("FALSA-SECRETO-BYBIT", 1760449080805,
                    "FALSA-CLAVE-BYBIT", 5000, texto)
    print(f"{nombre:<13} {len(texto)} bytes  signature {f[:16]}…")
OUT [04]Bybit: the same order, two texts, two signatures
compact       128 bytes  signature 61e79e68de63afd9…
with spaces   141 bytes  signature 7e3ec9e21fcf210b…

Thirteen bytes of difference (the spaces after each : and each ,) give signatures with nothing in common. If the client signs one text and the HTTP library sends another (for example, because it is passed the dictionary and serializes it itself), the server replies with the signature error. The rule applies to all of them: you sign the string you send, and you send the string you signed.

Ed25519 and the Coinbase JWT

Binance recommends Ed25519 keys: the signature is made with the private key and the server checks it with the public one, so the exchange stores nothing that can be used to sign. The Ed25519 examples in its documentation do not publish the private key, so they cannot serve as a vector (the one shown as a signature has 344 base64 characters, the length of a 2,048-bit RSA signature; an Ed25519 one has 88). The algorithm is checked against the vectors of its specification, RFC 8032, with the environment's cryptography library5Note 5 · S. Josefsson and I. Liusvaara, RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA), section 7.1, IETF, 2017.:

IN [05]python
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
    Ed25519PrivateKey)

RFC8032 = [  # TEST 1 and TEST 2: (secret key, message, signature)
    ("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
     "", "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e0652249"
     "01555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a"
     "100b"),
    ("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
     "72", "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223eb"
     "db69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612"
     "bb0c00"),
]
for i, (sk, msg, firma) in enumerate(RFC8032, 1):
    clave = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(sk))
    print(f"TEST {i}:", clave.sign(bytes.fromhex(msg)).hex() == firma)

# a toy key, derived from a text: never from an account
clave_ed = Ed25519PrivateKey.from_private_bytes(
    hashlib.sha256(b"clave de juguete del curso").digest())
carga = carga_rest({"symbol": "XSNUSDT", "side": "BUY",
                    "type": "LIMIT", "timeInForce": "GTC",
                    "quantity": "2", "price": "61.25",
                    "timestamp": "1760449080805"})
firma_ed = base64.b64encode(clave_ed.sign(carga.encode())).decode()
clave_ed.public_key().verify(base64.b64decode(firma_ed), carga.encode())
print("Ed25519 signature in base64:", len(firma_ed), "characters;",
      "in the URL:", quote(firma_ed, safe="")[:12] + "…")
OUT [05]Ed25519: RFC 8032 vectors and a Binance signature
TEST 1: True
TEST 2: True
Ed25519 signature in base64: 88 characters; in the URL: f9UQrpOxwkXX…

Both RFC vectors match. The order's signature takes 88 base64 characters and, since base64 can contain +, / and =, it is percent-encoded in the request, which is the third step of Binance's procedure for RSA and Ed25519. Ed25519 is deterministic: the same key and the same message always give the same signature, and that is why it has vectors.

ECDSA is not. Coinbase Advanced Trade authenticates with a JWT signed with ES256 (ECDSA with the P-256 curve and SHA-256): a header with alg, kid (the key name, organizations/{org_id}/apiKeys/{key_id}) and a random nonce; claims sub (the key name again), iss (cdp), nbf (now), exp (now + 120 s) and uri, which ties the token to one request: POST api.coinbase.com/api/v3/brokerage/orders. The documentation asks for a different JWT for each request4. PyJWT is not in the environment; with cryptography it is built by hand, and there is a detail that shows what a library does for us: ECDSA produces the signature in DER format, and the JWT wants it as the 64 bytes of r and s concatenated.

IN [06]python
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import (
    decode_dss_signature, encode_dss_signature)

def b64url(b):
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

def de_b64url(s):
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def jwt_es256(privada, kid, claims, nonce):
    cab = {"alg": "ES256", "kid": kid, "nonce": nonce, "typ": "JWT"}
    partes = [b64url(json.dumps(x, separators=(",", ":")).encode())
              for x in (cab, claims)]
    msg = ".".join(partes).encode()
    r, s = decode_dss_signature(privada.sign(msg,
                                             ec.ECDSA(hashes.SHA256())))
    firma = r.to_bytes(32, "big") + s.to_bytes(32, "big")
    return ".".join(partes + [b64url(firma)])

def verificar_es256(publica, token):
    cab, cuerpo, firma = token.split(".")
    raw = de_b64url(firma)
    der = encode_dss_signature(int.from_bytes(raw[:32], "big"),
                               int.from_bytes(raw[32:], "big"))
    publica.verify(der, f"{cab}.{cuerpo}".encode(),
                   ec.ECDSA(hashes.SHA256()))       # raises if invalid
    return json.loads(de_b64url(cab)), json.loads(de_b64url(cuerpo))

KID = "organizations/org-falsa/apiKeys/clave-falsa"
privada_cb = ec.derive_private_key(
    int.from_bytes(hashlib.sha256(b"juguete ES256").digest(), "big"),
    ec.SECP256R1())

def claims_coinbase(ahora, metodo, ruta):
    return {"sub": KID, "iss": "cdp", "nbf": ahora, "exp": ahora + 120,
            "uri": f"{metodo} api.coinbase.com{ruta}"}

token = jwt_es256(privada_cb, KID, claims_coinbase(
    1_760_449_080, "POST", "/api/v3/brokerage/orders"), "00" * 16)
cab, claims = verificar_es256(privada_cb.public_key(), token)
print("parts:", len(token.split(".")), "| signature:",
      len(de_b64url(token.split(".")[2])), "bytes")
print(cab["alg"], cab["kid"])
print(claims["uri"], "| lifetime:", claims["exp"] - claims["nbf"], "s")
OUT [06]An ES256 JWT in Coinbase's shape, built and verified
parts: 3 | signature: 64 bytes
ES256 organizations/org-falsa/apiKeys/clave-falsa
POST api.coinbase.com/api/v3/brokerage/orders | lifetime: 120 s

The token has three parts and a 64-byte signature, and verification with the public key passes. The cell does not print the token: it is a bearer credential (whoever has it can use it for two minutes), and in a log it must be treated like the key itself (lesson 4.4.5). The signature changes on every run, because ECDSA uses a random number; that is why Coinbase cannot publish a vector, and the check is verification with the public key.

A server that verifies all four

This section's simulator imitates the private order routes of the four exchanges and applies their authentication rules as their documentation describes them, with a logical clock: the server time is a variable the lesson moves, and the client's is that time plus an offset. That way you can see what happens with a clock that runs fast or slow without depending on the machine's clock. The rules:

  • Binance: timestamp < serverTime + 1000 and serverTime − timestamp ≤ recvWindow; otherwise, -1021. Bad signature, -1022.
  • Bybit: server_time − recv_window ≤ timestamp < server_time + 1000; otherwise, retCode 10002 (“The request time exceeds the time window range”). Bad signature, 10004. Bybit's error responses arrive with HTTP 200 and the error in retCode.
  • Kraken: each key's nonce must be greater than the last accepted one; otherwise, EAPI:Invalid nonce. Errors go in the response's error list.
  • Coinbase: a JWT with a valid signature for the kid, nbf ≤ now < exp and uri equal to the request; otherwise, 401. The simulator gives no tolerance on nbf: a real server may give some, and that has to be checked in its sandbox.
IN [07]python
import asyncio
from urllib.parse import parse_qsl
from aiohttp import web, ClientSession

RELOJ = {"ms": 1_760_449_080_000}         # server time
CLAVES = {"binance": ("FALSA-CLAVE-BN", "FALSA-SECRETO-BN"),
          "bybit": ("FALSA-CLAVE-BY", "FALSA-SECRETO-BY"),
          "kraken": ("FALSA-CLAVE-KR", base64.b64encode(
              b"FALSO-SECRETO-KR" * 4).decode())}
ULTIMO_NONCE = {}
ORDENES = []

async def binance_orden(req):
    clave, secreto = CLAVES["binance"]
    texto = req.query_string + (await req.text())
    carga, _, firma = texto.rpartition("&signature=")
    p = dict(parse_qsl(carga))
    ts, ventana = int(p["timestamp"]), int(p.get("recvWindow", 5000))
    if req.headers.get("X-MBX-APIKEY") != clave or not \
            hmac.compare_digest(firma_binance(secreto, carga), firma):
        return web.json_response({"code": -1022, "msg": "Signature "
                                  "for this request is not valid."},
                                 status=400)
    if not (ts < RELOJ["ms"] + 1000 and RELOJ["ms"] - ts <= ventana):
        return web.json_response({"code": -1021, "msg": "Timestamp for "
                                  "this request is outside of the "
                                  "recvWindow."}, status=400)
    ORDENES.append(("binance", p["newClientOrderId"]))
    return web.json_response({"clientOrderId": p["newClientOrderId"],
                              "status": "NEW"})

async def bybit_orden(req):
    clave, secreto = CLAVES["bybit"]
    h, cuerpo = req.headers, await req.text()
    ts, ventana = int(h["X-BAPI-TIMESTAMP"]), int(
        h.get("X-BAPI-RECV-WINDOW", 5000))
    esperada = firma_bybit(secreto, ts, clave, ventana, cuerpo)
    if h["X-BAPI-API-KEY"] != clave or esperada != h["X-BAPI-SIGN"]:
        return web.json_response({"retCode": 10004, "retMsg": "Error "
                                  "sign, please check your signature "
                                  "generation algorithm."})
    if not RELOJ["ms"] - ventana <= ts < RELOJ["ms"] + 1000:
        return web.json_response({"retCode": 10002, "retMsg": "The "
                                  "request time exceeds the time "
                                  "window range."})
    d = json.loads(cuerpo)
    ORDENES.append(("bybit", d["orderLinkId"]))
    return web.json_response({"retCode": 0, "retMsg": "OK", "result": {
        "orderLinkId": d["orderLinkId"]}})

SECRETOS_KRAKEN = dict([CLAVES["kraken"]])   # key -> secret

async def kraken_orden(req):
    clave = req.headers.get("API-Key")
    secreto = SECRETOS_KRAKEN.get(clave, "")
    datos = await req.text()
    p = dict(parse_qsl(datos))
    nonce = int(p["nonce"])
    firma = firma_kraken(secreto, req.path.removeprefix("/kraken"),
                         nonce, datos)
    if not secreto or firma != req.headers.get("API-Sign"):
        return web.json_response({"error": ["EAPI:Invalid signature"],
                                  "result": {}})
    if nonce <= ULTIMO_NONCE.get(clave, 0):
        return web.json_response({"error": ["EAPI:Invalid nonce"],
                                  "result": {}})
    ULTIMO_NONCE[clave] = nonce
    ORDENES.append(("kraken", p["cl_ord_id"]))
    return web.json_response({"error": [], "result": {
        "cl_ord_id": p["cl_ord_id"]}})

async def coinbase_orden(req):
    token = req.headers.get("Authorization", "").removeprefix("Bearer ")
    try:
        cab, c = verificar_es256(privada_cb.public_key(), token)
    except Exception:
        return web.json_response({"error": "UNAUTHENTICATED"},
                                 status=401)
    ahora = RELOJ["ms"] // 1000
    ruta = req.path.removeprefix("/coinbase")
    uri = f"{req.method} api.coinbase.com{ruta}"
    if cab["kid"] != KID or not c["nbf"] <= ahora < c["exp"] \
            or c["uri"] != uri:
        return web.json_response({"error": "UNAUTHENTICATED"},
                                 status=401)
    d = await req.json()
    ORDENES.append(("coinbase", d["client_order_id"]))
    return web.json_response({"success": True, "success_response": {
        "client_order_id": d["client_order_id"]}})

app = web.Application()
app.router.add_post("/binance/api/v3/order", binance_orden)
app.router.add_post("/bybit/v5/order/create", bybit_orden)
app.router.add_post("/kraken/0/private/AddOrder", kraken_orden)
app.router.add_post("/coinbase/api/v3/brokerage/orders",
                    coinbase_orden)
corredor = web.AppRunner(app)
await corredor.setup()
sitio = web.TCPSite(corredor, "127.0.0.1", 0)
await sitio.start()
BASE = f"http://127.0.0.1:{sitio._server.sockets[0].getsockname()[1]}"
print("routes:", len(app.router.routes()))
OUT [07]The simulator: four order routes with a logical clock
routes: 4

The clients are small functions, one per exchange, that take the client time (ms) and their own identifier. Each one builds the string, signs it and sends exactly that string:

IN [08]python
sesion = ClientSession()

async def enviar_binance(ms, cid, ventana=5000):
    clave, secreto = CLAVES["binance"]
    carga = carga_rest({"symbol": "XSNUSDT", "side": "BUY",
                        "type": "LIMIT", "timeInForce": "GTC",
                        "quantity": "2", "price": "61.25",
                        "newClientOrderId": cid,
                        "recvWindow": ventana, "timestamp": ms})
    cuerpo = f"{carga}&signature={firma_binance(secreto, carga)}"
    async with sesion.post(BASE + "/binance/api/v3/order", data=cuerpo,
                           headers={"X-MBX-APIKEY": clave}) as r:
        d = await r.json()
    return "ok" if r.status == 200 else str(d["code"])

async def enviar_bybit(ms, cid, ventana=5000):
    clave, secreto = CLAVES["bybit"]
    texto = json.dumps(cuerpo | {"orderLinkId": cid},
                       separators=(",", ":"))
    cab = {"X-BAPI-API-KEY": clave, "X-BAPI-TIMESTAMP": str(ms),
           "X-BAPI-RECV-WINDOW": str(ventana),
           "X-BAPI-SIGN": firma_bybit(secreto, ms, clave, ventana,
                                      texto),
           "Content-Type": "application/json"}
    async with sesion.post(BASE + "/bybit/v5/order/create", data=texto,
                           headers=cab) as r:
        d = await r.json()
    return "ok" if d["retCode"] == 0 else str(d["retCode"])

async def enviar_kraken(nonce, cid, clave_secreto=None):
    clave, secreto = clave_secreto or CLAVES["kraken"]
    datos = (f"nonce={nonce}&ordertype=limit&pair=XSNUSD&price=61.25"
             f"&type=buy&volume=2&cl_ord_id={cid}")
    cab = {"API-Key": clave, "API-Sign": firma_kraken(
        secreto, "/0/private/AddOrder", nonce, datos),
        "Content-Type": "application/x-www-form-urlencoded"}
    async with sesion.post(BASE + "/kraken/0/private/AddOrder",
                           data=datos, headers=cab) as r:
        d = await r.json()
    return "ok" if not d["error"] else d["error"][0]

async def enviar_coinbase(ms, cid):
    ruta = "/api/v3/brokerage/orders"
    nonce = hashlib.sha256(cid.encode()).hexdigest()
    jwt = jwt_es256(privada_cb, KID,
                    claims_coinbase(ms // 1000, "POST", ruta), nonce)
    cab = {"Authorization": f"Bearer {jwt}"}
    async with sesion.post(BASE + "/coinbase" + ruta,
                           json={"client_order_id": cid},
                           headers=cab) as r:
        await r.read()
    return "ok" if r.status == 200 else str(r.status)

for f in (enviar_binance, enviar_bybit, enviar_coinbase):
    print(f.__name__, await f(RELOJ["ms"], f"prueba-{f.__name__[7:]}"))
print("enviar_kraken",
      await enviar_kraken(RELOJ["ms"], "prueba-kraken"))
OUT [08]One client per exchange
enviar_binance ok
enviar_bybit ok
enviar_coinbase ok
enviar_kraken ok

With the client clock on time, all four orders are accepted: the signatures checked against the vectors also work against the server.

The client clock

Now the same client with its clock off, from two minutes and ten seconds slow to two minutes and ten seconds fast. This is what happens on a machine without time synchronization (NTP) or on a virtual machine that is suspended and resumed:

IN [09]python
import pandas as pd

desfases = [-130, -60, -6, -4, 0, 0.9, 1.1, 60, 130]   # seconds
filas = {}
for d in desfases:
    ms = RELOJ["ms"] + int(d * 1000)
    cid = f"d{d:+}"
    filas[d] = {"binance": await enviar_binance(ms, "bn" + cid),
                "bybit": await enviar_bybit(ms, "by" + cid),
                "coinbase": await enviar_coinbase(ms, "cb" + cid)}
ventanas = pd.DataFrame(filas)
ventanas.columns = [f"{d:+g} s" for d in desfases]
ventanas
OUT [09]What is accepted depending on the client clock offset
         -130 s  -60 s   -6 s -4 s +0 s +0.9 s +1.1 s  +60 s +130 s
binance   -1021  -1021  -1021   ok   ok     ok  -1021  -1021  -1021
bybit     10002  10002  10002   ok   ok     ok  10002  10002  10002
coinbase    401     ok     ok   ok   ok     ok    401    401    401

The table is the figure at the top. Binance and Bybit accept a slow clock up to their window (5 seconds here) and a fast one by less than a second: at 1.1 s fast they already reject, at 0.9 they accept. Coinbase has no receive window but the JWT is valid for two minutes: it accepts a clock up to 120 seconds slow and rejects one that is fast, however slightly, because the nbf falls in the server's future (under the simulator's strict rule). The asymmetry is deliberate in all three: an old request may be a replay (someone who captures it and resends it), and the window limits how long it is good for.

Raising recvWindow to the 60,000 ms maximum “fixes” Binance's rejections, but it only hides the symptom: the larger the window, the longer a captured request is valid, and an order that takes a minute to arrive reaches a market that has already changed. The fix is to synchronize the machine's clock and, as an additional measure, to measure the offset against the server time (Binance gives it at GET /api/v3/time) and raise an alarm if it exceeds a few hundred milliseconds.

Kraken: one nonce per key

Kraken does not look at the time. It requires each key's nonce to always increase, and its guide warns that problems appear when a key is shared between processes or when the system clock is corrected backward; also that too many invalid nonces can end in a temporary lockout3. The simulation: two processes use the time in milliseconds as the nonce and send 20 orders each, with a random network latency (fixed seed) between the moment they generate the nonce and the moment the request reaches the server. Each process sends over its own connection, so its requests arrive in the order it generated them; those of one and the other do get mixed.

IN [10]python
import numpy as np

def llegadas(semilla=4404, n=20):
    rng = np.random.default_rng(semilla)
    sucesos = []
    for proc in ("A", "B"):
        t = 1_760_449_100_000 + (0 if proc == "A" else 37)
        llega = 0
        for i in range(n):
            t += int(rng.integers(40, 160))      # how often it sends
            red = int(rng.lognormal(np.log(30), 0.8))
            llega = max(t + red, llega + 1)   # one connection: in order
            sucesos.append((llega, t, proc, i))
    return sorted(sucesos)                       # order of arrival

clave_b = ("FALSA-CLAVE-KR-B",
           base64.b64encode(b"FALSO-SECRETO-KR-B" * 4).decode())
CLAVES_PROC = {"A": CLAVES["kraken"], "B": clave_b}
SECRETOS_KRAKEN.update([clave_b])                  # register key B
resultado = {}
for modo in ("shared key", "one key per process"):
    ULTIMO_NONCE.clear()
    res = []
    for llega, nonce, proc, i in llegadas():
        kp = (CLAVES["kraken"] if modo == "shared key"
              else CLAVES_PROC[proc])
        r = await enviar_kraken(nonce, f"{modo[0]}{proc}{i}", kp)
        res.append((llega, proc, r))
    resultado[modo] = res
    malas = sum(r != "ok" for _, _, r in res)
    print(f"{modo:<20} rejected {malas:>2} of {len(res)}")
OUT [10]Two processes with the same Kraken key and with one key each
shared key           rejected  5 of 40
one key per process  rejected  0 of 40

With the shared key, 5 of the 40 orders are rejected with EAPI:Invalid nonce: every time a request from one process overtakes on the network one from the other with a higher nonce, the second arrives late. With one key per process none is rejected, even though the latencies are the same. The nonce window that Kraken lets you configure tolerates some disorder, but the clean solution is for each key to have a single nonce generator: one key per process or a single process that signs.

Fig. — Two processes sharing a Kraken key: arrival of each request and those rejected for their nonce

User streams

Orders are sent over REST, but what happens to them afterwards arrives over websocket. Each exchange authenticates its private stream in its own way1234:

ExchangeHow it authenticatesOrders channelKeeping the session alive
BinanceWebSocket API: userDataStream.subscribe.signature with apiKey, timestamp and signature (any key), or session.logon with Ed25519 and userDataStream.subscribeexecutionReport events wrapped in {"subscriptionId", "event"}The server sends a ping every 20 s and disconnects if it gets no pong within a minute
Bybit{"op": "auth", "args": [key, expires, signature]}, signature = HMAC of "GET/realtime" + expiresorder and execution topicsA {"op": "ping"} every 20 s, recommended
KrakenToken from GetWebSocketsToken, to be used within 15 minutes; it does not expire while the connection stays upexecutions channel (orders and executions) on ws-auth.kraken.com/v2A new connection requires a new token
CoinbaseJWT without uri in the subscribe message, within 5 s of connectinguser channel on advanced-trade-ws-user.coinbase.comheartbeats channel

At Binance there was a fundamental change: the listenKey method (POST /api/v3/userDataStream and the connection to stream.binance.com with that key) was deprecated in April 2025 and stopped being available on February 20, 2026. A system written before then, or a library that was not updated, stops receiving executions without any order failing: it simply stops knowing what happens to them.

signature acceptedsignature rejectedevent or pingdisconnectionorders over RESTConnectedSubscribedReconcile
Mermaid source
stateDiagram-v2
  [*] --> Connected
  Connected --> Subscribed: signature accepted
  Connected --> [*]: signature rejected
  Subscribed --> Subscribed: event or ping
  Subscribed --> Reconcile: disconnection
  Reconcile --> Connected: orders over REST

The level's example file includes three executionReport events of a limit order for 2 XSN at 61.25 USDT: NEW, a partial execution of 1.2 and the final one of 0.8 at 61.24. Their fields are those in the user stream documentation, with two nuances worth reading carefully: I is the execution identifier (not a field to ignore) and M is the one that is ignored. The local server sends them through the WebSocket API after checking the subscription signature, which is computed over the parameters sorted alphabetically:

IN [11]python
from pathlib import Path

EJ = json.loads(Path("brokers4_ejemplos.json").read_text())
INFORMES = EJ["binance_execution_report"]["eventos"]

async def ws_api(req):
    ws = web.WebSocketResponse()
    await ws.prepare(req)
    m = json.loads((await ws.receive()).data)
    p = m["params"]
    carga = "&".join(f"{k}={v}" for k, v in sorted(p.items())
                     if k != "signature")
    clave, secreto = CLAVES["binance"]
    ok = (p["apiKey"] == clave and hmac.compare_digest(
        firma_binance(secreto, carga), p["signature"])
        and RELOJ["ms"] - int(p["timestamp"]) <= 5000)
    if not ok:
        await ws.send_json({"id": m["id"], "status": 400, "error": {
            "code": -1022, "msg": "Signature for this request is not "
            "valid."}})
        await ws.close()
        return ws
    await ws.send_json({"id": m["id"], "status": 200,
                        "result": {"subscriptionId": 0}})
    for e in INFORMES:
        await ws.send_json({"subscriptionId": 0, "event": e})
    return ws

app_ws = web.Application()
app_ws.router.add_get("/ws-api/v3", ws_api)
corredor_ws = web.AppRunner(app_ws)
await corredor_ws.setup()
sitio_ws = web.TCPSite(corredor_ws, "127.0.0.1", 0)
await sitio_ws.start()
WS = (f"http://127.0.0.1:{sitio_ws._server.sockets[0].getsockname()[1]}"
      "/ws-api/v3")

clave, secreto = CLAVES["binance"]
params = {"apiKey": clave, "timestamp": RELOJ["ms"]}
params["signature"] = firma_binance(
    secreto, "&".join(f"{k}={v}" for k, v in sorted(params.items())))
ws = await sesion.ws_connect(WS)
await ws.send_json({"id": "sub-1", "method":
                    "userDataStream.subscribe.signature",
                    "params": params})
print(await ws.receive_json())
acumulado, caja, comision = 0.0, 0.0, {}
while True:
    e = (await ws.receive_json())["event"]
    l, z = float(e["l"]), float(e["z"])
    acumulado += l
    caja += float(e["Y"])
    if e["N"]:
        comision[e["N"]] = comision.get(e["N"], 0) + float(e["n"])
    assert abs(acumulado - z) < 1e-12, "an execution is missing"
    print(f"{e['x']:<6} {e['X']:<17} l={e['l'][:4]} z={e['z'][:4]} "
          f"L={e['L'][:5]} exec={e['I']} maker={e['m']}")
    if e["X"] in {"FILLED", "CANCELED", "EXPIRED", "REJECTED"}:
        break
media = float(e["Z"]) / float(e["z"])
print(f"average Z/z = {media:.4f}; traded value {caja:.3f} = Z {e['Z'][:7]}")
print("commission:", comision)
OUT [11]The Binance user stream through the WebSocket API
{'id': 'sub-1', 'status': 200, 'result': {'subscriptionId': 0}}
NEW    NEW               l=0.00 z=0.00 L=0.000 exec=11234567 maker=False
TRADE  PARTIALLY_FILLED  l=1.20 z=1.20 L=61.25 exec=11234568 maker=True
TRADE  FILLED            l=0.80 z=2.00 L=61.24 exec=11234569 maker=False
average Z/z = 61.2460; traded value 122.492 = Z 122.492
commission: {'XSN': 0.002}

The client adds up the quantities of each execution (l) and checks on every event that they match the cumulative total sent by the exchange (z): if a message were lost, the check would fail on the next one. The average price comes from dividing the cumulative traded value (Z) by the quantity (z), as the documentation indicates: 61.246 USDT. The first execution was as maker (m = True, the order was resting on the book) and the second as taker; the commission is charged in XSN, the coin bought: 0.002 in total, 0.1% of what was bought.

The same, in ccxt's unified structure

ccxt translates these events into its order structure (watch_orders in ccxt.pro, fetch_order over REST). The example file includes that structure for the same order. The cell checks that its keys are those of safe_order, the function with which ccxt builds the structure in the installed version, and that its figures agree with the events:

IN [12]python
unificada = EJ["ccxt_orden"]["orden"]
claves_ccxt = set(ccxt.Exchange().safe_order({"id": "1"}))
print("missing keys:", claves_ccxt - set(unificada),
      "| extra:", set(unificada) - claves_ccxt)
comparar = {"filled": (unificada["filled"], float(e["z"])),
            "cost": (unificada["cost"], float(e["Z"])),
            "average": (unificada["average"], round(media, 6)),
            "fee": (unificada["fee"]["cost"], comision["XSN"]),
            "clientOrderId": (unificada["clientOrderId"], e["c"]),
            "lastTradeTimestamp": (unificada["lastTradeTimestamp"],
                                   e["T"])}
for k, (a, b) in comparar.items():
    print(f"{k:<19} ccxt {a!s:<15} stream {b!s:<15} {a == b}")
OUT [12]ccxt's unified order versus the executionReport events
missing keys: {'stopPrice'} | extra: {'info'}
filled              ccxt 2.0             stream 2.0             True
cost                ccxt 122.492         stream 122.492         True
average             ccxt 61.246          stream 61.246          True
fee                 ccxt 0.002           stream 0.002           True
clientOrderId       ccxt R4-cripto-0001  stream R4-cripto-0001  True
lastTradeTimestamp  ccxt 1760449081445   stream 1760449081445   True

The figures match. The example structure lacks one of the keys safe_order produces (stopPrice, which in ccxt is an old alias of triggerPrice) and includes info, where ccxt keeps the untouched native response. That info field is the bridge between the two worlds: when something is not in the unified structure (the execution's I, the self-trade prevention mode V), it is in info.

IN [13]binance_ccxtpro_ordenes.pypython
import asyncio
import os
import ccxt.pro

async def main():
    ex = ccxt.pro.binance({"apiKey": os.environ["BINANCE_KEY"],
                           "secret": os.environ["BINANCE_SECRET"]})
    ex.set_sandbox_mode(True)                  # testnet
    try:
        while True:
            for o in await ex.watch_orders("XSN/USDT"):
                print(o["clientOrderId"], o["status"], o["filled"],
                      o["info"].get("I"))        # native field
    finally:
        await ex.close()

asyncio.run(main())

Which method ccxt.pro uses underneath for the Binance user stream (the old listenKey or the WebSocket API) depends on the version: after a change like the one in February 2026, you have to check that the installed version uses the current mechanism before relying on watch_orders.

What goes wrong with native APIs

  • Signing one string and sending another. Parameter order, encoding, JSON spaces. Build the string once and use it for both.
  • The clock. Bursts of -1021 or 10002 rejections are usually a drifting clock. NTP on the machine and an alarm on the offset against the exchange's time.
  • Shared nonces. A Kraken key in two processes, or two threads with separate counters. One generator per key.
  • Streams that change. Binance's listenKey stopped working in February 2026; Coinbase's HMAC keys, in February 2025. Reading each exchange's changelog is part of maintaining the system.
  • Errors with HTTP 200. Bybit (retCode) and Kraken (error) return 200 with the error inside: a client that only looks at the HTTP code accepts rejected orders as good.
  • Per-IP limits. At Binance limits are counted per IP, not per key, with the X-MBX-USED-WEIGHT-* and X-MBX-ORDER-COUNT-* headers; after a 429, continuing to send ends in a 418 and an IP ban of 2 minutes to 3 days. Bybit allows 600 requests in 5 seconds per IP and bans for 10 minutes with a 403.
  • Credentials. All of the above assumes keys with only the permissions needed, stored outside the code (lesson 4.4.5).
IN [14]python
await ws.close()
await sesion.close()
await corredor.cleanup()
await corredor_ws.cleanup()
print("orders accepted by the simulator:", len(ORDENES))
OUT [14]Shutting down the simulator
orders accepted by the simulator: 90

Exercises

  1. Add to the simulator the authentication of Bybit's private websocket: the client sends {"op": "auth", "args": [key, expires, signature]} with the HMAC signature of "GET/realtime" + expires, and the server accepts if the signature is correct and expires is later than its time. Try an expires in the future and another in the past.
Solution
IN [15]python
async def bybit_privado(req):
    ws = web.WebSocketResponse()
    await ws.prepare(req)
    m = json.loads((await ws.receive()).data)
    clave, expira, firma = m["args"]
    _, secreto = CLAVES["bybit"]
    esperada = hmac.new(secreto.encode(),
                        f"GET/realtime{expira}".encode(),
                        hashlib.sha256).hexdigest()
    ok = clave == CLAVES["bybit"][0] and firma == esperada \
        and expira > RELOJ["ms"]
    await ws.send_json({"success": ok, "op": "auth",
                        "ret_msg": "" if ok else "auth failed"})
    await ws.close()
    return ws

app_by = web.Application()
app_by.router.add_get("/v5/private", bybit_privado)
c_by = web.AppRunner(app_by)
await c_by.setup()
s_by = web.TCPSite(c_by, "127.0.0.1", 0)
await s_by.start()
url_by = f"http://127.0.0.1:{s_by._server.sockets[0].getsockname()[1]}"

async with ClientSession() as s:
    for nombre, expira in [("future", RELOJ["ms"] + 1000),
                           ("past", RELOJ["ms"] - 1000)]:
        clave, secreto = CLAVES["bybit"]
        f = hmac.new(secreto.encode(), f"GET/realtime{expira}".encode(),
                     hashlib.sha256).hexdigest()
        w = await s.ws_connect(url_by + "/v5/private")
        await w.send_json({"op": "auth", "args": [clave, expira, f]})
        print(nombre, await w.receive_json())
        await w.close()
await c_by.cleanup()
OUT [15]Authentication of Bybit's private websocket
future {'success': True, 'op': 'auth', 'ret_msg': ''}
past {'success': False, 'op': 'auth', 'ret_msg': 'auth failed'}

The expires in the future is accepted and the one in the past is not: it is a validity window like recvWindow, but set by the client (Bybit's documentation computes it as the current time plus one second). The signature includes no subscription data: it authenticates the connection, and from then on subscriptions to order or execution go unsigned.

  1. In the Kraken simulation with the shared key, how many rejections would there be if the server accepted nonces up to 100 ms below the last accepted one (without repeating any)? Modify the check and count again.
Solution
IN [16]python
def con_ventana(sucesos, ventana):
    ultimo, usados, malas = 0, set(), 0
    for llega, nonce, proc, i in sucesos:
        if nonce in usados or nonce <= ultimo - ventana:
            malas += 1
            continue
        usados.add(nonce)
        ultimo = max(ultimo, nonce)
    return malas

for v in (0, 50, 100, 200):
    print(f"window {v:>3} ms: rejected {con_ventana(llegadas(), v)}")
OUT [16]A 100 ms nonce window
window   0 ms: rejected 5
window  50 ms: rejected 2
window 100 ms: rejected 1
window 200 ms: rejected 0

With a window of 0 the rule is the strict one, with one detail: here a nonce equal to the last one is already in usados and is rejected anyway, so the count matches the simulator's. With 100 ms of tolerance the rejections drop to almost zero, because the disorder in the simulation comes from network latencies of tens of milliseconds. The window is a tolerance, not a solution: the larger it is, the easier it is for a replayed request with an old nonce to get through. This is the simulator's interpretation; Kraken's documentation describes the window as “a tolerance between nonce values” with no further detail, and its exact behavior has to be checked on the account.

  1. A system trades on Binance with HMAC keys created in 2024 and receives executions with a listenKey. Make the list of changes it needs to keep working in 2026 and of what you would check on the testnet before moving it to live.
Solution

Three changes. The user stream: replace the listenKey with the WebSocket API using userDataStream.subscribe.signature (it works with the current key) or, better, with session.logon and userDataStream.subscribe using an Ed25519 key; events now arrive wrapped in subscriptionId and event, so the parser changes. The signature: percent-encode the payload before signing (mandatory since January 15, 2026; without it, -1022 as soon as a non-ASCII or reserved character appears). The keys: create an Ed25519 one (the recommended type; HMAC keys are marked as deprecated), with only the permissions needed and IP-restricted, and retire the HMAC key once the new one works (the rotation procedure of lesson 4.4.5). On the testnet: a limit order that executes in two parts, checking that the executionReport events arrive, that z agrees with the sum of l and that reconnection reconciles over REST; a request with the clock shifted 6 seconds to see the -1021; and a forced disconnection to check that the system subscribes again. If ccxt is used, also check which mechanism the installed version uses for watch_orders.

Footnotes

  1. Binance, official spot API documentation (source repository of developers.binance.com): rest-api.md (signed endpoints, HMAC, RSA, Ed25519, recvWindow, limits), web-socket-api.md (userDataStream.subscribe.signature, ping), user-data-stream.md (executionReport), errors.md (-1021, -1022), faqs/api_key_types.md and CHANGELOG.md (entries of 2025-04-07, 2025-12-17 and 2026-01-21). Accessed on October 22, 2026. ↩ ↩2 ↩3

  2. Bybit, Integration Guidance (authentication), WebSocket Connect, Error codes, Rate limit and Demo trading; official example code in api-usage-examples. Accessed on October 22, 2026. ↩ ↩2 ↩3

  3. Kraken, Spot REST authentication, API keys, spot errors, executions (WebSocket v2) and GetWebSocketsToken. Accessed on October 22, 2026. ↩ ↩2 ↩3 ↩4

  4. Coinbase, API key authentication, legacy keys, WebSocket overview, user channel and sandbox. Accessed on October 22, 2026. ↩ ↩2 ↩3

  5. S. Josefsson and I. Liusvaara, RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA), section 7.1, IETF, 2017. ↩

Each answer is folded under its question

Self-assessment

  1. A bot sends Binance a timestamp 6 seconds behind the server clock with recvWindow 5000. What happens?

    • AIt is accepted: the delay is small
    • BIt is rejected with -1022 INVALID_SIGNATURE
    • CIt is rejected with -1021 INVALID_TIMESTAMP: the server requires serverTime − timestamp ≤ recvWindow
    • DIt is accepted, but with a higher fee
    Show answer

    Answer C. It is rejected with -1021 INVALID_TIMESTAMP: the server requires serverTime − timestamp ≤ recvWindow

    The documented rule is timestamp < serverTime + 1 s and serverTime − timestamp ≤ recvWindow. With a 6,000 ms delay and a 5,000 window, the request is outside. The fix is to synchronize the clock (NTP), not to raise the window to 60,000 ms.

  2. Two processes use the same Kraken key with a nonce equal to the time in milliseconds. What problem appears?

    • ARequests that arrive out of order carry a nonce lower than the last accepted one and are rejected with EAPI:Invalid nonce
    • BNone: each process has its own clock
    • CKraken charges double
    • DThe signature stops being valid
    Show answer

    Answer A. Requests that arrive out of order carry a nonce lower than the last accepted one and are rejected with EAPI:Invalid nonce

    Each key's nonce must always increase. Two processes with the same key compete: the request that arrives later with a lower nonce is rejected. In the lesson's simulation, 5 of 40 requests; with one key per process, none.

  3. Why does the Bybit signature of a POST depend on how the JSON body is serialized?

    • ABecause Bybit reorders the JSON keys
    • BBecause Bybit only accepts XML
    • CIt does not depend on it
    • DBecause the signature is an HMAC of timestamp + api_key + recv_window + body, with the body as text: one extra space changes the bytes and the signature
    Show answer

    Answer D. Because the signature is an HMAC of timestamp + api_key + recv_window + body, with the body as text: one extra space changes the bytes and the signature

    The server recomputes the HMAC over the bytes it receives. If you sign json.dumps(body) with spaces and send a different text without them, the signature does not match (retCode 10004). You have to sign exactly the string you send.

  4. Since February 20, 2026, how do you receive the spot user stream at Binance?

    • AWith a listenKey from POST /api/v3/userDataStream
    • BThrough the WebSocket API: userDataStream.subscribe.signature (any key type) or session.logon and userDataStream.subscribe (Ed25519 keys)
    • COnly with ccxt
    • DBy email
    Show answer

    Answer B. Through the WebSocket API: userDataStream.subscribe.signature (any key type) or session.logon and userDataStream.subscribe (Ed25519 keys)

    The Binance changelog announced that the listenKey userDataStream endpoints would no longer be available on 2026-02-20. Subscription is now done through the WebSocket API, and events arrive wrapped in {"subscriptionId": …, "event": {…}}.

  5. A Coinbase Advanced Trade JWT is signed at 10:00:00 with exp = nbf + 120. Can it be reused at 10:01:30 for another order?

    • AYes, as long as it has not expired
    • BYes, if you change the kid
    • CNo: the uri claim carries the method and path of a specific request and the documentation asks for a different JWT per request; besides, it expires after two minutes
    • DOnly in the sandbox
    Show answer

    Answer C. No: the uri claim carries the method and path of a specific request and the documentation asks for a different JWT per request; besides, it expires after two minutes

    The documentation says the JWT expires after 2 minutes and that you have to generate a different one for each unique request. The uri (POST api.coinbase.com/api/v3/brokerage/orders) ties the token to a method and a path.

Key points

  • ccxt signs, adds timestamp and recvWindow (10,000 ms by default for Binance in version 4.5.89) and, if you do not give your own identifier, injects one with its broker prefix: the native API is what lies underneath.
  • Four schemes: hex HMAC-SHA256 of the parameter string (Binance), HMAC-SHA256 of timestamp + key + window + payload (Bybit), base64 HMAC-SHA512 of path + SHA-256 of nonce and data (Kraken) and a per-request ES256 JWT (Coinbase). Binance recommends Ed25519 keys and marks HMAC ones as deprecated.
  • You sign the exact string you send: parameter order, percent-encoding (mandatory at Binance since January 2026) and JSON spaces change the signature.
  • The clock matters: Binance and Bybit reject what arrives more than a second early or older than the window; Coinbase, what falls outside nbf–exp. Kraken does not look at the time but requires increasing nonces per key: one key per process.
  • User streams authenticate in different ways (signature on subscription, 15-minute token, JWT, auth message) and require their own heartbeats; the order's state is checked against the cumulative totals (z, Z) and reconciled over REST on reconnection.

Resources

Data