When to Use
Use this skill when a trading system talks to the Bybit V5 REST API without an SDK — because it needs control over threading, connection reuse, timeout handling and backoff that a synchronous SDK does not expose. It covers the four things a hand-rolled Bybit client gets wrong:
- Signing a string that is not the string actually transmitted (
retCode 10004). - A timestamp outside the acceptance window (
retCode 10002). - Pacing against one rate limit while breaching the other (
retCode 10006vs. HTTP 403). - Retrying an order submission after a timeout and opening a second position.
When NOT to Use
- When
pybitis adequate. Bybit's official Python SDK signs correctly, retries and handles WebSocket sessions. Hand-rolling is justified by a specific control requirement, not by default. This skill's signing logic deliberately produces the same canonical form aspybitso the two can be cross-checked. - For WebSocket authentication. The WS handshake signs
GET/realtimeplus an expiry, nottimestamp + api_key + recv_window + payload. Different scheme; do not reuse this one. Seewebsocket-reconnection-with-state-recovery. - For RSA-signed keys. Self-generated (RSA) API keys sign with RSA-SHA256 and encode the signature as base64, not lowercase hex. This module implements the HMAC path only.
- As the whole rate-limit story.
RateLimitSnapshotreads the per-UID, per-endpoint headers. The per-IP limit is invisible to them and needs a process-wide budget shared by every strategy on the same egress IP — seemulti-broker-rate-limit-handling. - From a restricted jurisdiction. Bybit returns HTTP 403 to requests from IP addresses in the US or Mainland China regardless of how correctly they are signed.
Prerequisites
- A Bybit API key with derivatives trading permission, IP-whitelisted. A key bound to a
different IP fails with
retCode 10010. - The key must match the environment it is used against. Mainnet, testnet, mainnet-demo
and testnet-demo are four separate key domains, and a mismatch fails with
retCode 10003— an error easy to misread as a bad secret. - A clock kept NTP-synchronised. Bybit's documented recommendation is local device time
under NTP;
sync_with_server_time()is a fallback for hosts that cannot run a daemon. - Python 3.10+. Standard library only —
hmac,hashlib,json,uuid. No SDK, and no HTTP client is bundled: the module signs, the caller sends.
Workflow
-
Build the config, and keep the secret out of your logs.
BybitConfigvalidates the credentials andrecv_windowup front and masksapi_secretin itsrepr. A plain dataclass prints its secret into every traceback and debug log line that touches it. -
Render the payload, then sign that exact rendering.
sign_request()returnsurl,headers,bodyand thepayloadthat was signed. For GET the query string is already appended tourl; for POST the signed JSON string isbody. Send those verbatim. Handing the original dict to a client library that re-serialises the JSON or re-encodes the query changes the transmitted bytes and yieldsretCode 10004, with a signature that looks perfectly well-formed.Alphabetical key ordering is not a Bybit requirement — the V5 documentation states no ordering rule. This module sorts keys to match
pybit's canonical form. What matters is only that the signed string equals the sent string. -
Reject parameters that cannot survive the wire.
Noneis dropped rather than stringified to"None"; booleans render lowercase; a value containing a space,&,+,#, a malformed%escape or non-ASCII raises, because an HTTP client rewrites those after signing.=and well-formed%XXescapes are allowed through unchanged — Bybit'snextPageCursorneeds them, and no client rewrites them. Bybit's own SDK does not percent-encode, so failing loudly beats emitting a signature that cannot match. -
Pass quantities and prices as decimal strings.
qty,price,triggerPrice,takeProfitandstopLossare rejected unless they are strings. Letting JSON serialise a float means0.1 + 0.2is transmitted as0.30000000000000004. -
Keep the timestamp inside the window — and know which direction is forgiving. Bybit accepts
server_time - recv_window <= ts < server_time + 1000. A clock that is behind is covered byrecv_window; a clock that is ahead gets exactly 1000 ms no matter how largerecv_windowis. Raisingrecv_windowto fix a fast clock does nothing except widen your replay window.is_within_acceptance_window()asserts the rule against a realGET /v5/market/timeresponse as a pre-flight check. -
Pace against both limits, separately. Read
RateLimitSnapshot.from_headers(response.headers)after each call and back off whenshould_throttle()is true. The test is relative toX-Bapi-Limiton purpose: several order endpoints have a limit of exactly 10/s, so an absolute "back off below 10 remaining" rule fires on every healthy request. Separately, hold a process-wide budget under the per-IP ceiling of 600 requests per 5 seconds — the headers say nothing about it. -
Attach an
orderLinkIdto every order, before you need it. Mint it withnew_order_link_id("strat-")and store it with the order intent before dispatch. When a submission times out, retry with the same id: Bybit rejects the duplicate withretCode 110072instead of opening a second position. An id generated at retry time provides no protection at all. -
Classify the failure before retrying.
10002(timestamp),10004(signature) and10010(IP) are deterministic — retrying them just burns rate limit.10006and HTTP 403 need backoff, and the 403 IP ban needs all HTTP sessions terminated for at least 10 minutes. Only a network-level timeout on a POST is genuinely ambiguous, and that is the caseorderLinkIdexists for.
Full procedure: see
references/workflows.md. Endpoints, limits, headers and error codes: seereferences/standards.md. Printable pre-flight checklist: seeassets/checklist.md.
Common Pitfalls
- Signing one string and sending another. The single most common V5 failure. It
happens when the query is rebuilt from a dict by
requests, when the JSON is re-serialised with default separators, or when a value gets percent-encoded after signing.retCode 10004says "error sign", which sends people hunting for a bad secret. - Assuming query parameters must be sorted. They need not be. Sorting for signing
while sending the dict's insertion order is precisely how a correct-looking
implementation produces
10004on every request. - Widening
recv_windowto cure clock drift. It only helps a slow clock. A clock 3 seconds fast is rejected under a 20-secondrecv_windowexactly as it is under 5 seconds, because the forward tolerance is a fixed 1000 ms. Fix NTP. - Retrying an order because the HTTP request timed out. Bybit may have accepted it
before the response was lost. Without a reused
orderLinkId, the retry is a second order — in a fast market, at a materially different price. - Backing off on the wrong limit.
X-Bapi-Limit-Statustracks the per-UID, per-endpoint budget only. A fleet of strategies sharing one IP can each stay well inside their endpoint budgets and still trip the 600-per-5-second IP ceiling, whose penalty is a ~10-minute ban on every session from that address, mid-position. - Reading
X-Bapi-Limit-Reset-Timestampas a reset time. Bybit documents that it is only a reset instant once the limit has been exceeded; otherwise it is just the current server timestamp. Sleeping until it unconditionally is a no-op that reads like a control. - Attaching a JSON body to a GET. Bybit returns HTTP 403 for a GET carrying an empty JSON body. Send the query string in the URL and no body.
- Logging the config object.
BybitConfigmasks the secret; a dict, adataclasses.asdict()call or a custom subclass will not. Never put the raw secret in a log record or an exception message. - Pointing testnet keys at mainnet.
retCode 10003is a key/domain mismatch, not an invalid key. Verify the base URL before assuming the credentials are wrong.
Verification
- Confirm the signature equals an independently derived HMAC over
timestamp + api_key + recv_window + payload— not merely that it is 64 hex characters, which is true of any SHA-256 digest of anything. - Confirm the GET
urlends with exactly the signedquery_string, and the POSTbodyis byte-identical to the signedpayload. - Confirm
{"cursor": None}yields nocursorkey rather thancursor=None, that a value containing a space is rejected, and that a realnextPageCursorwith=and%XXescapes passes through byte-for-byte. - Confirm
qty=0.1raises andqty="0.1"is accepted. - Confirm the acceptance window is inclusive at
server_time - recv_window, exclusive atserver_time + 1000, and that the forward bound does not move whenrecv_windowgrows. - Confirm
RateLimitSnapshot(limit=10, remaining=9).should_throttle()isFalse— the regression that an absolute "below 10" threshold introduces. - Confirm
repr()of aBybitConfig, including inside a container, does not contain the secret. - Run
python -m unittest discover -s skills/bybit-derivatives-api-integration/scriptsand confirm a 100% pass rate. - Against testnet only: sign a
GET /v5/order/realtime, send it unmodified, and confirmretCode 0. A signing bug that unit tests cannot see is one where the caller's transport mutates the request.