Authentication
Every /v1/** endpoint is authenticated with an HMAC-SHA256 request signature. There are no sessions or bearer tokens.
Headers
| Header | Description |
|---|---|
X-Api-Key | API key |
X-Timestamp | Current time in Unix milliseconds. Rejected if more than ±30 s from server time (REQUEST_EXPIRED) |
X-Nonce | Random string of 16–64 characters; must not repeat for the same key within 5 minutes (NONCE_REPLAYED). Hex of 16 random bytes is recommended |
X-Signature | The signature (below), lowercase hex |
Content-Type | application/json when there is a body |
String to sign
Join these 6 parts with \n (line feed, 0x0A), without a trailing newline:
METHOD
PATH
CANONICAL_QUERY
TIMESTAMP
NONCE
hex(sha256(BODY))| Part | Rule |
|---|---|
METHOD | Uppercase, e.g. GET, POST |
PATH | URL path without host or query string, exactly as sent (percent-encoded), e.g. /v1/wallets/u_10001/addresses |
CANONICAL_QUERY | Sort parameters by key, then by value for repeated keys; RFC 3986-encode keys and values (only A-Z a-z 0-9 - _ . ~ stay literal, space is %20, not +); join with &. Empty string when there is no query |
TIMESTAMP | Same as X-Timestamp |
NONCE | Same as X-Nonce |
hex(sha256(BODY)) | Lowercase hex SHA-256 of the raw body bytes. Without a body (e.g. GET) it is the hash of the empty string, e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
X-Signature = hex(HMAC-SHA256(API secret, string to sign))The two most common mistakes
- Sign the exact bytes you send. Serialize the JSON to a string once, hash that string and send that same string as the body; don't sign one serialization and let your HTTP library produce another.
- Send the canonical query string itself as the URL query, so what you sign and what you send can never differ in order or encoding.
Worked example
API secret test-secret, X-Timestamp: 1762650000000, X-Nonce: 0123456789abcdef0123:
GET /v1/deposits?user_id=u%201&chain_code=TRON&status=PENDING&status=CONFIRMED¬e=a%2Bb%21Canonical query:
chain_code=TRON¬e=a%2Bb%21&status=CONFIRMED&status=PENDING&user_id=u%201String to sign:
GET
/v1/deposits
chain_code=TRON¬e=a%2Bb%21&status=CONFIRMED&status=PENDING&user_id=u%201
1762650000000
0123456789abcdef0123
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855Signature: d474bb4c8ff89a37f214d8c39c3d17a502d54b64c1822024a86480c248bcbc86
With a body (POST /v1/withdraws, same timestamp and nonce):
{"user_id":"u1","chain_code":"TRON","token_code":"USDT","to_address":"TXYZ","amount":"12.5","biz_id":"order-1"}Signature: 381b9e20fc9b2e0cd97ecb003686acd472db596757b674e56df9e038d708867c
Use these two vectors to test your implementation.
Scopes and IP allowlist
Each API key has scopes; the scope each endpoint needs is listed in the API reference:
| Scope | Allows |
|---|---|
read | All queries, resending events |
deposit | Creating deposit addresses, rescans |
withdraw | Creating withdrawals |
A missing scope returns PERMISSION_DENIED; a source IP outside the key's allowlist returns IP_NOT_ALLOWED. Use a dedicated withdraw key for withdrawals and allow only your servers' egress IPs.
Authentication errors
| reason | HTTP | Cause |
|---|---|---|
MERCHANT_AUTH_FAILED | 401 | Missing signature headers, unknown or disabled key, or disabled merchant |
SIGNATURE_INVALID | 401 | Wrong signature — compare your string to sign with the example above, part by part |
REQUEST_EXPIRED | 401 | Timestamp missing, not in milliseconds, or more than 30 s off (check NTP) |
NONCE_REPLAYED | 401 | Nonce reused, or not 16–64 characters long |
IP_NOT_ALLOWED | 403 | Source IP not in the allowlist |
PERMISSION_DENIED | 403 | The key lacks the scope this endpoint requires |