The /api/v1 surface is built for bots and external services: market data needs
no key, private endpoints take a key and an HMAC signature on every request.
The machine-readable description of every endpoint is the
endpoint reference, backed by the spec
public-v1.en.yaml (OpenAPI 3.0.3) — feed it to
openapi-generator and you get a client. This page covers what the spec does
not: how to sign a request, how the limits work, and where the API behaves
differently than it first appears.
Keys are not for browsers. CORS on
/api/v1is disabled deliberately and will stay that way: a secret that reaches the frontend leaks with the page — to browser extensions and to anyone the user shows DevTools to. This surface is strictly server-to-server. A web interface needs the private/api/*under a session, not a key.
1. Key and secret
Keys are issued in your account, on the API keys page.
Behind it are private-surface endpoints that work under a session (Sanctum,
cookie), not through /api/v1:
| Method and path | What it does |
|---|---|
GET /api/api-keys |
List your keys (without secrets) |
POST /api/api-keys |
Create a key; the only response that contains the secret |
PUT /api/api-keys/{id} |
Rename, adjust ip_allowlist / expires_at |
DELETE /api/api-keys/{id} |
Revoke (idempotent) |
Creating a key:
POST /api/api-keys
{
"name": "market-maker-1",
"scopes": ["read", "trade"], // read | trade | withdraw
"ip_allowlist": ["203.0.113.7"], // optional; required for withdraw
"expires_at": null, // ISO-8601 or null
"code": "123456" // TOTP: required for a trade/withdraw key
}
201 Created
{
"key": { "id": 7, "public_id": "ek_9f1c…", "scopes": ["read","trade"], "is_active": true, … },
"secret": "…",
"secret_shown_once": true
}
Things worth knowing up front:
- The
secretis shown exactly once. There is no "show the secret again" endpoint and there will not be one: otherwise a stolen account session would hand over every key ever issued. Lost it — revoke the key and issue a new one. - Scopes are immutable. A
PUTwithscopesreturns 422. Need a different set of rights — create a new key. - Issuing a
trade/withdrawkey requires a second factor (TOTP, or your password if 2FA is off). A read-only key needs no confirmation. - A
withdrawkey cannot be issued without 2FA and a non-emptyip_allowlist— see section 8. - The number of active (non-revoked) keys per account is capped by
PUBLIC_API_MAX_KEYS(10 by default), otherwise 422key_limit_reached. - Keys are tied to the application's
APP_KEY: rotating it makes every secret undecryptable and all keys have to be reissued.
2. Signing a request
Every private request carries three headers (plus one optional):
| Header | Value |
|---|---|
X-API-KEY |
The key's public_id (ek_…) |
X-API-TIMESTAMP |
Client time in milliseconds since epoch |
X-API-SIGNATURE |
HMAC-SHA256, lowercase hex |
X-API-RECV-WINDOW |
Accept window in ms: 5000 by default, 60000 maximum |
The canonical string
canonical = TIMESTAMP + "\n" + METHOD + "\n" + PATH + "\n" + QUERY + "\n" + BODY
signature = lowercase(hex(hmac_sha256(api_secret, canonical)))
The rules for each field — breaking them is 99% of all invalid_signature
responses:
| Part | Exactly how |
|---|---|
TIMESTAMP |
The same string you send in X-API-TIMESTAMP, character for character |
METHOD |
Uppercase: GET, POST, DELETE |
PATH |
Path with a leading slash and the surface prefix: /api/v1/orders. No scheme, no host, no ? |
QUERY |
The raw query string without ?; empty string when there are no parameters |
BODY |
The raw body, exactly the bytes that go on the wire; empty string for GET/DELETE without a body |
The separator is a single \n (0x0A). There are always five lines, even when
QUERY and BODY are empty — so the canonical string ends with \n\n, or with
\n before the body.
A note on QUERY: the server takes it as is and sorts nothing. Sign the
exact string you send, including parameter order and URL encoding. A library
that rebuilds the URL after signing (or adds a parameter of its own) will break
the signature.
Example 1 — GET with parameters
The secret used in the examples (for self-checks only):
c2VjcmV0LWRvLW5vdC11c2UtaW4tcHJvZA
Request GET /api/v1/ticker?symbol=BTC_USDT, timestamp 1757500000123. The
canonical string (shown with explicit \n):
1757500000123\nGET\n/api/v1/ticker\nsymbol=BTC_USDT\n
Signature:
5414b01b270ef6e5518005d2c23b6bd8118300ebcfc789c2bf21b884c25838b5
Check your implementation byte for byte:
printf '1757500000123\nGET\n/api/v1/ticker\nsymbol=BTC_USDT\n' \
| openssl dgst -sha256 -hmac 'c2VjcmV0LWRvLW5vdC11c2UtaW4tcHJvZA' -r
Example 2 — POST with a body
Request POST /api/v1/orders, timestamp 1757500000456, empty query, body
(136 bytes, one single line, no line breaks and no extra whitespace):
{"symbol":"BTC_USDT","side":"buy","type":"limit","tif":"gtc","price":"64150.10","quantity":"0.015","client_order_id":"mm-1757500000123"}
Canonical string:
1757500000456\nPOST\n/api/v1/orders\n\n{"symbol":"BTC_USDT",…,"client_order_id":"mm-1757500000123"}
Signature:
db9795f324e502083ee00fef5b6bd66ecaa34666b3526b07497b58cd0c50f1fb
If your JSON serializer inserts spaces after : or reorders keys, that is a
different byte stream and a different signature. Serialize the body once,
into a string or buffer, sign that exact value and send that exact value.
The time window and replay protection
- The window is two-sided: a timestamp from the future is rejected just like
one from the past (
stale_timestamp). A skewed client clock is the second most common cause of rejections after a malformed canonical string; check againstGET /api/v1/time. - An
X-API-RECV-WINDOWabove60000, zero, or not a number gives 401bad_recv_window. Widening the window "just in case" hurts: it also defines how long replay protection lives. - Every accepted signature is remembered for the length of the window. Repeating
the same signature gives 401
replayed_request.
Sharp edge. The signature is a function of (timestamp, method, path, query, body). Two identical requests with the same timestamp produce the same signature, and the second one is rejected as a replay — even though it is not an attack but two honest orders in a row. On a fast machine two calls easily land in the same millisecond.
So the client must guarantee strictly increasing timestamps. The trick the exchange's own tests use:
timestamp = max(current_time_ms, last_timestamp + 1)
last_timestamp = timestamp
A timestamp a couple of milliseconds ahead is safe — the window is two-sided.
The alternative is to add a unique field to every request (client_order_id for
orders), but that does not work everywhere, while a monotonic counter always
does.
3. Working examples
All three examples do the same thing: read /account/balances, then place a
limit order. Just fill in API_KEY, API_SECRET and BASE.
bash + curl + openssl
#!/usr/bin/env bash
set -euo pipefail
BASE="https://wallex.zone"
API_KEY="ek_…"
API_SECRET="…"
# Strictly monotonic timestamp: two requests in the same millisecond would share a signature.
LAST_TS=0
next_ts() {
local now
now=$(date +%s%3N)
if (( now <= LAST_TS )); then now=$(( LAST_TS + 1 )); fi
LAST_TS=$now
printf '%s' "$now"
}
signed() {
local method="$1" path="$2" query="${3:-}" body="${4:-}"
local ts sig url
ts=$(next_ts)
sig=$(printf '%s\n%s\n%s\n%s\n%s' "$ts" "$method" "$path" "$query" "$body" \
| openssl dgst -sha256 -hmac "$API_SECRET" -r | cut -d' ' -f1)
url="$BASE$path"
[[ -n "$query" ]] && url="$url?$query"
curl -sS -X "$method" "$url" \
-H "X-API-KEY: $API_KEY" \
-H "X-API-TIMESTAMP: $ts" \
-H "X-API-SIGNATURE: $sig" \
${body:+-H 'Content-Type: application/json' --data-raw "$body"}
}
signed GET /api/v1/account/balances
ORDER='{"symbol":"BTC_USDT","side":"buy","type":"limit","price":"50000.00","quantity":"0.001","client_order_id":"bash-'"$(date +%s%3N)"'"}'
signed POST /api/v1/orders '' "$ORDER"
--data-raw matters here: --data strips line breaks and may change the body
bytes, and the signature is computed over those bytes.
Python
import hashlib
import hmac
import json
import time
import requests
BASE = "https://wallex.zone"
API_KEY = "ek_…"
API_SECRET = "…"
class Client:
def __init__(self, base: str, key: str, secret: str) -> None:
self.base, self.key, self.secret = base, key, secret
self.session = requests.Session()
self._last_ts = 0
def _timestamp(self) -> str:
# Strictly monotonic: the same timestamp on two identical requests yields
# the same signature, and the second one comes back as replayed_request.
ts = max(int(time.time() * 1000), self._last_ts + 1)
self._last_ts = ts
return str(ts)
def request(self, method: str, path: str, params: dict | None = None, body: dict | None = None):
query = "&".join(f"{k}={v}" for k, v in (params or {}).items())
# Serialize the body ONCE and send exactly the bytes you signed.
payload = json.dumps(body, separators=(",", ":")) if body is not None else ""
ts = self._timestamp()
canonical = "\n".join([ts, method.upper(), path, query, payload])
signature = hmac.new(self.secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
headers = {
"X-API-KEY": self.key,
"X-API-TIMESTAMP": ts,
"X-API-SIGNATURE": signature,
"Accept": "application/json",
}
if payload:
headers["Content-Type"] = "application/json"
url = f"{self.base}{path}" + (f"?{query}" if query else "")
response = self.session.request(method.upper(), url, headers=headers, data=payload or None)
if response.status_code >= 400:
error = response.json()
raise RuntimeError(f"{response.status_code} {error['code']}: {error['message']}")
return response.json()
client = Client(BASE, API_KEY, API_SECRET)
print(client.request("GET", "/api/v1/account/balances"))
order = client.request("POST", "/api/v1/orders", body={
"symbol": "BTC_USDT",
"side": "buy",
"type": "limit",
"price": "50000.00", # money as strings, always
"quantity": "0.001",
"client_order_id": f"py-{int(time.time() * 1000)}",
})
print(order["order"]["id"], order["order"]["status"])
Node.js (18+, no dependencies)
import { createHmac } from 'node:crypto';
const BASE = 'https://wallex.zone';
const API_KEY = 'ek_…';
const API_SECRET = '…';
let lastTimestamp = 0;
function nextTimestamp() {
// Node easily fires two requests within one millisecond — the signatures would match too.
const now = Math.max(Date.now(), lastTimestamp + 1);
lastTimestamp = now;
return String(now);
}
async function signed(method, path, { params = {}, body = null } = {}) {
const query = new URLSearchParams(params).toString();
const payload = body === null ? '' : JSON.stringify(body);
const timestamp = nextTimestamp();
const canonical = [timestamp, method.toUpperCase(), path, query, payload].join('\n');
const signature = createHmac('sha256', API_SECRET).update(canonical).digest('hex');
const headers = {
'X-API-KEY': API_KEY,
'X-API-TIMESTAMP': timestamp,
'X-API-SIGNATURE': signature,
Accept: 'application/json',
};
if (payload) headers['Content-Type'] = 'application/json';
const url = `${BASE}${path}${query ? `?${query}` : ''}`;
const response = await fetch(url, { method: method.toUpperCase(), headers, body: payload || undefined });
const data = await response.json();
if (!response.ok) {
throw new Error(`${response.status} ${data.code}: ${data.message}`);
}
return data;
}
console.log(await signed('GET', '/api/v1/account/balances'));
const { order } = await signed('POST', '/api/v1/orders', {
body: {
symbol: 'BTC_USDT',
side: 'buy',
type: 'limit',
price: '50000.00',
quantity: '0.001',
client_order_id: `node-${Date.now()}`,
},
});
console.log(order.id, order.status);
The easiest place to debug your signature is GET /api/v1/ping: it answers only
a correctly signed request, and it risks nothing.
4. Rate limits
Buckets are counted per key (per IP for public endpoints). Default values; the environment variables are listed for anyone running the exchange themselves:
| Bucket | What it covers | Limit | Environment variable |
|---|---|---|---|
api-v1-public |
/time, /symbols, /ticker, /depth, /trades, /klines, /funding |
1200 requests / 1 min | PUBLIC_API_LIMIT_PUBLIC, PUBLIC_API_LIMIT_PUBLIC_PER |
api-v1-read |
/ping and every read-scope endpoint |
600 / 1 min | PUBLIC_API_LIMIT_READ, PUBLIC_API_LIMIT_READ_PER |
api-v1-trade |
POST /orders, DELETE /orders, DELETE /orders/{id} |
300 / 1 min | PUBLIC_API_LIMIT_TRADE, PUBLIC_API_LIMIT_TRADE_PER |
api-v1-withdraw |
POST /withdrawals |
10 / 1 min | PUBLIC_API_LIMIT_WITHDRAW, PUBLIC_API_LIMIT_WITHDRAW_PER |
| authentication failures | failed attempts from one IP | 60 / 5 min | PUBLIC_API_AUTH_FAILURE_LIMIT, PUBLIC_API_AUTH_FAILURE_WINDOW |
Other settings: PUBLIC_API_MAX_KEYS (10 active keys per account),
PUBLIC_API_ADDRESS_COOLDOWN_HOURS (24-hour cooldown on a withdrawal address).
Specifics:
- Exceeding a limit returns 429
rate_limit_exceededwithRetry-After,X-RateLimit-LimitandX-RateLimit-Remaining. Wait forRetry-After, not a fixed pause. - The authentication-failure bucket counts failures, not requests, and lives per IP. A successful request does not reset it. In practice: do not run a bot with a knowingly broken signature in production — after 60 failures from an address, every key from it stops getting meaningful answers, including the healthy ones.
- There is no separate "orders per second" bucket:
api-v1-tradecovers placement and cancellation together.
5. Errors
One format across the whole surface, including 401/403/404/429/500:
{ "code": "unknown_symbol", "message": "Unknown symbol `BTC_USD`. See GET /api/v1/symbols for the list." }
Validation errors (422) add a third field:
{
"code": "invalid_parameter",
"message": "The request body did not pass validation.",
"errors": { "quantity": ["The quantity must be sent as a JSON string (e.g. \"0.015\") to avoid float rounding."] }
}
Branch on code: message is written for humans and may change.
Authentication and access
code |
HTTP | What happened |
|---|---|---|
missing_credentials |
401 | One of the three required headers is absent |
invalid_key |
401 | No key with this public_id |
key_revoked |
401 | The key was revoked by its owner or an admin |
key_expired |
401 | expires_at has passed |
invalid_signature |
401 | The signature did not match — check the canonical string byte by byte |
stale_timestamp |
401 | Timestamp outside the window (in either direction) |
replayed_request |
401 | This signature was already accepted — see monotonic timestamps |
bad_recv_window |
401 | X-API-RECV-WINDOW is not a number, zero, or above 60000 |
unauthorized |
401 | Generic authorization failure |
ip_not_allowed |
403 | The request address is not in the key's ip_allowlist |
insufficient_scope |
403 | The key lacks the required scope |
account_blocked |
403 | The account is blocked — contact support |
kyc_required |
403 | Completed verification is required (withdrawals) |
forbidden |
403 | Generic denial |
rate_limit_exceeded |
429 | Bucket limit or authentication-failure limit |
Parameters and resources
code |
HTTP | What happened |
|---|---|---|
unknown_symbol |
404 | No such market, or it is delisted |
order_not_found |
404 | No such order on this account |
unknown_asset |
404 | No asset with this symbol |
not_found |
404 | Unknown path |
method_not_allowed |
405 | The path exists, the method does not |
not_acceptable |
406 | You asked for a response format this surface does not produce |
csrf_token_mismatch |
419 | A sign that the request went to the private surface instead of /api/v1 |
invalid_parameter |
422 | A parameter or the body failed validation (errors) |
service_unavailable |
503 | A dependency is temporarily unavailable |
server_error / request_failed |
5xx | Our fault; retry with backoff |
Trading domain rejections (POST /api/v1/orders)
code |
HTTP | What happened |
|---|---|---|
trading_paused |
503 | Trading is paused — the only rejection that clears on its own |
market_not_tradable |
422 | The market is not in a tradable state |
perp_unavailable |
422 | The perpetual surface is unavailable |
no_liquidity |
422 | Nothing for a market order to match against |
tick_lot_mismatch |
422 | Price is not a multiple of tick_size, or quantity of lot_size |
order_too_small |
422 | Quantity below the minimum |
notional_too_small |
422 | Notional in the quote asset below min_notional |
notional_too_large |
422 | Notional above max_notional |
insufficient_funds |
422 | Not enough free balance |
reduce_only_invalid |
422 | reduce_only without a matching position |
order_rejected |
422 | Any other domain rejection |
Withdrawals (POST /api/v1/withdrawals)
code |
HTTP | What happened |
|---|---|---|
ip_allowlist_required |
403 | The key has an empty ip_allowlist |
two_factor_required |
403 | 2FA is disabled on the account |
address_not_whitelisted |
403 | The address is not in the address book for this asset+network pair |
address_in_cooldown |
403 | The address is still cooling down (message carries the ready-at time) |
demo_withdraw_forbidden |
422 | Demo accounts cannot withdraw |
asset_not_withdrawable |
422 | The asset cannot be withdrawn to a blockchain network |
insufficient_funds |
422 | Not enough available balance |
6. Data format and pagination
- Time is an integer number of milliseconds since epoch. No ISO strings in
/api/v1. - Money and quantities are strings, both in responses and in requests.
"quantity": 0.015instead of"0.015"is a 422: a JSON number means the amount already went through adouble, and on an exchange that kind of error only surfaces in the accounting. - Pagination is the same for every history endpoint (
/orders,/orders/open,/my-trades,/deposits,/withdrawals): the cursorfrom_id(strictly greater) pluslimit(1..1000, 100 by default), ordered by ascendingid. There is no offset — on a live feed it skips records. - The end is a short page: fewer items than
limit. There is nohas_morefield. - A
limitoutside the bounds is a 422, not a silent clamp to 1000.
A typical export loop:
cursor = 0
while True:
page = client.request("GET", "/api/v1/my-trades", {"from_id": cursor, "limit": 1000})["trades"]
if not page:
break
handle(page)
cursor = page[-1]["id"]
7. Trading: sharp edges
client_order_id and idempotency
client_order_id is your idempotency key, unique within the account. Repeating a
request with an already used value does not create a second order: the
exchange returns the existing one and answers 200 OK instead of
201 Created.
Tell creation and repeat apart by the status code: the body is identical in both cases. That is what makes a retry on timeout safe — the connection dropped, the response never arrived, and the retry returns the same order instead of doubling the position. Use it whenever a request can be retried.
The identifier space is per account: someone else's client_order_id will never
return someone else's order.
post_only is rejected asynchronously
A post_only order that would cross the book cannot be rejected in the POST
response: the engine decides, not the API. Such an order gets a 201 with
status pending, and rejected shows up later.
The consequence: a 201 on a post_only order does not mean it is resting
in the book. If your logic depends on that (a maker strategy), poll the status —
GET /api/v1/orders/{id} or GET /api/v1/orders/open.
Cancellation is asynchronous in exactly the same way: DELETE /api/v1/orders/{id}
answers 200 and may still return the previous status — the command has gone to
the engine. A repeated DELETE is also a 200 (idempotent), not a 404 and not a
409.
/my-trades contains order fills only
Liquidations and funding payments are not in /my-trades at all. They have no
order behind them, and the endpoint returns order fills.
In practice: perpetual PnL computed from trades alone will diverge from the
balance, the more so the longer a position is held. The full picture comes from
realized_pnl in GET /api/v1/account/positions and the movements in
/account/balances.
Delisted pairs: an asymmetry
A delisted market does not exist for this surface: it is absent from /symbols,
and any request with that symbol is a 404 unknown_symbol.
But its orders and trades stay in the history and come back in unfiltered
listings. So GET /api/v1/orders will return an order on the FOO_USDT pair
while GET /api/v1/orders?symbol=FOO_USDT answers 404. A client that issues a
follow-up request per symbol from the history breaks on this — export the history
unfiltered and group it on your side.
Miscellaneous
GET /api/v1/tickeralways returns a list, even for a single symbol.DELETE /api/v1/ordersrequiressymbol; without it you get a 422, not "cancel everything". The response holdscanceledandfailedlists: whatever landed infailedmust be re-read, it is still active.- Demo accounts trade through the same API; their trades and balances are marked
is_demo, and withdrawals are closed for them.
8. Withdrawals
A bot cannot type a TOTP code, so the second factor on withdrawals is replaced by a set of conditions. All four are checked before any money moves:
- The key has a non-empty
ip_allowlist. A key without an address restriction cannot withdraw at all — 403ip_allowlist_required. Your account will not let you issue awithdrawkey without an allowlist either. - The account has 2FA enabled and confirmed — otherwise 403
two_factor_required. - The address is in your address book for this asset+network pair, otherwise
403
address_not_whitelisted. Adding an address itself costs a second factor (POST /api/withdrawal-addresseswithcode/password). - The address has rested for 24 hours (
PUBLIC_API_ADDRESS_COOLDOWN_HOURS) since it was added, otherwise 403address_in_cooldown; the ready-at time is inmessageand in theusable_atfield of the address-book record. This is what actually saves you when a session is stolen: the owner has a day to notice a stranger's address.
On top of that come the general gates: passed KYC (403 kyc_required), an
unblocked account (403 account_blocked), and the demo ban (422
demo_withdraw_forbidden).
There is no bank withdrawal through the API: bank details have no address, so
there is nothing to replace the second factor with. Those requests are created
only in your account; in the GET /api/v1/withdrawals history they appear with
type: bank, but without the details.
The request:
POST /api/v1/withdrawals
{
"asset": "USDT",
"network": "trx", // legacy spellings such as "TRC-20" are normalized
"address": "T…", // must be in the address book
"amount": "100.5" // as a string
}
The response is a 201 with a {"withdrawal": {…}} body. The request stores the
address spelling from the address book, not from the request.
The request is asynchronous: status goes pending → processing → completed | rejected, poll GET /api/v1/withdrawals.
9. Integration checklist
- Issue a key with the smallest set of scopes; for withdrawals, always with an
ip_allowlist. - Verify your signature on
GET /api/v1/pingbefore touching trading endpoints. - Synchronize your clock (
GET /api/v1/time) and make timestamps strictly monotonic. - Read
/symbolsat startup:tick_size,lot_size,min_notional,max_leveragecome from there, not from your bot's config. - Set a
client_order_idon every order and retry the request on timeout. - Branch on
codefor every error; on 429, wait forRetry-After. - For perpetuals, compute PnL from positions, not from
/my-trades.