Articles/Bybit V5 API Authentication in 2026: Signing Requests, Permission Tiers, and Common Error Codes
Tool Reviews

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.

May 25, 2026Read time: 11 min0 topic signals
Reading runway

Context above, deep read below. Use the TOC to move section by section without losing the thread.

Tool Reviews7 sections

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:

  1. 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.
  2. 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 default json.dumps inserts spaces after delimiters, and those spaces are part of the bytes you sign.
  3. Build the prehash. timestamp + api_key + recv_window + payload. No separators between parts. No alphabetical sorting.
  4. HMAC-SHA256 it with your secret. Hex digest, not base64. Bybit's V5 spec is explicit on hex.
  5. Send the headers plus the exact same payload bytes. For POST this means passing data=body_str (not json=body) to requests, 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:

  1. 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.
  2. Print the prehash string and signature in your code. If retCode is 10004 but the prehash looks correct, your POST body bytes on the wire are different from what you signed.
  3. 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-WINDOW to 15000-20000 and accept the slightly worse replay-attack story. Reasonable for read-only or low-stakes order flow.
  • NTP-disciplined, tight window. Run chrony or systemd-timesyncd against 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.

  • Mainnethttps://api.bybit.com. Real money, real order book.
  • Testnethttps://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 Tradinghttps://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.

Share this article

Article overview

Before you move on

Category
Tool Reviews
Read time
11 min
Mentioned tools
0
Back to all articles →

Next step

Finished reading? Continue comparing tools in the directory.

Browse tools