Bot API

Everything you can do on the site, your bot can do with an API key — market data, orders, TP/SL, positions, leverage. Withdrawals are never possible over the API.

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.

Contents

  1. Quick start
  2. API keys and scopes
  3. Authentication — Node and Python signing clients
  4. Hedge mode — read this before your first order
  5. Conventions — envelope, decimals, precision, pagination
  6. Idempotency and clientOid
  7. Unknown outcomes
  8. Endpoints — market data, account, positions, orders, TP/SL, funding, affiliate
  9. Take-profit / stop-loss semantics
  10. WebSocket
  11. Rate limits
  12. Errors
  13. 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:

  1. Start with a read-only key (§2). It can call every GET endpoint 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.
  2. Then a trade key, at the smallest size the market allows. GET /markets gives you, per market, minTradeNum (the smallest size) and minTradeUSDT (the smallest notional of a limit order); send exactly that, on a market whose minimum is cheap, and watch the order through GET /orders/detail, GET /positions and GET /fills before you scale anything up.
  3. Keep the blast radius small while you learn: low leverage, a stop-loss on every position (§9), and POST /orders/countdown-cancel-all so a bot that dies does not leave resting orders behind.
  4. 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 the trade scope answers 403.

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"
  }
}

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

Rules:

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:

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:

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:

Rules:

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

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:

  1. Ask for a window that fits in one response. If meta.nextCursor is null, everything for that window came back in one page and the guarantee covers the whole window — nothing to merge. If it is not null, halve the window and ask again.
  2. Merge across pages yourself with the same rule as the table above (add the amounts, size-weight the two prices, keep the earliest cTime and the latest uTime) keyed on positionId. 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:

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" }
] }
{
  "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 }
{ "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.

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:

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

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


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

11. Rate limits


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

  1. Clock synced (NTP) — a drifting clock looks exactly like a replay.
  2. Every order has tradeSide, and you know which side (long / short) it acts on (§4).
  3. clientOid generated once per intended order and reused on retries; unknown outcomes handled as in §7.
  4. Sizes and prices sent as decimal strings, respecting volumePlace, minTradeNum and the tick.
  5. Stops in place: a TP/SL for every position, and countdown-cancel-all if your bot keeps resting orders.
  6. Read-only tools use a read-only key; secrets live in the environment, never in a repository. Revoke a key immediately if it leaks.
  7. You know there is no sandbox — the first order your bot sends is a real one. Rehearse on a read key and go live at minTradeNum (No test environment).
  8. BASE points at https://api.jadeperp.com and the stream at wss://api.jadeperp.com/ws (Base URL).