Bybit V5 API Authentication in 2026: Signing Requests, Permission Tiers, and Common Error Codes
Bybit's V5 docs explain the signature spec but skip the half-day of error-code triage every dev does first week. The signing recipe, permission matrix, and a 10001/10003/10004 cheat sheet.
Context above, deep read below. Use the TOC to move section by section without losing the thread.
Last sprint I was wiring up a funding-rate monitor for a basis-trade strategy, and Bybit V5 was the only exchange in the basket that ate my first six requests with retCode: 10004. The key was correct, the API secret was correct, the endpoint was the one the docs pointed me at, and the response still said the signature was wrong. The official V5 docs cover the spec — what bytes go into the HMAC, what header names to use — but they skip the half-day of error-code triage every new integrator does in their first week. This piece is the connector I wish had existed when I started.
If you came in from a Google search like "bybit api docs" or "bybit api documentation," you are almost certainly looking for this layer rather than another tour of the Bybit API endpoint catalog. We will build the V5 signature in Python step by step, walk through what each API-key permission actually unlocks, and end with an error-code map you can keep open in a second tab.
What V5 changed and why your V3 code stopped working
Bybit launched the V5 unified API in early 2023 and spent the following year deprecating the V3 surface across Spot, Linear, Inverse, and Option. By 2026 V3 is effectively gone for new accounts, and the lift-and-shift code that used to work has two specific places where it now silently misbehaves.
The first is the prehash construction. V3 took your query parameters, sorted them alphabetically, and concatenated them into the string you signed. V5 does not sort anything. The prehash is:
timestamp + api_key + recv_window + (queryString | rawBody)
For a GET request, the fourth slot is the query string in the exact order the params appear in the URL. For a POST request, it is the raw JSON body byte-for-byte as sent on the wire. If your HTTP client reorders JSON keys between the moment you sign and the moment you send, the server will see a different body and reject the signature.
The second is the header set. V3 accepted parameters in either the URL or a body field (api_key, timestamp, sign). V5 moves all auth metadata to headers — X-BAPI-API-KEY, X-BAPI-TIMESTAMP, X-BAPI-RECV-WINDOW, X-BAPI-SIGN, and X-BAPI-SIGN-TYPE: 2 for HMAC SHA256. If anything in your old wrapper still tries to put api_key in the query string, V5 will either ignore it or reject the request, depending on the endpoint.
| Field | V3 | V5 |
|---|---|---|
| Prehash assembly | Alphabetically sorted params | timestamp + api_key + recv_window + payload in order |
| POST body in signature | Sorted JSON | Raw JSON body, byte-exact |
| Auth transport | URL/body | Headers only |
| Signature header | sign query/body field |
X-BAPI-SIGN header |
| Sign type indicator | Implicit | X-BAPI-SIGN-TYPE: 2 (HMAC) or 1 (RSA) |
The cleanest way to migrate is to throw away your V3 signing helper and write the V5 one from scratch. Twenty lines of Python beat an afternoon of patching a thousand-line wrapper that was built around alphabetical sorting.
Building the V5 signature in Python, five steps
Here is a complete signing function that handles both GET and POST. No SDK, just requests and hmac.
import hmac
import hashlib
import json
import time
import requests
API_KEY = "YOUR_KEY"
API_SECRET = "YOUR_SECRET"
BASE_URL = "https://api.bybit.com"
RECV_WINDOW = "5000"
def sign(payload: str, timestamp: str) -> str:
prehash = timestamp + API_KEY + RECV_WINDOW + payload
return hmac.new(
API_SECRET.encode("utf-8"),
prehash.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def signed_get(path: str, params: dict) -> dict:
# Param order in the URL must match the order in the prehash.
query_string = "&".join(f"{k}={v}" for k, v in params.items())
timestamp = str(int(time.time() * 1000))
signature = sign(query_string, timestamp)
headers = {
"X-BAPI-API-KEY": API_KEY,
"X-BAPI-TIMESTAMP": timestamp,
"X-BAPI-RECV-WINDOW": RECV_WINDOW,
"X-BAPI-SIGN": signature,
"X-BAPI-SIGN-TYPE": "2",
}
url = f"{BASE_URL}{path}?{query_string}"
return requests.get(url, headers=headers).json()
def signed_post(path: str, body: dict) -> dict:
# Critical: serialize once, sign that exact string, send that exact string.
body_str = json.dumps(body, separators=(",", ":"))
timestamp = str(int(time.time() * 1000))
signature = sign(body_str, timestamp)
headers = {
"X-BAPI-API-KEY": API_KEY,
"X-BAPI-TIMESTAMP": timestamp,
"X-BAPI-RECV-WINDOW": RECV_WINDOW,
"X-BAPI-SIGN": signature,
"X-BAPI-SIGN-TYPE": "2",
"Content-Type": "application/json",
}
return requests.post(f"{BASE_URL}{path}", headers=headers, data=body_str).json()
The five steps the code does, in order:
- Mint a millisecond timestamp. Bybit expects Unix epoch milliseconds, not seconds.
int(time.time() * 1000)is the correct value;int(time.time())will trip 10002 immediately. - Assemble the payload. For GET, the payload is the URL query string. For POST, the payload is the JSON body as a single compact string. Note
separators=(",", ":")— the defaultjson.dumpsinserts spaces after delimiters, and those spaces are part of the bytes you sign. - Build the prehash.
timestamp + api_key + recv_window + payload. No separators between parts. No alphabetical sorting. - HMAC-SHA256 it with your secret. Hex digest, not base64. Bybit's V5 spec is explicit on hex.
- Send the headers plus the exact same payload bytes. For POST this means passing
data=body_str(notjson=body) torequests, because the latter re-serializes and may produce different bytes.
The single most common reason this code fails on the first paste is the POST gotcha at step 5. requests.post(..., json=body) will pretty-print the JSON with spaces, and now your signed bytes and your sent bytes do not match. Always sign the exact body_str you put on the wire.
API key permissions, in practice
Bybit's API Management page presents a tree of permission toggles that does not map cleanly onto endpoint groups. Here is what each permission actually unlocks, and which combinations real bots usually need.
| Permission group | What it unlocks | Typical bot that needs it |
|---|---|---|
| Read-Only | All GET endpoints on market data, account state, positions, orders, executions |
Read-only dashboard, funding-rate monitor |
| Unified Trading → Spot | Spot order placement, cancel, amend; spot leverage trading | Spot grid bot, DCA bot |
| Unified Trading → Derivatives | Linear and Inverse perpetual orders, leverage, margin mode | Perp market-maker, basis trade |
| Unified Trading → Options | Options orders, multi-leg combos | Options vol seller |
| Asset Transfer | Internal wallet transfers, master/sub UID transfers, convert (small-balance swap) | Treasury rebalancer, sub-account router |
| Earn | Subscribe/redeem flexible and fixed Earn products | Idle-cash sweep bot |
| Affiliate | Affiliate-only reporting endpoints | Affiliate dashboard |
| Copy Trading | Lead-trader actions and follower management | Copy-trade orchestration |
| Withdraw | Withdrawal to whitelisted addresses | Auto-withdrawal scripts (handle with care) |
Two practical notes that the toggle UI does not surface clearly.
First, Read-Only is not a superset of the trading permissions for endpoints that need both read and write context. Place-order endpoints under Unified Trading → Derivatives will read your positions implicitly to apply margin checks, but you cannot use a Read-Only key to also place orders — the two are separate grants. If you build a bot that reads positions and places orders, you need Read-Only plus the relevant trading permission.
Second, Withdraw is the only permission Bybit forces you to bind to an IP allowlist, and a withdraw key can only send to addresses already whitelisted in your Bybit account settings. If you forget the IP binding when creating the key, the toggle is greyed out. Plan the IP up front rather than recreating the key later.
The error-code map you actually need
The V5 error code list runs into the thousands once you include endpoint-specific codes (instrument not found, position size exceeds limit, and so on). For the auth layer specifically, the codes in the 100xx band are what you will see during your first week. Here is the field-tested triage table:
| Code | Message | First thing to check |
|---|---|---|
| 10001 | Parameter error | A required field is missing or has a wrong type. Print the params you sent and diff against the endpoint's request schema. |
| 10002 | Invalid request, please check your timestamp / recv_window | Server time minus your timestamp is greater than recv_window. Sync NTP, or bump X-BAPI-RECV-WINDOW to 10000+. |
| 10003 | API key is invalid | The key string itself is wrong, or it was deleted in the UI, or you are hitting mainnet with a testnet key (or vice versa). |
| 10004 | Error sign | The signature does not match. Re-read the V5 prehash section above; the POST body case is the most common landmine. |
| 10005 | Permissions denied for current API key | The endpoint needs a permission your key does not have. Re-check the permission matrix and re-issue the key if needed. |
| 10006 | Too many visits | Rate limit hit. Back off, and check whether you should be using WebSocket for this data instead of REST. |
| 10010 | Unmatched IP, please check your API key's IP allowlist | The IP your request came from is not on the key's allowlist. Common when bots get redeployed across data centers. |
| 10016 | Server error | Bybit-side. Retry with exponential backoff; do not assume your code is at fault. |
| 10018 | Exceeded IP rate limit | Different from 10006 — this is the per-IP cap rather than the per-UID cap. Spread across IPs or slow down. |
A simple debugging order when any 100xx fires:
- Curl the same endpoint with the exact same payload bytes from your laptop (not your VPS). If it works locally and fails on the VPS, it is 10010 or a regional timestamp drift problem.
- Print the prehash string and signature in your code. If
retCodeis 10004 but the prehash looks correct, your POST body bytes on the wire are different from what you signed. - Hit
GET /v5/market/time(no auth required) from your bot host and diff against your local clock. Anything past ~1 second of drift will eventually cause 10002 during burst periods.
recv_window and the cross-region timestamp problem
The recv_window check is the one place where geographically distributed bots quietly fail. The default 5000ms feels generous until your VPS is in São Paulo and the Bybit endpoint you reached resolves to a Tokyo node, and your round-trip occasionally spikes past 4 seconds during a US-session funding flip.
Two production patterns work:
- Wide window, low-trust path. Set
X-BAPI-RECV-WINDOWto 15000-20000 and accept the slightly worse replay-attack story. Reasonable for read-only or low-stakes order flow. - NTP-disciplined, tight window. Run
chronyorsystemd-timesyncdagainst a low-latency NTP pool, hold the window at 5000, and accept that you will occasionally see 10002 during network incidents.
The wrong pattern is to leave the SDK at its default 5000 and hope. Bybit will reject the request silently — from the user's perspective, the bot just stops placing orders during the most volatile minutes of the day.
If you want a single line of paranoid defense, add a "minute-mark" sync that calls GET /v5/market/time once a minute and adjusts an offset variable applied to every outbound timestamp. It is what every production-grade Bybit wrapper does, and it costs one request per minute against a generous per-minute rate limit.
Testnet, Mainnet, and Demo Trading are three different environments
Worth nailing down because every team gets this wrong once.
- Mainnet —
https://api.bybit.com. Real money, real order book. - Testnet —
https://api-testnet.bybit.com. Separate accounts, separate API keys, a faucet for test funds, and an order book that does not look like the real one. Use for signature debugging and end-to-end integration tests. Do not use for strategy validation. - Demo Trading —
https://api-demo.bybit.com. Created from a mainnet account but trades against real market data with virtual balances. Use for strategy validation when you want the real depth, real funding cycle, and real volatility.
A common mistake during onboarding is to register on testnet, get familiar, and then forget that none of your testnet API keys work on mainnet. They are different user databases. Create a fresh key on mainnet when you are ready to go live.
What to do after this article
Open the Bybit API directory entry for the endpoint catalog, then paste the Python signing function above into your project and verify GET /v5/account/wallet-balance?accountType=UNIFIED returns your real balance. If that works, the rest of the V5 surface is a matter of learning each endpoint's parameter schema — the auth layer is the same shape everywhere. If you are still seeing 10004 after the function works for wallet-balance, the next failure is almost always the POST body serialization gotcha; check there before anything else.
Jump to a section
Pass this article along
Send it to your preferred platform or copy the link.
Before you move on
Next step
Finished reading? Continue comparing tools in the directory.
Browse tools