Short answer: To use the Bitvavo API in Python, sign each private request with HMAC-SHA256 over timestamp, method, path and body, and send it to https://api.bitvavo.com/v2 with four Bitvavo-Access headers. Create, update and cancel calls also need an integer operatorId. As read on 6 November 2026, the default limit is 1,000 weight points a minute, and going over it returns HTTP 429 with errorCode 105.
Facts come from Bitvavo's official docs, specs and FAQ, read on 6 November 2026 (Istanbul time); code ran that day on Python 3.11.3 with requests 2.31.0. The docs list no sandbox, so signed calls were tested against a local mock server.
This is a technical guide, not a recommendation to use Bitvavo or any strategy, and automated trading can lose money. ESMA's interim MiCA register (updated 5 November 2026) lists Bitvavo B.V. as authorized by the Dutch AFM on 26 June 2025 for custody, operating a trading platform and transfer services. Your country's rules and Bitvavo's terms still decide what you may do. Article 99/A of Capital Markets Law No. 6362 treats activity by foreign crypto-asset platforms directed at Turkish residents as unauthorized.
Bitvavo API key: permissions and IP whitelist
Create a key in your Bitvavo account under Settings, API tab, after enabling two-factor authentication; the secret is shown only once.
- View access, called Read-only in the REST introduction: balances and transactions.
- Trade digital assets: create, update and cancel orders. Reading orders and trades needs both permissions.
- Withdraw digital assets: API withdrawals skip 2FA and email confirmation, so a trading bot should not hold it.
Whitelist your server's IP address: calls from elsewhere get errorCode 307, and missing permissions 310 to 312. Storage and rotation are in API key security for trading bots.
How Bitvavo API authentication works
Each private REST call carries four headers, and the signature is a hex HMAC-SHA256, keyed with your secret, of timestamp + method + path + body.
The headers are Bitvavo-Access-Key (64 characters per the spec), Bitvavo-Access-Timestamp (Unix milliseconds), Bitvavo-Access-Signature and the optional Bitvavo-Access-Window: default 10,000 ms, maximum 60,000 and, per errorCode 303, minimum 100.
- The path includes
/v2. With the secretbitvavo, the docs' order example signs to44d02272..., which the code below reproduces. - GET signs an empty body; otherwise sign and send the same string. The official SDK signs compact JSON but sends it through requests'
json=, which adds spaces. A mock checking raw bytes rejected that; Bitvavo's own behavior could not be tested without a key. - The introduction says path; the spec says path and parameters, and cancels put
operatorIdin the query. The SDK signs/v2/orders?market=BTC-EURwith the query, as the client below does; confirm it with a read-only key. - Requests outside the window fail with errorCode 304. The test machine ran about 1.3 seconds behind
/timeon 6 November 2026. - On the WebSocket, send the
authenticateaction with a signature of timestamp +GET+/v2/websocket.
The general method is in API signature generation.
Bitvavo API rate limit: 1,000 weight points a minute
Every REST request and WebSocket action costs weight from a default 1,000 points a minute, counted across your accounts with a key and per IP address without one.
REST responses carry bitvavo-ratelimit-limit, -remaining and -resetat (milliseconds); WebSocket responses carry none. Going over returns HTTP 429 with errorCode 105; with a key, the docs' example is blocked from 12:00:15 to 12:02:00, and without one for 15 minutes by IP.
| Request | Weight |
|---|---|
| Order book, candles, ticker price and book, time, markets, assets | 1 |
| Ticker 24h | 1 with market, 25 without |
| Create, update, get or cancel one order | 1 |
| Public trades, trade history, orders, balance | 5 |
| Open orders | 5 with market, 100 without |
| Cancel orders | 25 with market, 100 without |
| Atomic cancel orders | 100 |
On 19 May 2026 open orders went from 1 and 25 to 5 and 100, and cancels started costing weight.
Cached responses carry stale headers. On 6 November 2026, candles responses came from Cloudflare's cache (cf-cache-status: HIT, Age up to 28 seconds); one showed 988 points remaining and a resetat already past, while /time showed 998 after the reset. Hits did not appear to spend weight. Consecutive weight-1 calls lowered the count by 0, 1 or 2, so keep a reserve.
Hypothetical example. Polling open orders for all markets every 5 seconds costs 12 x 100 = 1,200 points a minute, more than the whole budget; three named markets cost 12 x 3 x 5 = 180.
Bitvavo API error codes that need different handling
Check the HTTP status before the errorCode, because errorCode 429 is a decimals error inside an HTTP 400, not a rate limit.
| HTTP | errorCode | Meaning | Handling |
|---|---|---|---|
| 400 | 429 | Too many decimal places | Fix the value; waiting does nothing |
| 400 | 422 | Price off the tick size | Use tickSize from /markets |
| 403 | 304, 309 | Outside the window; bad signature | Sync the clock; check the signed string |
| 429 | 105 | Rate limit, blocked | Wait until resetat |
| 503 | 107, 108 | Overloaded or processing issue | Retry after 500 ms |
| 503 | 109 | Timed out; may have succeeded | Get the order before resending |
The docs advise retrying 500 after 500 ms, 1 s, 2 s and so on, 503 after 500 ms, and never 400, 403 or 404 (see exponential backoff with jitter); after 109, look the order up by its client order ID. One conflict: the spec and errorCode 235 say 100 open orders per market, but /markets returned maxOpenOrders 400 for BTC-EUR on 6 November 2026; which applies was not testable without a key, so handle errorCode 235.
Bitvavo API Python example, tested
The client signs as documented, sends the bytes it signed, takes weight only from uncached responses and validates orders before any network call.
import hashlib, hmac, json, time
from decimal import Decimal
from urllib.parse import urlencode
import requests
HOST = "https://api.bitvavo.com"
class BitvavoError(Exception):
def __init__(self, status, code, text):
super().__init__(f"HTTP {status}, errorCode {code}: {text}")
self.status, self.code = status, code
def sign(secret, ts, method, path, body=""):
msg = f"{ts}{method}{path}{body}".encode()
return hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
def dec(value): # "7.16E+4" is 71600; inf is refused
d = Decimal(str(value)).normalize()
if not d.is_finite():
raise ValueError(f"not a finite number: {value}")
return d
def limit_body(info, side, amount, price, operator_id, client_id=None):
if not (type(operator_id) is int and (client_id is None or client_id) and info.get("status") == "trading"):
raise ValueError("operatorId not an integer, empty clientOrderId or market not trading")
try:
a, p = dec(amount), dec(price)
off_tick = p <= 0 or bool(p % dec(info["tickSize"]))
except ArithmeticError: # "abc", or "1e400" % 1 overflowing
raise ValueError(f"unusable number: {amount}, {price}") from None
size_ok = dec(info["minOrderInBaseAsset"]) <= a <= dec(info["maxOrderInBaseAsset"])
if off_tick or not size_ok or -a.as_tuple().exponent > info["quantityDecimals"]:
raise ValueError(f"{amount} at {price} breaks the tick size, size limits or decimals")
body = {"market": info["market"], "side": side, "orderType": "limit",
"amount": format(a, "f"), "price": format(p, "f"), "operatorId": operator_id}
if client_id:
body["clientOrderId"] = client_id
return body
class Bitvavo:
def __init__(self, key="", secret="", window=10000, reserve=50, http=None):
self.key, self.secret, self.window, self.reserve = key, secret, window, reserve
self.http, self.remaining, self.reset_ms = http or requests.Session(), None, 0
def call(self, method, path, params=None, body=None, private=False):
if private and not (self.key and self.secret):
raise BitvavoError(None, 300, "no key configured, nothing sent")
wait = self.reset_ms / 1000 - time.time()
if wait > 0 and self.remaining is not None and self.remaining <= self.reserve:
raise BitvavoError(None, 105, f"local pause, {wait:.0f} s to reset")
query = urlencode(params or {})
url = "/v2" + path + ("?" + query if query else "")
data = json.dumps(body, separators=(",", ":")) if body else ""
headers = {"Content-Type": "application/json"}
if private:
ts = int(time.time() * 1000)
headers.update({"Bitvavo-Access-Key": self.key, "Bitvavo-Access-Timestamp": str(ts),
"Bitvavo-Access-Signature": sign(self.secret, ts, method, url, data),
"Bitvavo-Access-Window": str(self.window)})
r = self.http.request(method, HOST + url, data=data or None, headers=headers, timeout=10)
h = r.headers
cached = h.get("cf-cache-status") == "HIT" or "Age" in h
if "bitvavo-ratelimit-remaining" in h and not cached:
self.remaining = int(h["bitvavo-ratelimit-remaining"])
self.reset_ms = int(h.get("bitvavo-ratelimit-resetat", 0))
if r.status_code == 429: # blocked up to 2 min with a key, 15 by IP
self.remaining = 0
self.reset_ms = max(self.reset_ms, (time.time() + (120 if private else 900)) * 1000)
try:
payload = r.json()
except ValueError: # e.g. an HTML proxy page
raise BitvavoError(r.status_code, None, r.text[:60]) from None
if r.status_code >= 400:
err = payload if isinstance(payload, dict) else {}
raise BitvavoError(r.status_code, err.get("errorCode"), err.get("error", ""))
return payloadThe demonstration:
from bitvavo_client import *
doc_body = '{"market":"BTC-EUR","side":"buy","price":"5000","amount":"1.23","orderType":"limit"}'
print(sign("bitvavo", 1548172481125, "POST", "/v2/order", doc_body)[:16]) # docs: 44d022723a20973a
api = Bitvavo() # public endpoints need no key
print("server time", api.call("GET", "/time")["time"], "remaining", api.remaining)
info = api.call("GET", "/markets", {"market": "BTC-EUR"})
print(info["tickSize"], info["quantityDecimals"], info["minOrderInBaseAsset"], info["maxOpenOrders"])
for price, amount in [(float("1e400"), "0.001"), ("60000", "1e-400"), ("60000.5", "0.001"), ("6E+4", "0.00100")]:
try:
print("ok", limit_body(info, "buy", amount, price, operator_id=2001))
except ValueError as err:
print("refused:", err)
class Fake: # synthetic error response, no network
def __init__(self, status, code):
self.status_code, self.headers = status, {}
self.text = json.dumps({"errorCode": code, "error": "synthetic"})
def json(self):
return json.loads(self.text)
for status, code in [(400, 429), (429, 105)]:
api = Bitvavo("k" * 64, "secret", http=type("H", (), {"request": lambda *a, **k: Fake(status, code)})())
for attempt in range(2):
try:
api.call("GET", "/balance", private=True)
except BitvavoError as err:
print(attempt, err)Output on 6 November 2026:
44d022723a20973a
server time 1793921637418 remaining 999
1.00 8 0.00007294 400
refused: not a finite number: inf
refused: 1e-400 at 60000 breaks the tick size, size limits or decimals
refused: 0.001 at 60000.5 breaks the tick size, size limits or decimals
ok {'market': 'BTC-EUR', 'side': 'buy', 'orderType': 'limit', 'amount': '0.001', 'price': '60000', 'operatorId': 2001}
0 HTTP 400, errorCode 429: synthetic
1 HTTP 400, errorCode 429: synthetic
0 HTTP 429, errorCode 105: synthetic
1 HTTP None, errorCode 105: local pause, 120 s to resetDecimal also flattens exponent notation, which Bitvavo sends: that day the all-markets 24-hour ticker had 16 such fields in 7 of 437 markets, such as a BTC-EUR high of 7.184E+4. Separate tests passed against a local mock that checks signatures over the raw body and query (a wrong secret got 309), and for an amount of "1e400", an empty clientOrderId, True or "2001" as operatorId, HTML error pages and error bodies without fields.
Bitvavo WebSocket API and the local order book
Connect to wss://ws.bitvavo.com/v2/, subscribe to book, take a getBook snapshot and apply updates only while each nonce is exactly one higher than the last.
{"action": "subscribe", "channels": [{"name": "book", "markets": ["BTC-EUR"]}]}
{"action": "getBook", "market": "BTC-EUR", "depth": 1000, "requestId": 7}Key price levels by Decimal, not by string. On 6 November 2026 the snapshot wrote "67508" and updates "67508.00"; in a 25-second run with 246 updates after a snapshot of 1,000 levels a side, a string-keyed book ended with 2,155 levels against 2,018 for a Decimal-keyed one, and its best bid equaled its best ask.
- The nonce resets when the matching engine restarts (FAQ): resync on any gap.
- Ticker events carried only the side that changed, such as
bestAskalone. - Order and fill events have no ordering guarantee.
- A session may send 5,000 messages a second before errorCode 112, and all connections share one weight budget.
See WebSocket heartbeat and reconnect and maintaining a local order book.
Official Bitvavo Python SDK: what to check first
The official python-bitvavo-api (ISC license) covers the core calls but has not followed the API since mid-2025: PyPI's newest version is 1.4.3, uploaded 14 May 2025, and the last push was 4 August 2025.
- No methods for cancel on disconnect, atomic cancel or the May 2026
/crypto/withdrawal. cancelOrderlets you omitoperatorId, which the API has rejected since 30 June 2025.- It parses the errorCode 105 reset time from the message text and trusts headers on cached responses.
Test against a mock first, as in paper trading APIs. For integration work in general, see Fincode's exchange API integration page; naming Bitvavo here does not mean Fincode integrates it or has built on it.
Frequently asked questions
Does Bitvavo have a sandbox or testnet?
None was found in the official docs or specs on 6 November 2026; the specs list production servers only.
What is operatorId in the Bitvavo API?
An integer you choose for the trader or algorithm behind an order. Since 30 June 2025 the API rejects create, update and cancel requests without it.
How do I see my remaining Bitvavo API weight?
Read the bitvavo-ratelimit-remaining header on uncached REST responses; WebSocket responses do not carry it.
Is errorCode 429 a Bitvavo rate limit?
No. It comes with HTTP 400 and means too many decimal places. The rate limit is HTTP 429 with errorCode 105.
Sources
- Bitvavo API Docs: Exchange REST API introduction, authentication and permissions (opens in a new tab)
- Bitvavo API Docs: Rate limits (opens in a new tab)
- Bitvavo API Docs: Handle errors (opens in a new tab)
- Bitvavo API Docs: Get started (API key creation and permissions) (opens in a new tab)
- Bitvavo API Docs: Exchange WebSocket API introduction (opens in a new tab)
- Bitvavo API Docs: Manage local order book (opens in a new tab)
- Bitvavo API Docs: Frequently asked questions (opens in a new tab)
- Bitvavo API Docs: Releases changelog (opens in a new tab)
- Bitvavo Exchange REST API OpenAPI specification (weights, servers) (opens in a new tab)
- Bitvavo on GitHub: python-bitvavo-api, the official Python SDK (opens in a new tab)
- Bitvavo public endpoint: market data for BTC-EUR (live) (opens in a new tab)
- ESMA: Markets in Crypto-Assets Regulation and the interim MiCA register (updated 5 November 2026) (opens in a new tab)
- ESMA interim MiCA register: authorised crypto-asset service providers (CSV) (opens in a new tab)
- Capital Markets Law No. 6362, Article 99/A (Turkish, consolidated text) (opens in a new tab)
This guide is general information about software and markets, not investment, legal or tax advice. Automated trading can lose money, past or backtested performance does not guarantee future results, and the rules that apply depend on your jurisdiction and your exchange's terms. Sources, checks and corrections: see the editorial policy.