CryptoExchanger
Синхронизируем рынок
For developers

Public API v1 — integrator guide

Everything the spec leaves out: how to sign a request, how the limits work and where the API behaves differently than it looks.

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/v1 is 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 secret is 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 PUT with scopes returns 422. Need a different set of rights — create a new key.
  • Issuing a trade/withdraw key requires a second factor (TOTP, or your password if 2FA is off). A read-only key needs no confirmation.
  • A withdraw key cannot be issued without 2FA and a non-empty ip_allowlist — see section 8.
  • The number of active (non-revoked) keys per account is capped by PUBLIC_API_MAX_KEYS (10 by default), otherwise 422 key_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 against GET /api/v1/time.
  • An X-API-RECV-WINDOW above 60000, zero, or not a number gives 401 bad_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_exceeded with Retry-After, X-RateLimit-Limit and X-RateLimit-Remaining. Wait for Retry-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-trade covers 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.015 instead of "0.015" is a 422: a JSON number means the amount already went through a double, 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 cursor from_id (strictly greater) plus limit (1..1000, 100 by default), ordered by ascending id. There is no offset — on a live feed it skips records.
  • The end is a short page: fewer items than limit. There is no has_more field.
  • A limit outside 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/ticker always returns a list, even for a single symbol.
  • DELETE /api/v1/orders requires symbol; without it you get a 422, not "cancel everything". The response holds canceled and failed lists: whatever landed in failed must 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:

  1. The key has a non-empty ip_allowlist. A key without an address restriction cannot withdraw at all — 403 ip_allowlist_required. Your account will not let you issue a withdraw key without an allowlist either.
  2. The account has 2FA enabled and confirmed — otherwise 403 two_factor_required.
  3. 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-addresses with code/password).
  4. The address has rested for 24 hours (PUBLIC_API_ADDRESS_COOLDOWN_HOURS) since it was added, otherwise 403 address_in_cooldown; the ready-at time is in message and in the usable_at field 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

  1. Issue a key with the smallest set of scopes; for withdrawals, always with an ip_allowlist.
  2. Verify your signature on GET /api/v1/ping before touching trading endpoints.
  3. Synchronize your clock (GET /api/v1/time) and make timestamps strictly monotonic.
  4. Read /symbols at startup: tick_size, lot_size, min_notional, max_leverage come from there, not from your bot's config.
  5. Set a client_order_id on every order and retry the request on timeout.
  6. Branch on code for every error; on 429, wait for Retry-After.
  7. For perpetuals, compute PnL from positions, not from /my-trades.