Binance API Timestamp Drift: What It Is and How to Fix It
Timestamp drift silently kills trading bots on Binance. This guide explains what causes it, how to diagnose the -1021 error, and gives you practical code fixes.
Timestamp drift silently kills trading bots on Binance. This guide explains what causes it, how to diagnose the -1021 error, and gives you practical code fixes.
Your Binance bot was working fine yesterday. Today it is throwing a wall of -1021 errors and refusing to place a single order. Nothing changed in the code. The culprit is almost certainly timestamp drift — a deceptively simple problem that takes down more trading bots than any logic bug. Every authenticated request to Binance must carry a timestamp that matches the server clock within a tight window. When your local clock drifts, even by a few seconds, every signed request gets rejected before it is even processed. Here is everything you need to understand and fix it.
Timestamp drift is the gap between your local system clock and Binance's server clock. Modern machines stay synchronized using Network Time Protocol (NTP), but in practice clocks do drift — especially on cloud VMs that get paused and resumed, Docker containers with misconfigured time settings, and laptops that have been sleeping. Even a few seconds of drift is enough to trigger authentication failures. Binance requires that the timestamp field in every signed request falls within recvWindow milliseconds of the server's current time. The default recvWindow is 5000ms (5 seconds). The maximum allowed is 60,000ms (60 seconds). If your clock is off by more than this margin, Binance rejects the request with error code -1021 and the message: Timestamp for this request is outside of the recvWindow. The problem compounds when you are running an automated strategy. A bot reacting to a VoiceOfChain real-time signal on a drifted clock will queue orders that all bounce — and by the time you notice, the trade window is gone.
Error -1021 (Timestamp outside recvWindow) and error -1022 (Invalid signature) are the two most common API authentication failures. Drift causes -1021. Never widen recvWindow past 10 seconds as a permanent fix — it creates a replay vulnerability window.
When Binance receives a signed request, it compares the timestamp field against its own server time. The check is: serverTime - timestamp must be less than recvWindow. Simple arithmetic, but it catches any client whose clock is behind. It also checks that timestamp is not too far in the future, catching clocks that have jumped forward. Binance exposes a public endpoint specifically for clock synchronization: GET /api/v3/time. This returns the server's current Unix timestamp in milliseconds. The correct way to use it is to measure round-trip latency, split it in half, and compute the offset between your clock and theirs. That offset gets applied to every subsequent timestamp you send.
import time
import requests
def get_binance_time_offset() -> int:
"""Returns milliseconds to add to local time to match Binance server."""
url = "https://api.binance.com/api/v3/time"
t0 = int(time.time() * 1000)
resp = requests.get(url, timeout=5)
t1 = int(time.time() * 1000)
server_time = resp.json()["serverTime"]
latency = (t1 - t0) // 2
offset = server_time - (t0 + latency)
return offset
# Call once at startup, then reuse
TIME_OFFSET = get_binance_time_offset()
print(f"Clock offset from Binance: {TIME_OFFSET}ms")
The latency correction matters. If the round-trip takes 80ms and you ignore it, your computed offset is off by 40ms. On a stable connection that is fine, but on a degraded link it adds up. Always measure before and after the request, split the difference, and apply it.
Before reaching for the fix, confirm that drift is actually the problem. A few scenarios produce -1021 that are not about the system clock at all.
To isolate the cause, print the raw timestamp your bot sends alongside what /api/v3/time returns at the same moment. If the gap exceeds 1000ms, you have drift. If the gap is under 500ms but you are still getting -1021, the issue is probably slow code between timestamp generation and request dispatch.
The production-grade approach is to compute the offset once at startup, then apply it to every timestamp you generate. This avoids depending on your system clock being perfect — instead you directly track the delta from Binance's authoritative time. Here is a complete authenticated request using this pattern.
import hmac
import hashlib
import requests
import time
API_KEY = "your_api_key_here"
SECRET_KEY = "your_secret_key_here"
BASE_URL = "https://api.binance.com"
# TIME_OFFSET computed at startup via get_binance_time_offset()
TIME_OFFSET = 0
def synced_ts() -> int:
return int(time.time() * 1000) + TIME_OFFSET
def sign(params: dict) -> str:
query = "&".join(f"{k}={v}" for k, v in params.items())
return hmac.new(
SECRET_KEY.encode("utf-8"),
query.encode("utf-8"),
hashlib.sha256
).hexdigest()
def get_open_orders(symbol: str) -> list:
endpoint = "/api/v3/openOrders"
params = {
"symbol": symbol,
"timestamp": synced_ts(),
"recvWindow": 5000
}
params["signature"] = sign(params)
headers = {"X-MBX-APIKEY": API_KEY}
resp = requests.get(
BASE_URL + endpoint,
headers=headers,
params=params,
timeout=10
)
resp.raise_for_status()
return resp.json()
orders = get_open_orders("BTCUSDT")
print(f"Open orders: {len(orders)}")
Note that sign() must be called after all other params are populated and immediately before the request fires. If anything runs between signing and sending — logging, additional computations, database writes — regenerate the timestamp rather than reusing the signed one.
Do not set recvWindow to 60000ms as a lazy fix. Widening the window reduces security by giving an attacker more time to replay a captured request. Keep it at 5000ms or lower. The right fix is a proper clock sync, not a wider window.
Timestamp validation is not unique to Binance — every major exchange enforces it. The implementation details differ, so if you are running multi-exchange strategies across platforms like Bybit and OKX, each requires its own sync approach. Bybit's V5 API uses the same millisecond timestamp pattern with a default 5-second window. OKX is stricter: it enforces a 30-second hard limit and requires the timestamp in ISO 8601 format in the OK-ACCESS-TIMESTAMP header rather than a query parameter. Bitget and KuCoin both follow the Binance convention closely — milliseconds, signed query string, similar error codes.
| Exchange | Time Endpoint | Default Window | Format | Error Code |
|---|---|---|---|---|
| Binance | /api/v3/time | 5000ms | Unix ms (param) | -1021 |
| Bybit | /v5/market/time | 5000ms | Unix ms (param) | 10002 |
| OKX | /api/v5/public/time | 30s | ISO 8601 (header) | 50113 |
| Bitget | /api/v2/public/time | 5000ms | Unix ms (param) | 40007 |
| KuCoin | /api/v1/timestamp | 5000ms | Unix ms (param) | 400100 |
The pattern of querying the exchange's time endpoint, computing the offset, and applying it to each request is consistent across all of them. If you maintain separate API clients per exchange, each should carry its own stored offset and refresh it on a schedule or on error detection.
Even with a startup sync, long-running bots can drift again over hours. VMs suspend, NTP hiccups, network latency spikes. The robust solution is to catch -1021 responses inline, resync immediately, and retry the request. This makes your bot self-healing without requiring a restart.
import time, hmac, hashlib, requests
API_KEY = "your_api_key"
SECRET_KEY = "your_secret_key"
TIME_OFFSET = 0
def get_binance_time_offset() -> int:
t0 = int(time.time() * 1000)
resp = requests.get("https://api.binance.com/api/v3/time", timeout=5)
t1 = int(time.time() * 1000)
server_time = resp.json()["serverTime"]
return server_time - (t0 + (t1 - t0) // 2)
def resync_clock():
global TIME_OFFSET
TIME_OFFSET = get_binance_time_offset()
print(f"[resync] New offset: {TIME_OFFSET}ms")
def sign(params: dict) -> str:
query = "&".join(f"{k}={v}" for k, v in params.items())
return hmac.new(
SECRET_KEY.encode("utf-8"),
query.encode("utf-8"),
hashlib.sha256
).hexdigest()
def place_market_order(symbol: str, side: str, quantity: float, retries: int = 3):
for attempt in range(retries):
params = {
"symbol": symbol,
"side": side,
"type": "MARKET",
"quantity": quantity,
"timestamp": int(time.time() * 1000) + TIME_OFFSET,
"recvWindow": 5000,
}
params["signature"] = sign(params)
resp = requests.post(
"https://api.binance.com/api/v3/order",
headers={"X-MBX-APIKEY": API_KEY},
params=params,
timeout=10,
)
data = resp.json()
if data.get("code") == -1021:
print(f"Timestamp drift on attempt {attempt + 1}, resyncing...")
resync_clock()
continue
return data
return {"error": "Max retries exceeded — check system clock and NTP"}
# Example: place order on a VoiceOfChain signal
result = place_market_order("ETHUSDT", "BUY", 0.1)
print(result)
This pattern works well in signal-driven systems. When a VoiceOfChain alert fires and your bot attempts to act on it, a -1021 response triggers an immediate resync and a single retry — keeping execution delay under 200ms on most connections. If you see repeated resyncs, that is a signal to investigate your infrastructure: restart the NTP daemon on Linux with systemctl restart systemd-timesyncd, or force a sync with ntpdate -u pool.ntp.org.
Timestamp drift is one of those problems that feels mysterious until you understand it, and trivially preventable once you do. The core fix is always the same: query the exchange's time endpoint at startup, compute your clock offset, and apply it to every authenticated request. Catch -1021 inline and resync rather than letting your bot go silent. Whether you are trading on Binance, cross-posting signals to Bybit and OKX, or running a multi-exchange arbitrage loop, clean clock synchronization is table stakes for any production bot — and now you have the code to do it right.