---
url: /en/guide/authentication.md
---
# 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**:

```text
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` |

```text
X-Signature = hex(HMAC-SHA256(API secret, string to sign))
```

::: warning The two most common mistakes

1. **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.
2. **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`:

```http
GET /v1/deposits?user_id=u%201&chain_code=TRON&status=PENDING&status=CONFIRMED&note=a%2Bb%21
```

Canonical query:

```text
chain_code=TRON&note=a%2Bb%21&status=CONFIRMED&status=PENDING&user_id=u%201
```

String to sign:

```text
GET
/v1/deposits
chain_code=TRON&note=a%2Bb%21&status=CONFIRMED&status=PENDING&user_id=u%201
1762650000000
0123456789abcdef0123
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```

Signature: `d474bb4c8ff89a37f214d8c39c3d17a502d54b64c1822024a86480c248bcbc86`

With a body (`POST /v1/withdraws`, same timestamp and nonce):

```json
{"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](/en/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 |
