Jade Perp — Bot API reference
Programmatic trading over HTTP (and a WebSocket for live data) with an API key you create in the Jade Perp app. Everything a trader can do on the website, a bot can do here — market data, orders of every type, take-profit / stop-loss, order management, positions, leverage and margin mode — with no manual step on the website beyond creating the key.
- Base URL:
https://api.jadeperp.com, every REST path below under/api/v1; the stream iswss://api.jadeperp.com/ws. Full details, and the addresses of a local instance: Base URL. - There is no sandbox and no testnet: every key trades real funds (No test environment).
- The bot API is served by the same services as the web app: a bot and the UI always see the same numbers.
- There are no withdrawal endpoints, and there never will be. A leaked key must not be able to move money out of your account. Withdrawals happen only in the web app, signed by your wallet.
- Everything else the site shows you is readable here: your account status, your deposit address and deposit history, your withdrawal history and your affiliate earnings (§8.6, §8.7).
Contents
- Base URL — production and local addresses
- No test environment — read this before your first key
- Quick start
- API keys and scopes
- Authentication — Node and Python signing clients
- Hedge mode — read this before your first order
- Conventions — envelope, decimals, precision, pagination
- Idempotency and
clientOid - Unknown outcomes
- Endpoints — market data, account, positions, orders, TP/SL, funding, affiliate
- Take-profit / stop-loss semantics
- WebSocket
- Rate limits
- Errors
- Checklist before you go live
Base URL
| What | Address |
|---|---|
| REST, production | https://api.jadeperp.com |
| WebSocket, production | wss://api.jadeperp.com/ws |
| REST / WebSocket, a local instance | http://localhost:4000 / ws://localhost:4000/ws |
Every REST path in this document is under /api/v1, so a full URL looks like
https://api.jadeperp.com/api/v1/account. The /v1 paths mentioned here and there (POST /v1/api-keys) are the web app's own API and live on the same host.
https://jadeperp.com is the website, not the API: https://jadeperp.com/api/v1/... answers 404.
Keep the host in one constant (BASE in the clients below) and point it at http://localhost:4000
if you run Jade Perp yourself; nothing else changes. The signature covers the path only —
/api/v1/orders/detail?orderId=123, never the host or the scheme — so the same signing code works
against either base URL (§3).
No test environment
There is no sandbox, no testnet and no paper-trading mode today. Every API key trades real funds on the live exchange, and every order you send can lose money. There is no flag, header or base URL that makes an order pretend.
The safe way to try the API out:
- Start with a
read-only key (§2). It can call everyGETendpoint and log in to the WebSocket, and it cannot place, modify or cancel anything. Build and run your whole market-data and state-reading path on it first. - Then a
tradekey, at the smallest size the market allows.GET /marketsgives you, per market,minTradeNum(the smallest size) andminTradeUSDT(the smallest notional of a limit order); send exactly that, on a market whose minimum is cheap, and watch the order throughGET /orders/detail,GET /positionsandGET /fillsbefore you scale anything up. - Keep the blast radius small while you learn: low leverage, a stop-loss on every position
(§9), and
POST /orders/countdown-cancel-allso a bot that dies does not leave resting orders behind. - A rejected or refused request costs nothing, so validation, signing, idempotency and error
handling can all be exercised without a fill: a deliberately malformed body answers
400, and a key without thetradescope answers403.
If a sandbox ever appears, it will be documented here, in this section, with its own base URL.
1. Quick start
The whole life of a trade, using the jade() helper from §3:
import { randomUUID } from "node:crypto";
const oid = (tag) => `${tag}-${randomUUID().slice(0, 20)}`; // ≤ 32 chars, [A-Za-z0-9._:/-]
// 0. Leverage for the long side (hedge mode keeps long and short separate).
await jade("POST", "/api/v1/leverage", { symbol: "BTCUSDT", leverage: 10, holdSide: "long" });
// 1. Open a long at market.
await jade("POST", "/api/v1/orders", {
symbol: "BTCUSDT",
side: "buy", // buy + open = open/add to a LONG
tradeSide: "open",
orderType: "market",
size: "0.010",
marginMode: "crossed",
clientOid: oid("open"),
});
// 2. Protect it: take profit at 70k (last price), stop at 60k (mark price), whole position.
const tpslOid = oid("tpsl");
await jade("POST", "/api/v1/tpsl", {
symbol: "BTCUSDT",
holdSide: "long",
tpPrice: "70000.0",
slPrice: "60000.0",
slTriggerBy: "mark",
clientOid: tpslOid,
});
// 2b. Move the stop-loss up.
await jade("POST", "/api/v1/tpsl/modify", { symbol: "BTCUSDT", clientOid: tpslOid, slPrice: "62500.0" });
// 3. Close half of the long (market).
await jade("POST", "/api/v1/positions/close", {
symbol: "BTCUSDT",
holdSide: "long",
size: "0.005",
clientOid: oid("half"),
});
// 4. Close the rest, cancel whatever is still working on the symbol.
await jade("POST", "/api/v1/positions/close", { symbol: "BTCUSDT", holdSide: "long", clientOid: oid("all") });
await jade("POST", "/api/v1/orders/cancel-all", { symbol: "BTCUSDT" });
Steps 1 and 2 can also be one request: put tpPrice / slPrice on the order itself (a preset
TP/SL, §9). A preset is changed with POST /orders/modify
while the order is working.
2. API keys and scopes
Create a key in the Jade Perp app, or over the web API with your session token:
POST /v1/api-keys
Authorization: Bearer <accessToken>
Content-Type: application/json
{ "label": "my-bot", "scopes": ["read", "trade"] }
{
"data": {
"id": "800001",
"keyId": "jp_8Xk2mQn4PvR7sT1wZc0y",
"label": "my-bot",
"scopes": ["read", "trade"],
"createdAt": "2026-09-16T09:12:44.118Z",
"lastUsedAt": null,
"revokedAt": null,
"secret": "qP3nX9ub7Lm0dK4rYcW2sE6tHgZaVfJoN1iB5uQxRkY"
}
}
keyIdidentifies the key publicly:jp_+ 20 characters of[A-Za-z0-9_-]. Thesecretis shown once, in this response only — store it now. Lost it? Revoke the key and create another.- Scopes
read— everyGETendpoint and the WebSocket login.trade— everyPOSTendpoint (orders, TP/SL, cancels, closes, leverage, margin mode). Atrade-only key cannot read; most bots want both. A monitoring bot should getreadonly.
- At most 10 active keys per account.
DELETE /v1/api-keys/:idrevokes a key immediately and permanently (a revoked key answers401; open WebSockets lose their private channels within a minute).GET /v1/api-keyslists your keys withlastUsedAt. - A suspended account cannot create keys, and its existing keys answer
403. - Your trading account is prepared automatically after sign-up. Until it is ready, account and
trading endpoints answer
409 ACCOUNT_NOT_READY— retry later; nothing needs to be done on the website.
3. Authentication
Every request carries three headers:
| Header | Value |
|---|---|
X-JADE-KEY |
your keyId |
X-JADE-TIMESTAMP |
current time in milliseconds since the epoch, as a decimal string |
X-JADE-SIGNATURE |
lowercase hex HMAC-SHA256(secret, payload) |
payload = timestamp + METHOD + pathWithQuery + rawBody
timestamp— exactly the string sent inX-JADE-TIMESTAMP.METHOD— uppercaseGETorPOST. (HEADworks on everyGETendpoint and is signed asHEAD.)pathWithQuery— the path including/api/v1and the query string, exactly as sent:/api/v1/orders/detail?orderId=123. No host.rawBody— the exact bytes of the body, or an empty string when there is none. Serialise once, sign that string, send that string.
Rules:
- The timestamp must be within ±5 seconds of our clock. Keep your clock synced (NTP).
POSTbodies must beContent-Type: application/json, at most 16 KB.- Unknown body fields are refused with
400 VALIDATION— a typo never silently drops a parameter. - Every credential problem (unknown or revoked key, stale timestamp, wrong signature) answers the
same
401 Invalid API credentials, on purpose. Check all three. - The host is not part of the payload, so a signature made for
https://api.jadeperp.comis the same signature a local instance accepts.
Shell (curl)
One signed request, to check your key before you write any code:
BASE=https://api.jadeperp.com
PATH_Q=/api/v1/account
TS=$(date +%s000)
SIG=$(printf '%sGET%s' "$TS" "$PATH_Q" | openssl dgst -sha256 -hmac "$JADE_SECRET" -hex | sed 's/.* //')
curl -s "$BASE$PATH_Q" \
-H "X-JADE-KEY: $JADE_KEY_ID" \
-H "X-JADE-TIMESTAMP: $TS" \
-H "X-JADE-SIGNATURE: $SIG"
date +%s000 is second precision, which is inside the ±5 s window; a POST appends the exact body
bytes to the payload and sends the same bytes with --data.
Node client
// node >= 20, no dependencies. JADE_KEY_ID / JADE_SECRET from the key creation response.
import { createHmac } from "node:crypto";
const BASE = process.env.JADE_BASE ?? "https://api.jadeperp.com";
const KEY_ID = process.env.JADE_KEY_ID;
const SECRET = process.env.JADE_SECRET;
export async function jade(method, pathWithQuery, body, headers = {}) {
const raw = body === undefined ? "" : JSON.stringify(body);
const timestamp = Date.now().toString();
const signature = createHmac("sha256", SECRET)
.update(timestamp + method.toUpperCase() + pathWithQuery + raw)
.digest("hex");
const response = await fetch(BASE + pathWithQuery, {
method,
headers: {
"content-type": "application/json",
"X-JADE-KEY": KEY_ID,
"X-JADE-TIMESTAMP": timestamp,
"X-JADE-SIGNATURE": signature,
...headers,
},
...(raw === "" ? {} : { body: raw }), // exactly the bytes that were signed
});
const json = await response.json();
if (!response.ok) {
const err = new Error(`${response.status} ${json.error.code}: ${json.error.message}`);
Object.assign(err, { status: response.status, ...json.error });
throw err;
}
return json; // { data, meta? }
}
// read
const { data: account } = await jade("GET", "/api/v1/account");
const { data: book } = await jade("GET", "/api/v1/markets/BTCUSDT/book?depth=20");
// trade — every order says which position it acts on: side + tradeSide
const { data: placed } = await jade("POST", "/api/v1/orders", {
symbol: "BTCUSDT",
side: "sell", // sell + open = open/add to a SHORT
tradeSide: "open",
orderType: "limit",
price: "66000.0",
size: "0.002",
force: "post_only",
marginMode: "isolated",
slPrice: "68000.0",
clientOid: "short-000000001",
});
Python client
# Python >= 3.9, standard library only.
import hashlib, hmac, json, os, time, urllib.error, urllib.request
BASE = os.environ.get("JADE_BASE", "https://api.jadeperp.com")
KEY_ID = os.environ["JADE_KEY_ID"]
SECRET = os.environ["JADE_SECRET"]
class JadeError(Exception):
def __init__(self, status, error):
super().__init__(f"{status} {error.get('code')}: {error.get('message')}")
self.status, self.code = status, error.get("code")
self.details = error.get("details")
def jade(method, path_with_query, body=None, headers=None):
raw = "" if body is None else json.dumps(body, separators=(",", ":"))
timestamp = str(int(time.time() * 1000))
payload = timestamp + method.upper() + path_with_query + raw
signature = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
request = urllib.request.Request(
BASE + path_with_query,
method=method.upper(),
data=raw.encode() if raw else None, # exactly the bytes that were signed
headers={
"Content-Type": "application/json",
"X-JADE-KEY": KEY_ID,
"X-JADE-TIMESTAMP": timestamp,
"X-JADE-SIGNATURE": signature,
**(headers or {}),
},
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
return json.loads(response.read())
except urllib.error.HTTPError as e:
raise JadeError(e.code, json.loads(e.read()).get("error", {})) from None
# open a long with a take-profit and a stop-loss
placed = jade("POST", "/api/v1/orders", {
"symbol": "BTCUSDT",
"side": "buy", # buy + open = open/add to a LONG
"tradeSide": "open",
"orderType": "market",
"size": "0.010",
"marginMode": "crossed",
"tpPrice": "70000.0",
"slPrice": "60000.0",
"clientOid": "py-long-0000001",
})["data"]
print(placed) # {'orderId': '...', 'clientOid': 'py-long-0000001'}
# close the long
jade("POST", "/api/v1/positions/close",
{"symbol": "BTCUSDT", "holdSide": "long", "clientOid": "py-close-0000001"})
4. Hedge mode (always on)
Every Jade Perp account runs in hedge mode. It is switched on automatically while your account is prepared, before your first trade, and it never changes. You never set a position mode — there is no endpoint for it, and there is no one-way mode.
In hedge mode, a market can hold two independent positions at the same time: a long and a short.
Each has its own size, entry price, leverage, margin and TP/SL. An order therefore has to say which
position it acts on and whether it opens or closes it. That is what side + tradeSide do, and
both are required on every order:
| You want to… | side |
tradeSide |
Acts on |
|---|---|---|---|
| Open or add to a long | buy |
open |
the long |
| Reduce or close a long | sell |
close |
the long |
| Open or add to a short | sell |
open |
the short |
| Reduce or close a short | buy |
close |
the short |
Rule of thumb: buy + open and sell + close are the long; sell + open and buy + close are the short.
Consequences worth knowing:
- Selling does not reduce a long unless you say
close.sell+openwhile you hold a long opens a short next to it — both positions exist until you close one. - A
closeorder can never flip a position or open the other side; the exchange refuses a close larger than the position. reduceOnlydoes not exist here (it is a one-way-mode concept) and is refused with400.holdSide(long|short) is optional on an order and only a cross-check: if you send it, it must match whatside+tradeSidealready imply, otherwise400.- Positions are returned per side:
GET /positionsmay listBTCUSDT longandBTCUSDT short. - Closing uses
holdSide(POST /positions/close), leverage may be set per side (holdSideonPOST /leverage), and every TP/SL belongs to one side (holdSideonPOST /tpsl).
Examples:
// 1. Open a 0.01 BTC long at market
{ "symbol": "BTCUSDT", "side": "buy", "tradeSide": "open", "orderType": "market", "size": "0.01", "marginMode": "crossed", "clientOid": "ex-1" }
// 2. Hedge it with a 0.004 BTC short — the long stays open
{ "symbol": "BTCUSDT", "side": "sell", "tradeSide": "open", "orderType": "market", "size": "0.004", "marginMode": "crossed", "clientOid": "ex-2" }
// 3. Take profit on half the long with a limit order
{ "symbol": "BTCUSDT", "side": "sell", "tradeSide": "close", "orderType": "limit", "price": "71000.0", "size": "0.005", "marginMode": "crossed", "clientOid": "ex-3" }
// 4. Close the short
{ "symbol": "BTCUSDT", "side": "buy", "tradeSide": "close", "orderType": "market", "size": "0.004", "marginMode": "crossed", "clientOid": "ex-4" }
5. Conventions
Envelope. Success (HTTP 2xx): { "data": ..., "meta": { "nextCursor": ... } } (meta only on
paginated lists). Failure (4xx/5xx): { "error": { "code", "message", "details"?, "traceId" } }.
Numbers. Prices, sizes, money, rates and ids are strings — parse them with a decimal library, never as floats. Exchange timestamps are millisecond epoch values as the exchange sent them (strings on account objects, numbers on market data); our own timestamps are ISO-8601. Account values — balances, PnL, liquidation price, margin, leverage — are the exchange's own figures, never recomputed.
Absent values. A field the exchange did not report comes back as an empty string. We never
turn it into a "0": a price we were not told is not a price of zero. The exchange itself, though,
sometimes uses "0" for the same idea — a stop-loss that was never set is reported as "0" — and
that "0" is passed through exactly as it arrived. So treat "" and "0" alike as "not set"
(!value || Number(value) === 0), and never let an empty string reach a decimal parser.
Words from the exchange. status and the other free-text state fields carry the exchange's own
vocabulary, passed through unchanged. The values listed here are the ones we have seen; the list is
not exhaustive and can grow. Match on the values you care about and treat anything else as
unknown rather than assuming a closed set — and never make "the order is working" depend on one
exact word.
Symbols. 2–30 characters of [A-Z0-9] (BTCUSDT); lowercase is accepted and upper-cased. A
symbol that is not listed answers 404 NOT_FOUND.
Sizes are in the base coin (BTC for BTCUSDT).
Precision. Use GET /markets:
sizeis rounded down tovolumePlacedecimals; a size that rounds to zero, or is belowminTradeNum, is refused.- Every price (
price,tpPrice,slPrice,tpLimitPrice,slLimitPrice) is rounded to the market's tick (tickSize=priceEndStep× 10^-pricePlace), half up. - A limit order below
minTradeUSDTnotional is refused.
The rounded values are what is sent to the exchange; the response carries the resulting ids, and the
order itself (GET /orders/detail) shows the exact price and size.
Pagination. Lists with a cursor return meta.nextCursor; pass it back as ?cursor= (≤ 64
characters). null means there is nothing more.
Booleans and enums are JSON booleans and lowercase strings exactly as listed.
6. Idempotency and clientOid
Every request that creates something carries a clientOid you generate:
- orders (
POST /orders, each line ofPOST /orders/batch), - position closes (
POST /positions/close), - TP/SL orders (
POST /tpsl).
Rules:
- Format: 1–32 characters of
A–Z a–z 0–9 . : / _ -(regex^[.A-Za-z0-9:/_-]{1,32}$). Anything else is refused with400before it reaches the exchange. - Generate it once per intended order and reuse it on every retry of that order. A retry with the
same
clientOidand the same body never creates a second order — it returns the first answer. - The same
clientOidwith a different body answers409 IDEMPOTENCY_CONFLICT. Orders and position closes share one namespace (aclientOidused for an order cannot be reused for a close). TP/SL orders have their own. - A definitive answer (success, or a
4xxsuch as a rejection) is stored and replayed for thatclientOid. A5xxanswer is not stored, so the sameclientOidcan be retried (see §7). clientOidis how you find the order later:GET /orders/detail?clientOid=…,POST /orders/cancel.
Modifications, cancels, cancel-all, leverage, margin mode and the countdown set absolute values,
so repeating one is harmless. POST /orders/modify and POST /tpsl/modify also accept an optional
Idempotency-Key header (8–64 characters of [A-Za-z0-9_-]): a repeat with the same key returns the
stored answer without sending anything; the same key with a different body is 409 IDEMPOTENCY_CONFLICT.
7. Unknown outcomes: what to do when the answer is unclear
Sometimes the exchange does not confirm whether a request took effect (a timeout, or its codes
40010 / 40725 / 45001). The API then answers:
{ "error": { "code": "BROKER_UNAVAILABLE", "message": "The exchange did not confirm the outcome, it is being checked",
"details": { "venueCode": "40010", "outcome": "unknown" }, "traceId": "req-7f" } }
details.outcome: "unknown" means the request may or may not have happened. Never send it again
with a new id. Instead:
| Request | What to do |
|---|---|
POST /orders, a partial POST /positions/close |
Retry with the same clientOid. The server first asks the exchange whether that order exists and returns it if it does; it only places it if it does not. 409 CONFLICT means the check is still running — wait a second and retry. |
POST /orders/batch |
Each line reports its own result. Retry the lines with ok: false (same clientOid, via /orders or a new batch). Lines that succeeded are replayed, not re-placed. |
POST /tpsl |
Retry with the same clientOid. The server looks for a working TP/SL with that id first and returns it; if a whole-position replacement had already cancelled the previous TP/SL, the retry places the new one. |
A whole-position POST /positions/close (no size) |
Read GET /positions. If the side is still open, send a new close with a new clientOid. |
| Modify, cancel, cancel-all, leverage, countdown | Read the state (/orders/detail, /orders/open, /tpsl/open, /positions) and send it again if it did not apply — these are absolute and safe to repeat. |
Other 502 BROKER_UNAVAILABLE answers without outcome mean the exchange did not answer at all:
back off and retry. 503 BROKER_PENDING means trading is not open yet.
8. Endpoints
Scope column: R = read, T = trade.
| Method | Path | Scope | Summary |
|---|---|---|---|
| GET | /markets |
R | Listed markets and their contract rules |
| GET | /markets/summary |
R | Last price, 24 h change and volume for every market |
| GET | /tickers |
R | Full ticker for every market |
| GET | /markets/:symbol/ticker |
R | Full ticker for one market |
| GET | /markets/:symbol/book |
R | Order book |
| GET | /markets/:symbol/trades |
R | Recent public trades |
| GET | /markets/:symbol/candles |
R | Candles |
| GET | /markets/:symbol/tiers |
R | Position tiers (max leverage per notional) |
| GET | /fees |
R | Platform fee schedule |
| GET | /me |
R | The account this key belongs to, and what the platform currently allows |
| GET | /account |
R | Balances and equity |
| GET | /account/fee-rate |
R | Your effective fee rates for a market |
| GET | /positions |
R | Open positions (per side) |
| GET | /positions/history |
R | Positions the exchange has fully closed — one row per position, or one per close with merge=false; fees and funding included |
| GET | /pnl/summary |
R | What a period realised: totals and a per-day breakdown |
| GET | /pnl/closes |
R | Every close a period contained, one row per closing execution — partial closes included |
| GET | /positions/adl-rank |
R | Auto-deleveraging rank of your positions |
| POST | /positions/close |
T | Close all or part of one side |
| POST | /leverage |
T | Set leverage (per side) |
| POST | /margin-mode |
T | Choose isolated / crossed for a market |
| GET | /orders/open |
R | Working orders |
| GET | /orders/history |
R | Past orders |
| GET | /orders/detail |
R | One order by orderId or clientOid |
| GET | /fills |
R | Your executions, optionally over a time window — the realised result of a single close (profit) is here |
| POST | /orders |
T | Place an order (market / limit, GTC / IOC / FOK / post-only, preset TP/SL) |
| POST | /orders/batch |
T | Place up to 20 orders |
| POST | /orders/modify |
T | Change price, size and/or preset TP/SL of a working order |
| POST | /orders/cancel |
T | Cancel one order |
| POST | /orders/cancel-batch |
T | Cancel up to 20 orders |
| POST | /orders/cancel-all |
T | Cancel every order of a symbol, or of all symbols |
| POST | /orders/countdown-cancel-all |
T | Dead-man switch |
| GET | /tpsl/open |
R | Working TP/SL orders |
| GET | /tpsl/history |
R | Past TP/SL orders |
| POST | /tpsl |
T | Attach a TP/SL to a position |
| POST | /tpsl/modify |
T | Change a TP/SL |
| POST | /tpsl/cancel |
T | Cancel a TP/SL |
| GET | /deposit/networks |
R | Networks USDT can be sent on, and whether each is open |
| GET | /deposit/address |
R | Your deposit address for one network |
| GET | /deposits |
R | Your deposits |
| GET | /withdrawals |
R | Your withdrawals — history only |
| GET | /affiliate |
R | Referral code and affiliate earnings |
| GET | /affiliate/referrals |
R | Users you referred |
| GET | /affiliate/accruals |
R | Affiliate earnings, row by row |
All paths are under /api/v1.
8.1 Market data
GET /api/v1/markets — R
{
"data": [
{
"symbol": "BTCUSDT", "base": "BTC", "quote": "USDT",
"pricePlace": 1, "priceEndStep": "1", "tickSize": "0.1",
"groupSteps": ["0.1", "1", "10", "100"],
"volumePlace": 4, "sizeMultiplier": "0.0001",
"minTradeNum": "0.0001", "minTradeUSDT": "5",
"maxLeverVenue": 125, "maxLever": 100,
"makerFeeRate": "0.0002", "takerFeeRate": "0.0006",
"fundIntervalHours": 8, "status": "normal", "category": "crypto"
}
]
}
maxLever is the most leverage Jade Perp allows on that market (never more than 100x).
category is crypto or rwa (stocks, ETFs, commodities).
GET /api/v1/markets/summary — R
{ "data": [ { "symbol": "BTCUSDT", "last": "64010.5", "change24h": "-0.0123", "quoteVolume": "1834550120.4" } ] }
change24h is a ratio (-0.0123 = −1.23 %).
GET /api/v1/tickers — R · GET /api/v1/markets/:symbol/ticker — R
/tickers returns every market (heavy — prefer the summary or the WebSocket); the single form returns one.
{
"data": {
"symbol": "BTCUSDT", "last": "64010.5", "mark": "64011.2", "index": "64008.9",
"bid": "64010.4", "ask": "64010.5",
"open24h": "64800.0", "high24h": "65120.0", "low24h": "63500.1", "change24h": "-0.0123",
"baseVolume": "28650.12", "quoteVolume": "1834550120.4",
"fundingRate": "0.0001", "nextFundingTime": 1789516800000,
"openInterest": "51234.55", "ts": 1789510423940
}
}
404 when the market is unknown or has no ticker yet.
GET /api/v1/markets/:symbol/book?depth=50&step=0.1 — R
| Query | Default | Notes |
|---|---|---|
depth |
50 | 1–50 levels per side |
step |
tick | price grouping; one of the market's groupSteps |
{ "data": { "symbol": "BTCUSDT", "asks": [["64010.5", "1.2"], ["64011.0", "0.4"]], "bids": [["64009.5", "0.9"]], "ts": 1789510423940 } }
Asks ascending, bids descending, levels are [price, size].
GET /api/v1/markets/:symbol/trades?limit=50 — R
limit 1–100 (default 50), newest first.
{ "data": [ { "id": "1189300021", "price": "64010.5", "size": "0.012", "side": "buy", "ts": 1789510423940 } ] }
GET /api/v1/markets/:symbol/candles?tf=15m&limit=300&endTime= — R
| Query | Default | Notes |
|---|---|---|
tf |
15m |
1m 3m 5m 15m 30m 1H 4H 6H 12H 1D 3D 1W 1M |
limit |
300 | 1–1000 |
endTime |
now | ms epoch; returns the candles before it — page backwards until the answer is empty |
Ascending by open time t; v = base volume, q = quote volume.
{ "data": [ { "t": 1789509600000, "o": "64000.0", "h": "64050.5", "l": "63990.0", "c": "64010.5", "v": "120.5", "q": "7713000.2" } ] }
GET /api/v1/markets/:symbol/tiers — R
{ "data": [ { "level": 1, "startUnit": "0", "endUnit": "150000", "leverage": 125, "keepMarginRate": "0.004" } ] }
GET /api/v1/fees — R
Jade Perp's published schedule (informational; your effective rates are on /account/fee-rate).
{ "data": { "makerFeeRate": "0", "takerFeeRate": "0.0002" } }
8.2 Account
GET /api/v1/me — R
Who this key belongs to, and what the platform currently allows. Read it once at start-up: it is how
a bot tells "not open yet" from "refused", instead of guessing from a 409 or a 503.
{
"data": {
"id": "100001",
"address": "0x5f3c9a0b7e2d41a8b6c5d4e3f2a1b0c9d8e7f6a5",
"referralCode": "K7MQ2XRD",
"referredBy": null,
"status": "active",
"role": "user",
"createdAt": "2026-08-02T11:04:19.882Z",
"permissions": { "trading": true, "deposits": true, "withdrawals": true }
}
}
| Field | Meaning |
|---|---|
address |
The wallet that owns the account — the only wallet a withdrawal can be signed by. |
status |
pending_broker / provisioning while the trading account is being prepared, active once it is ready, suspended if it has been stopped. Trading and account endpoints answer 409 ACCOUNT_NOT_READY until it is active. |
referralCode |
Your own code — the one to hand out (§8.7). |
permissions.trading |
false → trading endpoints answer 503 BROKER_PENDING. |
permissions.deposits |
false → GET /deposit/address answers 503 BROKER_PENDING. |
permissions.withdrawals |
Whether withdrawals are open on the website. There is no withdrawal endpoint here at any setting. |
GET /api/v1/account — R
{
"data": {
"marginCoin": "USDT", "available": "8420.41", "locked": "0",
"accountEquity": "12480.50", "usdtEquity": "12480.50",
"crossedMaxAvailable": "8420.41", "isolatedMaxAvailable": "8420.41",
"unrealizedPL": "60.09", "crossedRiskRate": "0.0421", "posMode": "hedge_mode"
}
}
posMode is always hedge_mode.
GET /api/v1/account/fee-rate?symbol=BTCUSDT — R
{ "data": { "makerFeeRate": "0.0002", "takerFeeRate": "0.0006" } }
8.3 Positions
GET /api/v1/positions — R
One row per open side.
{
"data": [
{
"symbol": "BTCUSDT", "marginCoin": "USDT", "holdSide": "long",
"posMode": "hedge_mode", "marginMode": "crossed",
"openPriceAvg": "63850.0", "total": "0.0500", "available": "0.0500", "locked": "0",
"marginSize": "638.50", "leverage": "5", "liquidationPrice": "52310.4",
"markPrice": "64010.5", "unrealizedPL": "8.02", "achievedProfits": "0",
"keepMarginRate": "0.004", "marginRatio": "0.051",
"takeProfit": "0", "stopLoss": "0",
"cTime": "1789500000000", "uTime": "1789510423940"
}
]
}
total is the position size; available is what can still be closed (not locked by close orders).
takeProfit / stopLoss on a position are not your TP/SL orders. They carry only the preset
the exchange attached to the position when it was opened (tpPrice / slPrice on POST /orders),
and they are "0" — or empty — whenever there is none. A TP/SL placed with POST /tpsl is a
separate order and does not appear here: these two fields stay "0" while that TP/SL is
working, exactly as in the example above.
GET /tpsl/open is the source of truth for working TP/SL orders. Read it (filtered by symbol
and matched on holdSide) whenever you need to know what protects a position; do not infer it from
a position row, and do not treat "0" here as "no stop-loss is in place".
GET /api/v1/positions/history?symbol=&cursor=&startTime=&endTime=&merge= — R
Positions the exchange has fully closed: by default one row per position, never a fragment
of one. Ask for merge=false and you get the exchange's rows instead — one row per close, untouched.
A position only appears here once it is fully closed. While a position is still open — including
one that has been reduced several times — it is in /positions, not here, and the realised result of
each of those partial closes is not in this list at all. That result lives on the execution that
produced it: profit on the fill, from GET /fills, which serves a time window of its own. Use this
endpoint for whole positions and /fills for individual closes.
{
"data": [
{
"positionId": "11234", "symbol": "BTCUSDT", "holdSide": "short",
"openAvgPrice": "65000.0", "closeAvgPrice": "64000.0",
"openTotalPos": "0.1", "closeTotalPos": "0.1",
"pnl": "100.0", "netProfit": "96.1", "totalFunding": "-0.3",
"openFee": "-1.95", "closeFee": "-1.95", "feesTotal": "-3.9",
"cTime": "1789400000000", "uTime": "1789500000000"
}
],
"meta": { "nextCursor": null }
}
One row per position. A position that was closed in several steps is returned as a single row
with its parts already added together. You never receive two rows for one positionId in one
response, so a report can sum the rows it gets without de-duplicating first. How the parts are
combined, if the exchange ever reports them separately:
| Rule | Fields |
|---|---|
| added together | pnl, netProfit, totalFunding, openFee, closeFee, openTotalPos, closeTotalPos |
| size-weighted average | openAvgPrice (weighted by openTotalPos), closeAvgPrice (weighted by closeTotalPos) |
| earliest of the parts | cTime |
| latest of the parts | uTime |
All of it is exact decimal arithmetic on the strings, never floating point. A position the exchange already reported whole is passed through untouched.
merge=false — the exchange's rows as they came. Pass merge=false when you want to see each
close on its own line: no grouping at all, every row exactly as the exchange reported it, in the
order it returned them. A position closed in three steps is three rows with three close times,
three close prices and three quantities. Rows still carry feesTotal (openFee + closeFee), so
the meaning of every field below is unchanged — what changes is only how many rows one position
occupies. Sum these rows and each close is counted once; do not read a row as a whole position.
merge defaults to true, which is the one-row-per-position behaviour described above and the
only behaviour that existed before. The parameter takes the literal words true or false;
anything else is a 400.
Fields.
| Field | Source | Meaning and sign |
|---|---|---|
positionId |
exchange | The position's id. Empty on the rare row the exchange sends without one; such rows are grouped by symbol + holdSide + cTime instead. |
symbol |
exchange | Market. |
holdSide |
exchange | long or short — which side was held, not the direction of the closing trade. |
openAvgPrice |
exchange (size-weighted when merged) | Average entry price. |
closeAvgPrice |
exchange (size-weighted when merged) | Average exit price. |
openTotalPos |
exchange (summed when merged) | Total size opened, in contracts. |
closeTotalPos |
exchange (summed when merged) | Total size closed, in contracts. |
pnl |
exchange (summed when merged) | Realised price result, before fees and funding. Positive = gain, negative = loss. |
netProfit |
exchange's own figure (summed when merged) | The exchange's net result for the position. See the note below before relying on it. |
totalFunding |
exchange (summed when merged) | Funding over the position's life. Negative = you paid funding, positive = you received it. |
openFee |
exchange (summed when merged) | Opening fee. Negative — it is a charge. |
closeFee |
exchange (summed when merged) | Closing fee. Negative — it is a charge. |
feesTotal |
our sum: openFee + closeFee |
Total trading fee for the position. Negative, like its parts. It is nothing but the two numbers above added up. |
cTime |
exchange (earliest when merged) | When the position was opened, unix ms as a string. |
uTime |
exchange (latest when merged) | When it was last updated — for a closed position, when it finished closing. |
Everything except feesTotal is the exchange's own number. feesTotal is the only value this API
computes, and it is a plain addition of two figures the exchange gave us. We never re-derive a
figure the exchange reports.
Which number is "the realised result". The exchange does not publish what its netProfit
includes, so do not guess from it. The unambiguous figure, built only from parts whose meaning is
defined above, is:
realised = pnl + feesTotal + totalFunding
All three are signed as returned (fees negative, funding negative when paid), so it is a plain
addition. For the example row: 100.0 + (-3.9) + (-0.3) = 95.8. netProfit is kept in the
response unchanged, as the exchange's own figure, so you can compare the two; where they differ,
the formula above is the one whose components are defined.
Filters and paging.
| Query | Notes |
|---|---|
symbol |
optional, one market |
startTime, endTime |
optional, unix milliseconds as digits |
cursor |
optional, from meta.nextCursor |
merge |
optional, true (default) or false — false returns one row per close, ungrouped |
endTimemust be later thanstartTime(400).- One request may span at most 30 days. A longer range is refused with
400rather than silently shortened — split the period and add the results up. AstartTimeon its own is measured against now, so it too must be within 30 days. - Only the last 90 days of closed positions exist; an older
startTimeis refused with400. - With no
startTime/endTimeyou get the exchange's own default window. meta.nextCursorisnullwhen there is nothing more; otherwise pass it back as?cursor=.
Paging and the one-row-per-position rule. The guarantee is per response. Since a page is merged as a whole, the only way one position can reach you twice is if it sits exactly on a page boundary. Two ways to be safe, in order of preference:
- Ask for a window that fits in one response. If
meta.nextCursorisnull, everything for that window came back in one page and the guarantee covers the whole window — nothing to merge. If it is notnull, halve the window and ask again. - Merge across pages yourself with the same rule as the table above (add the amounts,
size-weight the two prices, keep the earliest
cTimeand the latestuTime) keyed onpositionId. This is required if you walk pages, and it is safe to run unconditionally: on rows that are already one per position it changes nothing.
Paging with merge=false. None of the above applies: there is no guarantee to preserve, because
nothing was grouped in the first place — you are reading the exchange's rows. The only thing a
cursor walk has to watch for is a page boundary replaying a row it already gave you. Drop a row
that repeats an earlier one exactly — same positionId, uTime, closeAvgPrice,
closeTotalPos and netProfit — and keep everything else. Two genuine partial closes of one
position differ in at least one of those, so nothing real is lost.
Worked example — realised result over a period, with a read-only key.
Uses the jade() helper from the Python client above, with a key whose only
scope is read.
import time, urllib.parse
from decimal import Decimal
DAY = 86_400_000
AMOUNTS = ("pnl", "netProfit", "totalFunding", "openFee", "closeFee", "openTotalPos", "closeTotalPos")
def merge(a, b):
"""The same rule the API applies inside one response."""
out = dict(a)
for field in AMOUNTS:
out[field] = str(Decimal(a[field] or 0) + Decimal(b[field] or 0))
for price, size in (("openAvgPrice", "openTotalPos"), ("closeAvgPrice", "closeTotalPos")):
total = Decimal(a[size] or 0) + Decimal(b[size] or 0)
if total:
out[price] = str((Decimal(a[price] or 0) * Decimal(a[size] or 0)
+ Decimal(b[price] or 0) * Decimal(b[size] or 0)) / total)
out["cTime"] = min(a["cTime"], b["cTime"], key=int)
out["uTime"] = max(a["uTime"], b["uTime"], key=int)
out["feesTotal"] = str(Decimal(out["openFee"] or 0) + Decimal(out["closeFee"] or 0))
return out
def closed_positions(since_ms, until_ms):
"""Every closed position whose window overlaps [since, until), one entry per position."""
positions, window_start = {}, since_ms
while window_start < until_ms:
window_end = min(window_start + 30 * DAY, until_ms) # 30-day limit per request
cursor = None
while True:
query = {"startTime": window_start, "endTime": window_end}
if cursor:
query["cursor"] = cursor
body = jade("GET", "/api/v1/positions/history?" + urllib.parse.urlencode(query))
for row in body["data"]:
key = row["positionId"] or f'{row["symbol"]}|{row["holdSide"]}|{row["cTime"]}'
# Rows inside one response are already merged; this only ever fires on a page boundary.
positions[key] = merge(positions[key], row) if key in positions else row
cursor = body["meta"]["nextCursor"]
if not cursor:
break
window_start = window_end
return list(positions.values())
def realised(rows):
"""Decimal, never float: these are money."""
return sum((Decimal(r["pnl"]) + Decimal(r["feesTotal"]) + Decimal(r["totalFunding"]) for r in rows), Decimal(0))
now = int(time.time() * 1000)
rows = closed_positions(now - 90 * DAY, now) # the full 90 days that exist
print(len(rows), "positions,", realised(rows), "USDT realised")
A read key is enough for all of this: no trading scope is involved anywhere, and a key without
read is answered 403.
GET /api/v1/pnl/summary?period=24h|7d|30d&symbol= — R
What the period actually realised, added up from the closed positions above. Every figure is a
sum of the exchange's own numbers (netProfit, the two fees, funding) — nothing is recomputed
from prices. period defaults to 24h; 30d is the longest, because that is how far back closed
positions go. Fees and funding keep the exchange's sign (they arrive negative).
This summary is built from /positions/history, so it covers positions the exchange has fully
closed and nothing else: a partial close of a position that is still open is not counted here. For
those, read /pnl/closes below — the same window, one row per close — or the executions themselves
from /fills (profit per execution).
{
"data": {
"period": "7d",
"startTime": 1789000000000, "endTime": 1789604800000,
"trades": 14, "wins": 9,
"netPnl": "182.44", "fees": "-21.60", "funding": "-1.85",
"best": "96.10", "worst": "-40.00",
"truncated": false,
"days": [
{ "day": "2026-09-22", "trades": 6, "fees": "-9.20", "funding": "-0.80", "netPnl": "120.10" },
{ "day": "2026-09-21", "trades": 8, "fees": "-12.40", "funding": "-1.05", "netPnl": "62.34" }
]
}
}
| Field | Meaning |
|---|---|
trades |
Closed positions in the window, counted once each even if the exchange repeats one across pages |
wins |
Of those, the ones the exchange reported a netProfit above zero for |
netPnl · fees · funding |
Sums of the exchange's per-position figures |
best · worst |
The single best and worst netProfit, or null when nothing closed |
truncated |
true when the window held more closed positions than one request walks — the totals then cover only what was read; narrow the period |
days |
Newest first, keyed by UTC day of the close |
GET /api/v1/pnl/closes?period=24h|7d|30d&symbol= — R
Every close the period contained, one row per closing execution, newest first. period takes
the same three values as /pnl/summary and defaults to 24h; symbol narrows it to one market.
This is what the other two endpoints cannot answer: the realised result of an individual close,
including a partial close of a position that is still open. /positions/history and
/pnl/summary only ever see a position the exchange has fully closed, so an account that reduces a
position seven times and leaves it open has seven realised results that appear in neither. Each of
those results is a row here.
Every field of a row is the exchange's own figure for that one execution, with its sign untouched (a
fee arrives negative and stays negative). profit and fees are a plain sum of the rows listed —
nothing else is worked out: no net of the two, no win rate, and no entry price, because an execution
carries none. A close that executed twice under the same tradeId across a page boundary is counted
once.
{
"data": {
"period": "7d",
"startTime": 1789000000000, "endTime": 1789604800000,
"closes": 3, "wins": 2, "losses": 1,
"profit": "58.15", "fees": "-0.1152",
"truncated": false,
"rows": [
{
"tradeId": "987654323", "symbol": "BTCUSDT", "posSide": "long", "side": "sell",
"baseVolume": "0.001", "price": "64800.0", "fee": "-0.0388", "profit": "44.90",
"cTime": "1789604100000"
},
{
"tradeId": "987654322", "symbol": "ETHUSDT", "posSide": "short", "side": "buy",
"baseVolume": "0.05", "price": "2480.0", "fee": "-0.0380", "profit": "-6.75",
"cTime": "1789511000000"
},
{
"tradeId": "987654321", "symbol": "BTCUSDT", "posSide": "long", "side": "sell",
"baseVolume": "0.0005", "price": "64200.0", "fee": "-0.0384", "profit": "20.00",
"cTime": "1789510423940"
}
]
}
}
| Field | Meaning |
|---|---|
closes |
How many closes are listed. A close is one execution, not necessarily a whole position |
wins · losses |
Of those, the ones the exchange reported a profit above / below zero for. A flat close is neither |
profit · fees |
Plain sums of the profit and fee of the rows listed |
truncated |
true when the window held more executions than one request walks — the rows and totals then cover only what was read; narrow the period |
rows[].posSide · rows[].side |
Which position the execution acted on, and its direction: long+sell and short+buy are the closes. posSide is "" when the exchange does not say |
rows[].fee · rows[].profit |
The execution's own fee and realised result, as the exchange signed them |
rows[].cTime |
When the execution happened, unix ms as a string |
Which of the three to read. /positions/history for whole positions the exchange has closed,
/pnl/summary for what a period realised in total, and this one for the individual closes inside
that period.
GET /api/v1/positions/adl-rank?symbol= — R
Where each of your positions stands in the auto-deleveraging queue, as the exchange reports it.
symbol is an optional filter.
{ "data": [ { "symbol": "BTCUSDT", "holdSide": "long", "marginMode": "crossed", "adlRank": "2" } ] }
POST /api/v1/positions/close — T
{ "symbol": "BTCUSDT", "holdSide": "long", "size": "0.02", "clientOid": "close-000001" }
| Field | Required | Notes |
|---|---|---|
symbol |
yes | |
holdSide |
yes | long | short — the side to close |
size |
no | omit to close the whole side; a size at or above the position closes all of it |
clientOid |
yes | §6 |
Closes at market. A partial close is a market close order in the position's own margin mode.
{ "data": { "orderId": "1234567999", "clientOid": "close-000001" } }
400 when there is no such open position (partial close).
POST /api/v1/leverage — T
{ "symbol": "BTCUSDT", "leverage": 20, "holdSide": "long" }
| Field | Required | Notes |
|---|---|---|
symbol |
yes | |
leverage |
yes | whole number, 1 – the market's maxLever (never above 100) |
holdSide |
no | set one side; omit for both |
The margin mode you chose for the market (below) is applied. The exchange may refuse a change while
orders or positions exist (422 BROKER_REJECTED). Answer: { "data": { "ok": true } }.
POST /api/v1/margin-mode — T
{ "symbol": "BTCUSDT", "marginMode": "isolated" }
Stores your choice for that market (isolated | crossed); it is used for leverage changes. Each
order carries its own marginMode as well. Answer: { "data": { "ok": true } }.
8.4 Orders
The Order object
{
"orderId": "1234567890", "clientOid": "my-bot-000000001",
"symbol": "BTCUSDT", "side": "buy", "tradeSide": "open", "posSide": "long",
"orderType": "limit", "price": "64000.0", "size": "0.0010",
"baseVolume": "0", "priceAvg": "", "status": "new",
"leverage": "5", "marginMode": "crossed", "reduceOnly": "no",
"presetStopSurplusPrice": "70000.0", "presetStopLossPrice": "60000.0",
"fee": "0", "totalProfits": "0",
"cTime": "1789510423940", "uTime": "1789510423940"
}
status is the exchange's: new, partially_filled, filled, cancelled. baseVolume is the
filled size; presetStopSurplusPrice / presetStopLossPrice are the preset take-profit / stop-loss.
GET /api/v1/orders/open?symbol= — R
Every working order (no pagination). symbol optional.
GET /api/v1/orders/history?symbol=&cursor= — R
Order[], newest first, paginated.
GET /api/v1/orders/detail?orderId=… or ?clientOid=… — R
One order, working or finished. Optional symbol acts as a guard: an order on another market answers
404. 404 also when no such order exists.
GET /api/v1/fills?symbol=&cursor=&startTime=&endTime= — R
Your executions, newest first, paginated — and the realised result of an individual close.
{
"data": [
{
"tradeId": "987654321", "orderId": "1234567890", "symbol": "BTCUSDT",
"side": "buy", "tradeSide": "", "posSide": "long", "price": "64000.0",
"baseVolume": "0.001", "quoteVolume": "64.0", "profit": "0",
"fee": "-0.0384", "feeCoin": "USDT", "cTime": "1789510423940"
}
],
"meta": { "nextCursor": null }
}
Where each realised figure lives. /positions/history lists positions the exchange has fully
closed; a position that is still open is not in it, however many times it has been reduced. The
realised result of one close — a partial close of a position that is still open included — is on the
execution that produced it: profit on the fill below. Read the closes of a period from here — or
from /pnl/closes, which walks this endpoint for you and keeps the closing executions; read whole
positions from /positions/history, and their per-period totals from /pnl/summary.
Fields.
| Field | Meaning and sign |
|---|---|
tradeId |
The exchange's id for this execution. Unique — use it to deduplicate across page boundaries. |
orderId |
The order this execution belongs to. |
symbol |
Market. |
side |
buy or sell — the direction of this execution. |
tradeSide |
open (started or added to a position) or close (reduced one). Usually empty on a fill — it is a field of the order you sent, not of the execution; see below. |
posSide |
long or short — the position this execution acted on. With side it is what tells an open from a close. |
price |
The price this execution filled at. |
baseVolume |
Quantity filled, in contracts. |
quoteVolume |
Value filled, in the quote coin. |
profit |
The exchange's realised result for this execution. 0 on an opening fill; on a closing fill it is what that close realised. Positive = gain, negative = loss. |
fee |
Fee for this execution. Negative — it is a charge. |
feeCoin |
The coin the fee was charged in. |
cTime |
When the execution happened, unix ms as a string. |
Every field is the exchange's own number, carried through unchanged. Nothing here is computed by us.
Telling a close from an open. Read posSide with side: selling a long or buying back a
short closed something, the other two combinations opened or added. That pair is on every
execution, which tradeSide is not — tradeSide belongs to the order you sent and usually comes
back empty here, so use it only when posSide is missing. If both are empty, profit decides: an
opening fill realises nothing, so anything other than zero is a close.
posSide is also which position the execution closed. It is a label read off the execution, not a
figure — a fill carries no entry price and no funding, so neither can be derived from it.
Filters and paging.
| Query | Notes |
|---|---|
symbol |
optional, one market |
startTime, endTime |
optional, unix milliseconds as digits |
cursor |
optional, from meta.nextCursor |
The window rules are exactly those of /positions/history:
endTimemust be later thanstartTime(400).- One request may span at most 30 days; a longer range is refused with
400rather than silently shortened — split the period and add the results up. AstartTimeon its own is measured against now. - Only the last 90 days exist upstream; an older
startTimeis refused with400. - With no
startTime/endTimeyou get the exchange's own default window. meta.nextCursorisnullwhen there is nothing more; otherwise pass it back as?cursor=.
A cursor can repeat a row at a page boundary, so deduplicate on tradeId before summing a window.
POST /api/v1/orders — T
{
"symbol": "BTCUSDT",
"side": "buy",
"tradeSide": "open",
"orderType": "limit",
"price": "64000.0",
"size": "0.001",
"force": "gtc",
"marginMode": "crossed",
"tpPrice": "70000.0",
"tpTriggerBy": "last",
"slPrice": "60000.0",
"slTriggerBy": "mark",
"slOrderType": "limit",
"slLimitPrice": "59900.0",
"clientOid": "my-bot-000000001"
}
| Field | Required | Notes |
|---|---|---|
symbol |
yes | a listed market |
side |
yes | buy | sell |
tradeSide |
yes | open | close — see §4 Hedge mode |
orderType |
yes | market | limit |
size |
yes | base-coin quantity |
price |
limit only | a market order must not carry one |
force |
no | limit orders: gtc (default) | ioc | fok | post_only |
marginMode |
yes | isolated | crossed |
holdSide |
no | cross-check only (long | short) |
tpPrice, slPrice |
no | preset take-profit / stop-loss trigger prices |
tpTriggerBy, slTriggerBy |
no | last (default) | mark |
tpOrderType, slOrderType |
no | market (default) | limit |
tpLimitPrice, slLimitPrice |
with a limit TP/SL |
the limit price used once triggered |
clientOid |
yes | §6 |
A TP/SL detail field needs its price (tpTriggerBy needs tpPrice, …). When both prices are given,
a long needs tpPrice > slPrice and a short tpPrice < slPrice. Some markets (tokenised stocks)
accept only market TP/SL execution; the exchange refuses a limit one there with 422.
{ "data": { "orderId": "1234567890", "clientOid": "my-bot-000000001" } }
Order types at a glance:
| Want | Send |
|---|---|
| Market | orderType: market |
| Limit, good till cancelled | orderType: limit, force: gtc |
| Limit, maker only | orderType: limit, force: post_only |
| Immediate-or-cancel / fill-or-kill | orderType: limit, force: ioc / fok |
| Order with take-profit / stop-loss | any of the above + tpPrice / slPrice (+ details) |
| TP/SL on an existing position | POST /tpsl |
POST /api/v1/orders/batch — T
Up to 20 orders, each exactly like POST /orders (same fields, same rules, TP/SL included).
{ "orders": [
{ "symbol": "BTCUSDT", "side": "buy", "tradeSide": "open", "orderType": "limit", "price": "63000.0", "size": "0.001", "marginMode": "crossed", "clientOid": "grid-1" },
{ "symbol": "BTCUSDT", "side": "buy", "tradeSide": "open", "orderType": "limit", "price": "62000.0", "size": "0.001", "marginMode": "crossed", "clientOid": "grid-2" }
] }
- Every line is validated first. If any line is invalid, or two lines share a
clientOid, nothing is sent and the answer is400naming the line (orders[1]: …). - Otherwise each line is placed on its own, with its own idempotency, and reported in order.
One line failing does not stop the others. The HTTP status is
200whenever the batch was processed.
{
"data": [
{ "index": 0, "ok": true, "orderId": "1234567001", "clientOid": "grid-1" },
{ "index": 1, "ok": false, "clientOid": "grid-2",
"error": { "code": "BROKER_REJECTED", "message": "The order size is greater than the max open size",
"details": { "venueCode": "40762", "venueMessage": "The order size is greater than the max open size" } } }
]
}
Counts as one request per line against your rate limit.
POST /api/v1/orders/modify — T
Changes a working (not fully filled) order in place.
{ "symbol": "BTCUSDT", "orderId": "1234567890", "price": "63500.0", "size": "0.002", "slPrice": "61000.0", "slTriggerBy": "mark" }
| Field | Required | Notes |
|---|---|---|
symbol |
yes | the order's market |
orderId or clientOid |
one | orderId wins if both are sent |
price |
– | new limit price |
size |
– | new size; a partly filled order cannot go below its filled size |
tpPrice, slPrice + details |
– | new preset TP/SL, same fields as on POST /orders |
autoCancel |
no | true: cancel the order if the modification fails (default false: keep it) |
At least one of price, size, tpPrice, slPrice is required. Only one modification of the same
order can be in flight at a time. Optional Idempotency-Key header (§6).
{ "data": { "orderId": "1234567890", "clientOid": "my-bot-000000001" } }
POST /api/v1/orders/cancel — T
{ "symbol": "BTCUSDT", "orderId": "1234567890" }
orderId or clientOid is required. Answer: { "data": { "orderId": "…", "clientOid": "…" } }.
POST /api/v1/orders/cancel-batch — T
Up to 20 orders, each with one id (orderId is used when both are given). Each order may appear once.
{ "orders": [ { "symbol": "BTCUSDT", "orderId": "1234567001" }, { "symbol": "ETHUSDT", "clientOid": "eth-7" } ] }
{
"data": [
{ "orderId": "1234567001", "clientOid": "", "status": "cancelled" },
{ "orderId": "", "clientOid": "eth-7", "status": "failed", "code": "22001", "message": "The order does not exist" }
]
}
status: cancelled, failed (with the exchange's code and message), or unknown (the
exchange's answer did not mention that order — check it with /orders/detail). Counts as one
request per line against your rate limit.
POST /api/v1/orders/cancel-all — T
{ "symbol": "BTCUSDT" }
Cancels every working order of symbol. Send {} to cancel every working order on every market.
TP/SL orders are managed separately: list them with /tpsl/open and cancel them with /tpsl/cancel.
{ "data": { "symbol": "BTCUSDT", "cancelled": [ { "orderId": "1234567001", "clientOid": "grid-1" } ], "failed": [] } }
POST /api/v1/orders/countdown-cancel-all — T
A dead-man switch: unless you call it again within countdown seconds, every working order on the
account is cancelled. Call it every few seconds from your bot's main loop; if the bot dies or loses
its connection, its orders do not stay behind.
{ "countdown": 10 }
countdown: whole seconds from 5 to 60;0switches the switch off.- Each call restarts the countdown. After it fires, the switch is off until you call it again.
- Suggested use:
countdown: 10, called every 3–5 seconds. - The exchange enables this feature per account. If it is not enabled for yours, the call answers
422 BROKER_REJECTED; contact support.
{ "data": { "countdown": 10 } }
8.5 Take-profit / stop-loss
The TpslOrder object
The object returned by GET /tpsl/open and GET /tpsl/history. Every value is the exchange's own,
reported as a string; nothing here is computed by us, and nothing is filled in from what you sent.
{
"orderId": "1486050096785629235", "clientOid": "tp-000001",
"symbol": "ETHUSDT", "holdSide": "long", "tpslMode": "", "size": "0.01",
"tpPrice": "2908.54", "slPrice": "0",
"tpTriggerBy": "last", "slTriggerBy": "last",
"tpOrderType": "market", "slOrderType": "",
"tpLimitPrice": "0", "slLimitPrice": "0",
"status": "pending", "cTime": "1789510423940", "uTime": "1789510423940"
}
That is a real answer: a take-profit on the whole position, with no stop-loss. Note what it
does not tell you — tpslMode is empty, slTriggerBy says last for a stop-loss that does not
exist, and the absent prices are "0" rather than empty. Read the table before parsing it.
| Field | Meaning |
|---|---|
orderId |
The exchange's id for this TP/SL. This is what identifies it to POST /tpsl/cancel and POST /tpsl/modify. Always set. |
clientOid |
The id you sent on POST /tpsl. It identifies the order too, wherever orderId does — send one or the other, not both. Empty string when the TP/SL was not created with one (an order's preset TP/SL, or one set in the web app). |
symbol |
Its market, e.g. BTCUSDT. |
holdSide |
long | short — the side of the position it protects and will close. |
tpslMode |
full (the whole position; follows its size) or partial (a fixed size) — and often empty, because the exchange accepts the mode when the order is placed but does not report it back. It decides whether a new POST /tpsl replaces this one (see below), so when it is empty, remember the mode you sent against your own clientOid; you cannot recover it from this answer. |
size |
Base-coin quantity. Filled for a full TP/SL too — there it is the whole position's quantity at the time, not a size of its own, and it moves as the position does. Size therefore does not tell full and partial apart. |
tpPrice |
Take-profit trigger price. "" or "0" when there is no take-profit. |
slPrice |
Stop-loss trigger price. "" or "0" when there is no stop-loss. |
tpTriggerBy |
Which price the take-profit watches: last | mark. Empty when the exchange did not say (treat as last). |
slTriggerBy |
The same for the stop-loss — but it is reported even when there is no stop-loss at all. It is a default, not evidence of a leg. |
tpOrderType |
How the take-profit executes once triggered: market | limit. Empty means market. |
slOrderType |
The same for the stop-loss. |
tpLimitPrice |
The limit price used when tpOrderType is limit; "" or "0" otherwise. |
slLimitPrice |
The same for the stop-loss. |
status |
The exchange's own word for the state, passed through unchanged. A working order is reported as pending; finished ones carry the exchange's history words (such as executed, cancelled, fail_execute). The list is not exhaustive — everything in /tpsl/open is working whatever the word says, so filter by endpoint, not by matching a status string. |
cTime |
When it was created: milliseconds since the epoch, as a string. |
uTime |
When it last changed, same format. |
Which legs actually exist. tpPrice and slPrice are the only fields that answer that: a leg
is set when its price is neither "" nor "0". The trigger and execution fields next to them may
carry defaults for a leg that was never placed, so they prove nothing on their own.
Telling full from partial. The mode is what decides whether your next POST /tpsl replaces
this order or adds to it, and the response may not carry it. Neither size (filled for both) nor
anything else in the object recovers it. Keep the mode you sent alongside the clientOid you sent
it with, and treat an empty tpslMode as "unknown", never as partial.
Which id to send back. POST /tpsl/cancel and POST /tpsl/modify take orderId, or
clientOid when you set one on POST /tpsl — exactly one of the two. A TP/SL whose clientOid
comes back empty can only be addressed by its orderId, so store the orderId from the
POST /tpsl answer and you are never stuck.
GET /api/v1/tpsl/open?symbol= — R
Working TP/SL orders — the ones that have not triggered, been cancelled or expired. symbol is
optional and filters the list. No pagination: everything working comes back in one answer.
{
"data": [
{
"orderId": "1311220001", "clientOid": "tp-000001",
"symbol": "BTCUSDT", "holdSide": "long", "tpslMode": "", "size": "0.0500",
"tpPrice": "70000.0", "slPrice": "60000.0",
"tpTriggerBy": "last", "slTriggerBy": "mark",
"tpOrderType": "market", "slOrderType": "market",
"tpLimitPrice": "0", "slLimitPrice": "0",
"status": "pending", "cTime": "1789510423940", "uTime": "1789513880117"
},
{
"orderId": "1311220044", "clientOid": "scale-out-1",
"symbol": "BTCUSDT", "holdSide": "long", "tpslMode": "", "size": "0.0050",
"tpPrice": "72000.0", "slPrice": "0",
"tpTriggerBy": "last", "slTriggerBy": "last",
"tpOrderType": "limit", "slOrderType": "",
"tpLimitPrice": "71950.0", "slLimitPrice": "0",
"status": "pending", "cTime": "1789511002418", "uTime": "1789511002418"
},
{
"orderId": "1311220090", "clientOid": "",
"symbol": "ETHUSDT", "holdSide": "short", "tpslMode": "", "size": "1.20",
"tpPrice": "0", "slPrice": "2650.00",
"tpTriggerBy": "", "slTriggerBy": "mark",
"tpOrderType": "", "slOrderType": "market",
"tpLimitPrice": "0", "slLimitPrice": "0",
"status": "pending", "cTime": "1789509111003", "uTime": "1789509111003"
}
]
}
The first two are a whole-position TP/SL and a partial one side by side on the same position: a
stop plus take-profit on all 0.05 BTC, and a partial take-profit that closes 0.005 BTC with a limit
order. Nothing in the answer says which is which — tpslMode is empty on both and both carry a
size (the first one's is simply the position's own quantity right now). Only the mode you sent
with scale-out-1 and tp-000001 tells them apart, which is why you should keep it. The third row
has no clientOid because it came from an order's preset, and no take-profit at all (tpPrice is
"0") even though tpOrderType is present.
GET /api/v1/tpsl/history?symbol=&cursor= — R
Triggered, cancelled and expired TP/SL orders, newest first, paginated. symbol filters the page.
{
"data": [
{
"orderId": "1311219880", "clientOid": "tp-000000",
"symbol": "BTCUSDT", "holdSide": "long", "tpslMode": "", "size": "0.0500",
"tpPrice": "69000.0", "slPrice": "60000.0",
"tpTriggerBy": "last", "slTriggerBy": "mark",
"tpOrderType": "market", "slOrderType": "market",
"tpLimitPrice": "0", "slLimitPrice": "0",
"status": "cancelled", "cTime": "1789508110220", "uTime": "1789510423940"
},
{
"orderId": "1311214402", "clientOid": "scale-out-0",
"symbol": "BTCUSDT", "holdSide": "long", "tpslMode": "", "size": "0.0030",
"tpPrice": "68500.0", "slPrice": "0",
"tpTriggerBy": "last", "slTriggerBy": "last",
"tpOrderType": "market", "slOrderType": "",
"tpLimitPrice": "0", "slLimitPrice": "0",
"status": "executed", "cTime": "1789501004881", "uTime": "1789507740119"
}
],
"meta": { "nextCursor": "1311214402" }
}
The first row is the whole-position TP/SL that the newer one above replaced — a replacement leaves
the old order here with the exchange's own cancelled word. As in /tpsl/open, tpslMode is empty
and the status words are the exchange's, so match the rows by clientOid or orderId rather than
by mode or status. Pass meta.nextCursor back as ?cursor= for the next page; null means there
is nothing older.
POST /api/v1/tpsl — T
Attaches a take-profit and/or stop-loss to one side of a position.
{
"symbol": "BTCUSDT",
"holdSide": "long",
"tpslMode": "partial",
"size": "0.005",
"tpPrice": "70000.0",
"slPrice": "60000.0",
"slTriggerBy": "mark",
"slOrderType": "limit",
"slLimitPrice": "59900.0",
"clientOid": "tp-000001"
}
| Field | Required | Notes |
|---|---|---|
symbol |
yes | |
holdSide |
yes | long | short — the position it protects |
tpslMode |
no | full (default): the whole position, follows its size. partial: a fixed size |
size |
partial only |
refused for full |
tpPrice / slPrice |
at least one | trigger prices |
tpTriggerBy / slTriggerBy |
no | last (default) | mark |
tpOrderType / slOrderType |
no | market (default) | limit |
tpLimitPrice / slLimitPrice |
with limit |
execution price once triggered |
clientOid |
yes | §6 |
{ "data": { "orderId": "1311220001", "clientOid": "tp-000001" } }
A whole-position TP/SL replaces the one before it. If you send tpslMode: "full" (the default)
for a symbol and holdSide that already has a working full TP/SL, the old one is cancelled
first and the new one placed after, so that side never ends up with two whole-position TP/SLs and
they are never both live at once. This is what a bot that adds to a position and re-sets its
take-profit each time wants: one call, one working order, the latest prices. The replaced order
stays visible in GET /tpsl/history with the exchange's cancelled status.
- The mode is remembered here, not there. The exchange does not report
tpslModeback, so we keep our own note of the mode eachPOST /tpslwas placed with and use it to recognise the order being replaced. The note is internal: it never changes whatGET /tpsl/openanswers, and a TP/SL we did not place (an order's preset, or one from before this note existed) is never cancelled by this rule, because nothing identifies it as whole-position. Cancel those yourself. partialTP/SLs accumulate. They are never cancelled by this rule, and nothing they do cancels afullone: a partial TP/SL covers a fixedsize, so several of them are a ladder, and that is exactly whatpartialis for. Sending the same ladder twice gives you the ladder twice — reuse theclientOidper rung, or cancel what you no longer want.POST /tpsl/modifychanges an existing one in place, without replacing it: theorderIdstays the same and nothing is cancelled. Prefer it when you already hold the id and only the prices move; usePOST /tpslagain when you would rather not track the id at all.- The replacement is one request from your side but two at the exchange, so it costs a moment. If
the cancel fails, the new TP/SL is not placed and the call answers with the error — your old
protection is still in place. If the cancel succeeds but the placement fails, you are briefly
left with neither and the call answers with that error: retry it with the same
clientOid, which places the order (see §7).
POST /api/v1/tpsl/modify — T
{ "symbol": "BTCUSDT", "orderId": "1311220001", "slPrice": "62500.0" }
| Field | Required | Notes |
|---|---|---|
symbol |
yes | the TP/SL's market (checked) |
orderId or clientOid |
one | |
size |
– | partial TP/SL only |
tpPrice, slPrice + details |
– | same fields as POST /tpsl |
At least one of size, tpPrice, slPrice. 404 when no working TP/SL with that id exists on
that market. Optional Idempotency-Key header. Answer: { "data": { "orderId", "clientOid" } }.
POST /api/v1/tpsl/cancel — T
{ "symbol": "BTCUSDT", "orderId": "1311220001" }
orderId or clientOid is required; symbol is optional and, when given, checked.
Answer: { "data": { "orderId", "clientOid" } }.
8.6 Funding (read only)
A bot can show its owner where to send money, and what has arrived or left. It cannot move money:
- There is no
POST /withdrawals. Withdrawals are created in the web app only, signed by the account's wallet.GET /withdrawalsbelow is history, nothing else. - There is no
POST /deposits. That call exists in the web app only to echo a transfer the app itself just broadcast from your wallet. It changes nothing: an arriving deposit is credited from the exchange's own record, so a deposit you send by hand shows up here on its own.
GET /api/v1/deposit/networks — R
Every network USDT can be sent on, and whether each is open right now. Never fails per network: a
network that is paused or unavailable comes back with depositEnabled: false and a reason.
{
"data": [
{
"id": "arbitrum", "name": "Arbitrum One", "kind": "evm", "chainId": 42161,
"usdt": { "address": "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9", "decimals": 6 },
"gasSymbol": "ETH",
"enabled": true, "depositEnabled": true, "withdrawEnabled": true, "confirmations": 12
},
{
"id": "tron", "name": "TRON", "kind": "tron",
"usdt": { "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "decimals": 6 },
"gasSymbol": "TRX",
"enabled": false, "depositEnabled": false, "withdrawEnabled": false, "confirmations": null,
"reason": "Deposits and withdrawals of USDT on TRON are paused"
}
]
}
enabled is an alias of depositEnabled. id is what GET /deposit/address?network= takes.
GET /api/v1/deposit/address?network=arbitrum — R
Your own deposit address for one network. It is fetched once and never changes, so cache it.
{ "data": { "network": "arbitrum", "coin": "USDT", "address": "0x9c1d…", "tag": null } }
| Query | Required | Notes |
|---|---|---|
network |
yes | one id from /deposit/networks |
- Send only USDT, only on that network. Anything else is lost.
tagisnullon every network we support today; if it is ever set, the transfer needs it.503 BROKER_PENDINGwhile deposits are not open,409 ACCOUNT_NOT_READYwhile the trading account is still being prepared,409 CONFLICTwhen that network's deposits are paused.
GET /api/v1/deposits?cursor= — R
Your deposits, newest first, 50 per page.
{
"data": [
{
"id": "500128", "network": "arbitrum", "coin": "USDT", "amount": "250.000000000000000000",
"txHash": "0x7b…", "status": "credited",
"createdAt": "2026-09-14T08:21:03.114Z", "creditedAt": "2026-09-14T08:24:41.902Z"
}
],
"meta": { "nextCursor": null }
}
status: submitted (seen, not credited yet) → credited. creditedAt is null until it lands.
GET /api/v1/withdrawals?cursor= — R
Your withdrawals, newest first, 50 per page. History only — this API cannot create one.
{
"data": [
{
"id": "600041", "network": "arbitrum", "coin": "USDT",
"address": "0x4a…", "amount": "500.000000000000000000",
"fee": "0.150000000000000000", "receive": "499.85",
"status": "success", "txHash": "0x2e…", "failureReason": null,
"createdAt": "2026-09-15T17:02:55.401Z"
}
],
"meta": { "nextCursor": null }
}
status: requested → submitted → pending → success or failed. receive is amount − fee
— the only derived number on this object, and null until the fee is known.
8.7 Affiliate (read only)
Your referral programme, exactly as the Refer screen shows it. Claiming is not here: a claim moves money, so it stays a deliberate action in the web app.
GET /api/v1/affiliate — R
{
"data": {
"referralCode": "K7MQ2XRD", "referredCount": 14, "referredVolume": "8412300.55",
"earned": "1204.882000000000000000", "claimable": "980.140000000000000000",
"accruing": "64.742000000000000000", "claimed": "160.000000000000000000",
"pending": "0", "share": "0.2", "coin": "USDT"
}
}
| Field | Meaning |
|---|---|
earned |
Everything ever accrued, final or not. |
claimable |
Final and not yet claimed — what a claim in the web app would pay. |
accruing |
Accrued but still inside the re-check window, so not claimable yet. |
claimed |
Already claimed. pending is claimed but not paid out yet. |
share |
Your share of the commission, as a decimal string. |
Every figure is a sum of rows and is 0 while nothing has accrued — never an error.
GET /api/v1/affiliate/referrals?cursor=&limit= — R
Users you referred, newest first. limit 1–100 (default 50).
{
"data": [{ "address": "0x5f3c…f6a5", "joinedAt": "2026-09-01T10:12:44.003Z", "volume": "412300.10", "earned": "88.420000000000000000" }],
"meta": { "nextCursor": "4192" }
}
Referred addresses are always masked (0x1234…abcd); the full address is never returned.
GET /api/v1/affiliate/accruals?cursor=&limit= — R
One row per accrual, never a running total. limit 1–100 (default 50).
{
"data": [
{
"id": "9001", "referredAddress": "0x5f3c…f6a5", "symbol": "BTCUSDT",
"periodDay": "2026-09-15", "kind": "accrual",
"commission": "44.210000000000000000", "share": "0.2", "amount": "8.842000000000000000",
"createdAt": "2026-09-16T07:03:11.552Z", "claimedAt": null, "claimStatus": null
}
],
"meta": { "nextCursor": "8990" }
}
kind is accrual, or adjustment when a day was later corrected. periodDay is the UTC
settlement day the commission belongs to. claimedAt / claimStatus are set once a claim covers it.
9. Take-profit / stop-loss semantics
- Two ways to get one. (a) Preset:
tpPrice/slPriceon an order (POST /orders,/orders/batch,/orders/modify). It takes effect for the position once the order fills. (b) Position TP/SL:POST /tpslon a position you already hold. - Trigger. A take-profit triggers when the watched price reaches
tpPricein your favour, a stop-loss when it reachesslPriceagainst you.lastwatches the last traded price,markthe mark price (steadier; the price liquidation is based on). - Execution.
market(default) closes at market once triggered.limitplaces a close order attpLimitPrice/slLimitPrice, which may not fill if the market moves past it. - Size. A
fullTP/SL covers whatever the side holds when it triggers — add to the position and it still closes all of it. Apartialone closes exactly itssize. Both report asizeinGET /tpsl/open; on afullone it is the position's quantity at that moment, so it is not a way to tell the two apart. - A second
fullTP/SL replaces the first. OnePOST /tpslwithtpslMode: "full"(the default) for a symbol and side cancels the workingfullTP/SL that side already had, then places the new one — a side never carries two of them, and re-sending your stop as the position grows does not pile orders up.partialTP/SLs accumulate instead: each one is its own rung and none of them is ever cancelled for you.POST /tpsl/modifyis the third option — it changes an existing TP/SL in place, keeping itsorderId, and cancels nothing. - The mode is yours to remember. The exchange does not report
tpslModeback, so it usually comes back empty and only the mode you sent says whether the nextPOST /tpslwill replace this order or add to it. Keep it next to yourclientOid. Replacement works regardless: we keep our own note of what we placed for that purpose alone (it never alters an answer), and a TP/SL that did not come fromPOST /tpslis left for you to cancel. - Always a close. A TP/SL only ever closes the side it belongs to (
holdSide); it never opens or flips a position. - Direction check. When both prices are given, a long needs
tpPrice > slPriceand a shorttpPrice < slPrice. Whether a price is on the right side of the current price is checked by the exchange (422if not). - Moving a stop.
POST /tpsl/modifywith the newslPrice. There is no trailing stop yet. - Where to see them.
GET /tpsl/open— that endpoint, and only it, is the source of truth for working TP/SL orders. A position'stakeProfit/stopLossfields carry only the preset that was attached when the position was opened: a TP/SL placed withPOST /tpslnever appears there, and those fields stay"0"while it is working. An order'spresetStopSurplusPrice/presetStopLossPriceshow that order's own preset.
10. WebSocket
wss://api.jadeperp.com/ws (ws://localhost:4000/ws on a local instance) — JSON text frames.
Public channels need no login; private ones need an API-key login with the read scope.
const ws = new WebSocket("wss://api.jadeperp.com/ws");
→ {"op":"auth","apiKey":"jp_8Xk2mQn4PvR7sT1wZc0y","timestamp":"1789510423940","signature":"<hex>"}
← {"type":"auth","ok":true}
→ {"op":"subscribe","channels":["positions","orders","fills","account","ticker:BTCUSDT"]}
← {"type":"subscribed","channel":"positions"}
← {"type":"snapshot","channel":"positions","data":[ /* Position[] */ ],"ts":1789510424001}
→ {"op":"ping"}
← {"type":"pong"}
Login signature: hex HMAC-SHA256(secret, timestamp + "GET" + "/ws/auth"), timestamp in
milliseconds, within ±5 s. Failed logins answer {"type":"auth","ok":false} and count towards the
failed-authentication limit (§11).
import { createHmac } from "node:crypto";
const timestamp = Date.now().toString();
const signature = createHmac("sha256", SECRET).update(timestamp + "GET" + "/ws/auth").digest("hex");
ws.send(JSON.stringify({ op: "auth", apiKey: KEY_ID, timestamp, signature }));
timestamp = str(int(time.time() * 1000))
signature = hmac.new(SECRET.encode(), (timestamp + "GET" + "/ws/auth").encode(), hashlib.sha256).hexdigest()
ws.send(json.dumps({"op": "auth", "apiKey": KEY_ID, "timestamp": timestamp, "signature": signature}))
| Channel | Login | Data |
|---|---|---|
tickers |
– | snapshot of every market summary, then update with changed rows (~2 s) |
ticker:<SYMBOL> |
– | snapshot ticker on change |
book:<SYMBOL> / book:<SYMBOL>:<step> |
– | full book snapshot (top 50 per side), ≤ every 150 ms |
trades:<SYMBOL> |
– | snapshot of recent trades, then update with new ones |
candles:<SYMBOL>:<tf> |
– | update with the forming candle and newly closed ones |
account |
yes | account object (as GET /account) |
positions |
yes | full Position[] snapshot on every change |
orders |
yes | Order[] update |
fills |
yes | Fill[] update |
- At most 50 channels per connection; send
pingat least every 60 s or the server closes the socket. - Private data before login answers
{"type":"error","code":"UNAUTHORIZED","channel":…}. - A revoked key (or suspended account) loses its private channels within a minute; a socket that had
private channels open is then closed with code
4001. - While trading is not open yet, private channels answer
{"type":"error","code":"BROKER_PENDING"}and the socket stays open. - Reconnect with backoff, log in again and re-subscribe; after a reconnect, re-read state over REST
(
/positions,/orders/open,/tpsl/open) — updates sent while you were away are not replayed. - TP/SL orders have no stream yet; poll
GET /tpsl/open.
11. Rate limits
- 60 requests per 10 seconds per API key. Batch endpoints count one per line
(a 20-line batch uses 20). Over the limit:
429 RATE_LIMITEDwithdetails: { limit, windowMs, retryAfterMs }— waitretryAfterMs. Only correctly signed requests count, so someone who knows yourkeyIdcannot use up your budget. - Failed authentications: 30 per minute per client IP, 15 per minute per key id from one IP. Past
that, requests from that IP (for that key) answer
429for up to a minute — even correct ones. Fix the cause (clock, secret) instead of retrying in a loop. - A server-wide limit of 600 requests per minute per IP applies to unsigned traffic only. Signed bot requests are counted against their key, not their address, so bots sharing a machine do not share a budget.
- The exchange limits each account too; when it pushes back you get
429 RATE_LIMITEDwith an exchange code indetails. Back off. - Prefer the WebSocket to polling for prices, positions and orders.
12. Errors
| HTTP | code |
Meaning / what to do |
|---|---|---|
| 400 | VALIDATION |
Malformed or out-of-range input; details lists fields for schema errors. Fix it; do not retry unchanged. |
| 401 | UNAUTHORIZED |
Missing headers, or Invalid API credentials (unknown/revoked key, timestamp outside ±5 s, bad signature). |
| 403 | FORBIDDEN |
The key lacks the scope (read / trade), or the account is suspended. |
| 404 | NOT_FOUND |
Unknown market, order or TP/SL, or an endpoint that does not exist. |
| 409 | CONFLICT |
The same request is still being processed / checked. Retry shortly. |
| 409 | ACCOUNT_NOT_READY |
Your trading account is still being prepared. Retry later. |
| 409 | IDEMPOTENCY_CONFLICT |
The clientOid / Idempotency-Key was already used for a different request. |
| 413 | – | Body larger than 16 KB. |
| 415 | – | Unsupported content type; send application/json. |
| 422 | BROKER_REJECTED |
The exchange refused it; details.venueCode / details.venueMessage say why (balance, size, price band, state …). |
| 429 | RATE_LIMITED |
Slow down; see details.retryAfterMs. |
| 502 | BROKER_UNAVAILABLE |
The exchange did not answer or did not confirm. With details.outcome: "unknown" follow §7. |
| 503 | BROKER_PENDING |
Trading is not open yet. Market data works. |
| 500 | INTERNAL |
Our fault. Send the traceId to support. |
Common venueCode values: 40762 order larger than what your balance or tier allows; 40010,
40725, 45001 unknown outcome (§7).
13. Checklist before you go live
- Clock synced (NTP) — a drifting clock looks exactly like a replay.
- Every order has
tradeSide, and you know which side (long/short) it acts on (§4). clientOidgenerated once per intended order and reused on retries; unknown outcomes handled as in §7.- Sizes and prices sent as decimal strings, respecting
volumePlace,minTradeNumand the tick. - Stops in place: a TP/SL for every position, and
countdown-cancel-allif your bot keeps resting orders. - Read-only tools use a
read-only key; secrets live in the environment, never in a repository. Revoke a key immediately if it leaks. - You know there is no sandbox — the first order your bot sends is a real one. Rehearse on a
readkey and go live atminTradeNum(No test environment). BASEpoints athttps://api.jadeperp.comand the stream atwss://api.jadeperp.com/ws(Base URL).