Bybit API error 10004: fixing an invalid signature
Figures on this page are as of 2026-09. Fees, limits and margin tiers change — check the venue's own docs.
Bybit V5 returns retCode: 10004 with the message “Error sign, please check
your signature generation algorithm.” Bybit rebuilt the pre-hash string from the
request it received, signed it, and got a different X-BAPI-SIGN from the one
you sent. The fix: sign exactly
timestamp + api_key + recv_window + queryString (GET) or
+ jsonBodyString (POST), encode HMAC output as lowercase hex, and make sure
the string you signed is byte-for-byte the string you sent.
This page is for developers writing or debugging a Bybit signer — hand-rolled, ported from another exchange, or wrapped around an HTTP library. It covers the documented rule, the failure that accounts for most cases, and a way to prove which side is wrong in a few minutes.
What this error actually means
A signed request carries the ingredients of its own signature: the key in
X-BAPI-API-KEY, the timestamp in X-BAPI-TIMESTAMP, the window in
X-BAPI-RECV-WINDOW, and the query string or body. Bybit concatenates those in
a fixed order, computes the signature with its side of the key, and compares.
The documented construction:
| Request | Pre-hash string |
|---|---|
| GET | timestamp + api_key + recv_window + queryString |
| POST | timestamp + api_key + recv_window + jsonBodyString |
No separators, no HTTP method, no request path. The encoding depends on the key type:
| Key type | How it is created | Algorithm | Output encoding |
|---|---|---|---|
| System-generated | Bybit issues the key pair | HMAC_SHA256 | Lowercase hex |
| Self-generated | You create an RSA pair and give Bybit only the public key | RSA_SHA256 | Base64 |
For self-generated keys Bybit says it “will never hold your private key”; you sign with the private key and Bybit verifies with the public one. The general mechanism is explained in what HMAC request signing is.
Bybit does not verify what you meant to send. It verifies what arrived. One
differing byte anywhere in the pre-hash produces 10004.
The usual cause: the signed string is not the sent string
Bybit’s docs define the rule; they do not rank causes. What follows is our
inference from how the rule works and from where signing code typically breaks.
In most 10004 cases the construction is right in principle and the inputs
differ in practice.
GET: the query string is built twice. You build a query string to sign,
then pass a parameter dict to your HTTP library, which builds its own. The
order of parameters may differ, or the encoding does — spaces as + versus
%20, commas escaped in one and not the other. Sign the exact string you
append after ?.
POST: the JSON body is serialised twice. You call json.dumps(body) to
sign, then pass json=body to the library, which serialises again with
different spacing or key order. Serialise once, sign that string, and send that
same string as the raw body.
Header values differ from signed values. A timestamp generated once for the
pre-hash and again for the header; a recv_window in the pre-hash but a
different value (or none) in the header. Our advice: always send the header with
the exact value you signed, rather than relying on a default on one side and an
explicit value on the other.
A signer that cannot drift, in Python:
import hashlib, hmac, json, time, urllib.parse, requests
BASE = "https://api-testnet.bybit.com"
RECV = "5000"
def _headers(key, secret, ts, payload):
pre_hash = ts + key + RECV + payload
sign = hmac.new(secret.encode(), pre_hash.encode(),
hashlib.sha256).hexdigest() # lowercase hex
return {"X-BAPI-API-KEY": key, "X-BAPI-TIMESTAMP": ts,
"X-BAPI-RECV-WINDOW": RECV, "X-BAPI-SIGN": sign,
"Content-Type": "application/json"}
def signed_get(path, params, key, secret):
ts = str(int(time.time() * 1000))
qs = urllib.parse.urlencode(params) # built ONCE
return requests.get(f"{BASE}{path}?{qs}",
headers=_headers(key, secret, ts, qs))
def signed_post(path, body, key, secret):
ts = str(int(time.time() * 1000))
payload = json.dumps(body, separators=(",", ":")) # serialised ONCE
return requests.post(BASE + path, data=payload,
headers=_headers(key, secret, ts, payload))
The point is structural: the same variable feeds the signature and the wire, so the two cannot disagree.
Other causes, in order of likelihood
1. A construction ported from another exchange. Binance signs the query
string concatenated with the body; OKX signs
timestamp + method + requestPath + body. A signer adapted from either
produces a well-formed signature Bybit rejects. Rebuild from Bybit’s rule
instead of adjusting — the differences are compared in
API signature methods compared.
2. The wrong secret. A testnet secret with a mainnet key, a secret from a rotated key, or a trailing newline read from an environment file. The key authenticates as an identifier; the secret silently produces the wrong hash.
3. The wrong encoding. Uppercase hex, Base64 for an HMAC key, or hex for an RSA key. The docs specify lowercase hex for HMAC and Base64 for RSA; produce exactly that.
4. The wrong key material for an RSA key. Signing with a private key whose public half is not the one registered on this API key — common after regenerating a pair and updating only one side.
5. Not a signature problem at all. Several codes look adjacent:
| Code | Meaning | Where to go |
|---|---|---|
HTTP 401 | Wrong key, or auth params not in the request header | Check headers are headers, not query params |
HTTP 403 | Among other things: a GET sent with an empty JSON body | Drop the body on GET |
10002 | Timestamp outside the window | Bybit API error 10002 |
10003 (REST) | Key invalid for this host’s environment | Bybit API error 10003 |
How to confirm which one you have
Log the pre-hash string and the exact bytes sent. Never the secret — the pre-hash contains nothing secret. Put the pre-hash next to the final URL query and the raw request body. If they differ, you have found it.
Swap in an official SDK for one call. Bybit lists official SDKs, including
pybit for Python. Make the same call with the same key and secret through
it:
- SDK succeeds, your code fails → your construction is wrong.
- Both fail → the credentials are wrong (secret, key pair, environment).
Bisect by request shape.
| Works | Fails | Points to |
|---|---|---|
| GET with no parameters | GET with parameters | Query-string order or encoding |
| GET | POST | Body serialisation |
| Some POSTs | POSTs with nested or float values | Serialiser formatting differs between sign and send |
| Nothing | Everything | Secret, encoding, or a ported construction |
The worked example in Bybit API key setup and request signing is useful here: reproduce its documented signature exactly before testing live.
FAQ
What does Bybit error 10004 mean?
It means the signature Bybit computed from your request did not match the
X-BAPI-SIGN you sent. Bybit signs
timestamp + api_key + recv_window + queryString for GET, or the JSON body
string for POST, with no separators. Any byte that differs between what you
signed and what arrived produces this error.
Should a Bybit signature be lowercase hex or Base64?
It depends on the key type. For system-generated keys signed with HMAC_SHA256, Bybit specifies lowercase hex. For self-generated RSA keys signed with RSA_SHA256, it specifies Base64. Using the other encoding produces a well-formed but rejected signature.
Why do my GET requests work but POST requests fail with 10004?
Usually because the JSON body is serialised twice: once for signing and again by the HTTP library when sending, with different spacing or key order. Serialise the body once, sign that exact string, and send the same string as the raw request body instead of letting the library re-encode a dictionary.
Is 10004 caused by clock drift?
A timestamp outside Bybit’s window is reported as 10002, not 10004. What can
cause 10004 is a timestamp mismatch inside your own request — one value
signed and a different one sent in X-BAPI-TIMESTAMP. Generate the timestamp
once and use the same variable for both.