---
title: "4.4.4 Crypto: ccxt and native exchange APIs"
description: "Binance, Bybit, Kraken and Coinbase signatures against the official vectors, time windows, nonces and user streams, in a local simulator."
url: "https://pythonparatrading.com/en/python-trading-course/level-4/crypto-exchange-apis-ccxt-and-native/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-4/crypto-exchange-apis-ccxt-and-native.png"
---

# 4.4.4 · 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.

- Course: [Level 4: Portfolios, risk, execution and live trading](https://pythonparatrading.com/en/python-trading-course/level-4/)
- Module: 4.4 · Brokers and APIs
- Duration: 75 min
- Prerequisites: [1.3.5 · Crypto and on-chain data](https://pythonparatrading.com/en/python-trading-course/level-1/crypto-and-on-chain-data/), [1.4.1 · Downloading over REST and websockets: pagination, rate limits and retries](https://pythonparatrading.com/en/python-trading-course/level-1/rest-websockets-pagination-and-retries/), [3.7.4 · ccxt for trading on multiple exchanges](https://pythonparatrading.com/en/python-trading-course/level-3/ccxt-multi-exchange-trading/), [4.4.3 · Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20](https://pythonparatrading.com/en/python-trading-course/level-4/alpaca-tradier-metatrader-5-and-oanda/)

## By the end of this lesson

- Explain what ccxt does when it signs a request and when it is worth dropping down to an exchange's native API.
- Implement the HMAC-SHA256 signatures of Binance and Bybit, Kraken's HMAC-SHA512, Ed25519 and Coinbase's ES256 JWT, and check them against official vectors.
- Predict which requests each exchange rejects because of clock skew (`recvWindow`, `recv_window`, `nbf` and `exp`) and because of out-of-order nonces.
- Subscribe to the Binance user stream through the WebSocket API and rebuild an order from its `executionReport` events.
- Compare the rebuilt order with ccxt's unified structure and keep each exchange's private sessions alive.

## 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:

```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"])
```
```output
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 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 notes[^1][^2][^3][^4]. 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
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, unencoded[^1].

```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])
```
```output
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 signature[^3]:

```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))
```
```output
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-SHA256[^2]. 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.

```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]}…")
```
```output
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` library[^5]:

```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] + "…")
```
```output
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
request[^4]. `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.

```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")
```
```output
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.

```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()))
```
```output
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:

```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"))
```
```output
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:

```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
```
```output
         -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 lockout[^3]. 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.

```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)}")
```
```output
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.* Two rows of marks, one per process, with each one's 20 requests over about 2.1 seconds. The 5 highlighted marks are requests rejected with EAPI:Invalid nonce: they arrived after one from the other process with a larger nonce. With one key per process there are no rejections.

## 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 way[^1][^2][^3][^4]:

| 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
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:

```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)
```
```output
{'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:

```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}")
```
```output
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`.

```python skip title="binance_ccxtpro_ordenes.py"
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).

```python
await ws.close()
await sesion.close()
await corredor.cleanup()
await corredor_ws.cleanup()
print("orders accepted by the simulator:", len(ORDENES))
```
```output
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.**

```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()
```
```output
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.

2. 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.**

```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)}")
```
```output
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.

3. 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`.

[^1]: Binance, [official spot API documentation](https://github.com/binance/binance-spot-api-docs) (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]: Bybit, [Integration Guidance](https://bybit-exchange.github.io/docs/v5/guide) (authentication), [WebSocket Connect](https://bybit-exchange.github.io/docs/v5/ws/connect), [Error codes](https://bybit-exchange.github.io/docs/v5/error), [Rate limit](https://bybit-exchange.github.io/docs/v5/rate-limit) and [Demo trading](https://bybit-exchange.github.io/docs/v5/demo); official example code in [api-usage-examples](https://github.com/bybit-exchange/api-usage-examples). Accessed on October 22, 2026.
[^3]: Kraken, [Spot REST authentication](https://docs.kraken.com/exchange/guides/rest/authentication), [API keys](https://docs.kraken.com/exchange/guides/rest/api-keys), [spot errors](https://docs.kraken.com/api/docs/guides/spot-errors), [executions (WebSocket v2)](https://docs.kraken.com/exchange/api-reference/spot-websocket-v2/executions) and [GetWebSocketsToken](https://docs.kraken.com/api-reference/trading/get-websockets-token). Accessed on October 22, 2026.
[^4]: Coinbase, [API key authentication](https://docs.cdp.coinbase.com/coinbase-app/authentication-authorization/api-key-authentication), [legacy keys](https://docs.cdp.coinbase.com/coinbase-app/authentication-authorization/legacy-keys), [WebSocket overview](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/websocket/websocket-overview), [user channel](https://docs.cdp.coinbase.com/api-reference/advanced-trade-api/websocket/user) and [sandbox](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/sandbox). Accessed on October 22, 2026.
[^5]: S. Josefsson and I. Liusvaara, [RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA)](https://www.rfc-editor.org/rfc/rfc8032), section 7.1, IETF, 2017.

## Self-assessment

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

   - A. It is accepted: the delay is small
   - B. It is rejected with `-1022 INVALID_SIGNATURE`
   - C. It is rejected with `-1021 INVALID_TIMESTAMP`: the server requires `serverTime − timestamp ≤ recvWindow`
   - D. It is accepted, but with a higher fee

   *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?

   - A. Requests that arrive out of order carry a nonce lower than the last accepted one and are rejected with `EAPI:Invalid nonce`
   - B. None: each process has its own clock
   - C. Kraken charges double
   - D. The signature stops being valid

   *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?

   - A. Because Bybit reorders the JSON keys
   - B. Because Bybit only accepts XML
   - C. It does not depend on it
   - 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

   *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?

   - A. With a `listenKey` from `POST /api/v3/userDataStream`
   - B. Through the WebSocket API: `userDataStream.subscribe.signature` (any key type) or `session.logon` and `userDataStream.subscribe` (Ed25519 keys)
   - C. Only with ccxt
   - D. By email

   *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?

   - A. Yes, as long as it has not expired
   - B. Yes, if you change the `kid`
   - 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
   - D. Only in the sandbox

   *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

### Research

- [Finite-State Machines for Quantitative Trading Execution in Python](https://pythonparatrading.com/en/finite-state-machines-trading-python/) · Research-0029 · 47 min

### Documentation

- [Binance Spot API: official documentation (endpoint security, WebSocket API, user data stream, errors)](https://github.com/binance/binance-spot-api-docs) · github.com
- [Bybit V5: integration guidance (authentication) and websocket connection](https://bybit-exchange.github.io/docs/v5/guide) · bybit-exchange.github.io
- [Kraken: spot REST API authentication](https://docs.kraken.com/exchange/guides/rest/authentication) · docs.kraken.com
- [Coinbase Advanced Trade: API key authentication (JWT)](https://docs.cdp.coinbase.com/coinbase-app/authentication-authorization/api-key-authentication) · docs.cdp.coinbase.com
- [RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA)](https://www.rfc-editor.org/rfc/rfc8032) · rfc-editor.org

### Data

- [Level 4 data: sample broker and exchange messages (JSON)](https://pythonparatrading.com/curso/datos/nivel-4/brokers4_ejemplos.json) · Download · brokers4_ejemplos.json

Previous lesson: [4.4.3 · Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20](https://pythonparatrading.com/en/python-trading-course/level-4/alpaca-tradier-metatrader-5-and-oanda/)

Next lesson: [4.4.5 · Secure credentials: environment variables, secret managers and least privilege](https://pythonparatrading.com/en/python-trading-course/level-4/secure-api-credentials-and-least-privilege/)
