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.
- Module
- 4.4 · Brokers and APIs
- Duration
- 75 min
- Prerequisites
- 1.3.5 · Crypto and on-chain data, 1.4.1 · Downloading over REST and websockets: pagination, rate limits and retries, 3.7.4 · ccxt for trading on multiple exchanges, 4.4.3 · Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
- Author
- Jesús Cuesta
- Updated
Level 4 lessons
4.1 · Portfolio theory and optimisation
- 4.1.1 Markowitz: efficient frontier and real constraints
- 4.1.2 Covariance estimation: shrinkage, factor models and denoising
- 4.1.3 Black-Litterman: from market equilibrium to views
- 4.1.4 Risk parity, minimum variance, HRP and HERC
- 4.1.5 Optimization with CVaR, drawdown, turnover and costs
- 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
- 4.1.7 Rebalancing and strategy portfolios
- 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
4.2 · Risk management and position sizing
- 4.2.1 Risk per trade, stops and risk of ruin
- 4.2.2 Kelly criterion and volatility targeting: how much to risk
- 4.2.3 VaR, Expected Shortfall and VaR backtesting
- 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
- 4.2.5 Drawdown control and kill switch
- 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
4.3 · Microstructure and execution
4.4 · Brokers and APIs
- 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
- 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
- 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
- 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
- 4.4.5 Secure credentials: environment variables, secret managers and least privilege
4.5 · Live trading system
- 4.5.1 Live system architecture: from data to reconciliation
- 4.5.2 Order management: states, partial fills, cancellations and reconnections
- 4.5.3 Paper trading, shadow trading and slippage monitoring
- 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
- 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
- 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:
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"])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: Trueccxt 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 spot | Bybit V5 | Kraken spot | Coinbase Advanced Trade | |
|---|---|---|---|---|
| What is signed | Parameter string (query + body) | timestamp + key + recv_window + query or JSON body | Path + SHA-256(nonce + data) | A JWT with method, host and path |
| Algorithm | HMAC-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 goes | signature parameter; key in X-MBX-APIKEY | Headers X-BAPI-API-KEY, X-BAPI-TIMESTAMP, X-BAPI-RECV-WINDOW, X-BAPI-SIGN | Headers API-Key and API-Sign | Authorization: Bearer <JWT> |
| Time control | recvWindow (5,000 ms by default, maximum 60,000) | recv_window (5,000 ms by default) | nonce always increasing per key | nbf and exp = nbf + 120 s |
| Test environment | testnet.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 consulted | Sandbox 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.
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.
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])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: FalseAll 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:
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))matches: True | length: 88Bybit: 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.
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]}…")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.:
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] + "…")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.
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")parts: 3 | signature: 64 bytes
ES256 organizations/org-falsa/apiKeys/clave-falsa
POST api.coinbase.com/api/v3/brokerage/orders | lifetime: 120 sThe 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 + 1000andserverTime − timestamp ≤ recvWindow; otherwise,-1021. Bad signature,-1022. - Bybit:
server_time − recv_window ≤ timestamp < server_time + 1000; otherwise,retCode10002 (“The request time exceeds the time window range”). Bad signature, 10004. Bybit's error responses arrive with HTTP 200 and the error inretCode. - Kraken: each key's nonce must be greater than the last accepted one; otherwise,
EAPI:Invalid nonce. Errors go in the response'serrorlist. - Coinbase: a JWT with a valid signature for the
kid,nbf ≤ now < expanduriequal to the request; otherwise, 401. The simulator gives no tolerance onnbf: a real server may give some, and that has to be checked in its sandbox.
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()))routes: 4The 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:
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"))enviar_binance ok
enviar_bybit ok
enviar_coinbase ok
enviar_kraken okWith 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:
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 -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 401The 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.
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)}")shared key rejected 5 of 40
one key per process rejected 0 of 40With 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.
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:
| Exchange | How it authenticates | Orders channel | Keeping the session alive |
|---|---|---|---|
| Binance | WebSocket API: userDataStream.subscribe.signature with apiKey, timestamp and signature (any key), or session.logon with Ed25519 and userDataStream.subscribe | executionReport 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" + expires | order and execution topics | A {"op": "ping"} every 20 s, recommended |
| Kraken | Token from GetWebSocketsToken, to be used within 15 minutes; it does not expire while the connection stays up | executions channel (orders and executions) on ws-auth.kraken.com/v2 | A new connection requires a new token |
| Coinbase | JWT without uri in the subscribe message, within 5 s of connecting | user channel on advanced-trade-ws-user.coinbase.com | heartbeats 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.
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:
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){'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:
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}")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 TrueThe 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.
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
-1021or10002rejections 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
listenKeystopped 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-*andX-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).
await ws.close()
await sesion.close()
await corredor.cleanup()
await corredor_ws.cleanup()
print("orders accepted by the simulator:", len(ORDENES))orders accepted by the simulator: 90Exercises
- 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 andexpiresis later than its time. Try anexpiresin the future and another in the past.
Solution
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()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.
- 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
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)}")window 0 ms: rejected 5
window 50 ms: rejected 2
window 100 ms: rejected 1
window 200 ms: rejected 0With 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.
- 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
-
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.mdandCHANGELOG.md(entries of 2025-04-07, 2025-12-17 and 2026-01-21). Accessed on October 22, 2026. ↩ ↩2 ↩3 -
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
-
Kraken, Spot REST authentication, API keys, spot errors, executions (WebSocket v2) and GetWebSocketsToken. Accessed on October 22, 2026. ↩ ↩2 ↩3 ↩4
-
Coinbase, API key authentication, legacy keys, WebSocket overview, user channel and sandbox. Accessed on October 22, 2026. ↩ ↩2 ↩3
-
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
A bot sends Binance a
timestamp6 seconds behind the server clock withrecvWindow5000. 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 requiresserverTime − timestamp ≤ recvWindow - DIt is accepted, but with a higher fee
Show answer
Answer C. It is rejected with
-1021 INVALID_TIMESTAMP: the server requiresserverTime − timestamp ≤ recvWindowThe documented rule is
timestamp < serverTime + 1 sandserverTime − 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.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 nonceEach 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.
- ARequests that arrive out of order carry a nonce lower than the last accepted one and are rejected with
Why does the Bybit signature of a
POSTdepend 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 signatureThe 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.Since February 20, 2026, how do you receive the spot user stream at Binance?
- AWith a
listenKeyfromPOST /api/v3/userDataStream - BThrough the WebSocket API:
userDataStream.subscribe.signature(any key type) orsession.logonanduserDataStream.subscribe(Ed25519 keys) - COnly with ccxt
- DBy email
Show answer
Answer B. Through the WebSocket API:
userDataStream.subscribe.signature(any key type) orsession.logonanduserDataStream.subscribe(Ed25519 keys)The Binance changelog announced that the listenKey
userDataStreamendpoints would no longer be available on 2026-02-20. Subscription is now done through the WebSocket API, and events arrive wrapped in{"subscriptionId": …, "event": {…}}.- AWith a
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
uriclaim 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
uriclaim carries the method and path of a specific request and the documentation asks for a different JWT per request; besides, it expires after two minutesThe 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
timestampandrecvWindow(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,
authmessage) and require their own heartbeats; the order's state is checked against the cumulative totals (z,Z) and reconciled over REST on reconnection.
Resources
Documentation
- Binance Spot API: official documentation (endpoint security, WebSocket API, user data stream, errors)
- Bybit V5: integration guidance (authentication) and websocket connection
- Kraken: spot REST API authentication
- Coinbase Advanced Trade: API key authentication (JWT)
- RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA)