Skip to content

API reference

Live trading

Trade your real accounts with a live key - free trial, challenge, tournament and funded - under the terminal's own rules.

A pk_live_ key trades every account you can trade in the terminal: your free trial, a paid challenge, a tournament entry, and a funded account. It does so through the terminal's own order handlers, so the API and the terminal enforce exactly the same rules. There is no separate API engine and no rule that only one of them checks.

These are your real accounts

A live order counts. It moves your challenge toward its target or its loss limits, and on a funded account it is the account your payouts are measured on. Rehearse on the sandbox with a pk_test_ key first.

Which accounts a key can trade

GET /accounts lists every account you own, newest first, including archived ones (archived: true). What each one accepts depends only on its status, exactly as in the terminal:

StatusWhat the API accepts
ACTIVEEverything: trial, paid challenge or tournament entry. A paid challenge is ACTIVE from the moment it is issued.
PURCHASEDEverything, like ACTIVE. A legacy value: no account has been issued in it since 2026-09-23.
FUNDEDEverything.
PASSEDCloses, TP/SL and reduceOnly orders only. New exposure is refused with 403 account_locked ("Account is not active").
FAILEDNothing. 403 account_locked ("Account is locked").
EXPIREDNothing (a tournament account past its end date). 403 account_locked ("Account is locked").

The rest of the rules come with the account, not the key: the daily and overall loss limits, the profit target and the pass check, the leverage caps, the position and per-market caps, the minimum order value, and a tournament's own drawdown limits. An order the terminal would refuse, the API refuses with the same message.

curl https://api.perpsfund.com/v1/accounts \  -H "X-API-Key: $PERPSFUND_LIVE_KEY"
{  "accounts": [    { "id": "6a03fe8e-…", "status": "ACTIVE", "isTrial": false, "isTournament": true, "tournamentName": "…", "challengeSize": 100000, "currentBalance": 100000, "evalType": "1-step", "phase": 1 },    { "id": "b66aff24-…", "status": "FUNDED", "isTrial": false, "isTournament": false, "challengeSize": 50000, "currentBalance": 50000, "evalType": "1-step", "phase": 1 },    { "id": "9d01ef55-…", "status": "ACTIVE", "isTrial": true, "isTournament": false, "challengeSize": 5000, "currentBalance": 5000, "evalType": "1-step", "phase": 1 }  ]}

Each row also carries startingBalance, archived, createdAt, fundedFromAccountId (on a funded account, the challenge it was issued from) and tournamentName (on a tournament entry). evalType is one of: 1-step, gauntlet, hyper, 2-step.

One key reaches every account you own, so every live write names its account with accountId. An id that is not yours answers 404 account_not_found, the same as an id that does not exist.

Reading an account

EndpointReturns
GET /accounts/{accountId}Balance, equity, loss floors, target, phase and status.
GET /positions?accountId=Open positions, with TP/SL.
GET /trades?accountId=Closed trades.
GET /accounts/{accountId}/ordersResting limit orders (orders) and running Pro runs (runs).
GET /accounts/{accountId}/runsFinished Pro runs (history).
GET /accounts/{accountId}/fundingFunding payments.

Place an order

POST/ordersAPI key

A market order, or a marketable limit. It fills at the server's own fresh price.

FieldTypeNotes
accountIdstringRequired.
symbolstringRequired.
sidestringRequired. long or short.
sizenumberRequired. Base units, greater than 0.
leveragenumberRequired. Clamped to the market's cap.
orderTypestringmarket (default) or limit.
limitPricenumberWith limit: a bound, never a price. Fills only if the market has reached it; otherwise 400 NOT_MARKETABLE (use /orders/limit to rest it).
reduceOnlybooleanOnly shrinks an opposite position; never opens one.
takeProfitPrice, stopLossPricenumberOptional.
expectedPricenumberOptional slippage guard: 409 GUARD_BAND if the fill moved beyond tolerance of it.
maxSlippagePctnumberOptional, market orders. Max slippage in percent: default 8, 0.1 to 30, one decimal place. 409 MAX_SLIPPAGE if the estimate from walking the live book for size is above it, 409 INSUFFICIENT_LIQUIDITY if the book cannot fill size, 400 BAD_MAX_SLIPPAGE if the value is invalid. Not applied to a reduceOnly order or a marketable limit.
curl -X POST https://api.perpsfund.com/v1/orders \  -H "X-API-Key: $PERPSFUND_LIVE_KEY" \  -H "Content-Type: application/json" \  -d '{"accountId":"9d01ef55-…","symbol":"BTC","side":"long","size":0.001,"leverage":2}'
{  "success": true,  "action": "opened",  "fill": { "price": 78065.5, "size": 0.001, "fee": 0.0351 },  "position": { "symbol": "BTC", "side": "long", "size": 0.001, "entryPrice": 78065.5, "leverage": 2, "…": "…" },  "newBalance": 5000}

A market order that opens or adds is rejected whole when its estimated slippage is above maxSlippagePct; the codes and the error body are the ones the sandbox returns (see Max slippage). The default applies to every API order that does not name its own max: the max saved in the terminal for an account is the terminal's, and the API does not read it. /positions/close is never limited.

A live success carries "success": true, never "ok". fill is this order's own execution. position is the whole position after it, so after adding to a position its entry is the blended average.

Resting limits

POST/orders/limitAPI key

Rests until the market reaches limitPrice, then fills server-side; your agent does not need to be connected. Same fields as /orders plus limitPrice (required). Returns { "success": true, "order": { "id": "…", … } }.

POST/orders/cancelAPI key

{ "accountId": "…", "orderId": "…" }.

Close, and TP/SL

POST/positions/closeAPI key

{ "accountId": "…", "symbol": "BTC", "closePercent": 100 }. Closes at a fresh price and works on a PASSED account. pnl, fee and newBalance come back as strings with two decimals, and status tells you if the close passed or failed the account.

POST/positions/tpslAPI key

{ "accountId": "…", "symbol": "BTC", "takeProfitPrice": 93685, "stopLossPrice": 62456 }. Pass null to clear a level. A level you change must sit on the correct side of the current price, not the entry: for a long, the stop below the price and the take-profit above it; for a short, the other way round. A stop at the entry or in profit is valid. A level at or past the current price is refused with invalid_tpsl, because it would close the position on the next check, and so is a stop past the take-profit. A level you send unchanged is not judged, and if no price can be read the changed levels are judged against the entry instead.

Pro orders

Every Pro order type the terminal offers. Each returns a runId; a running run shows on GET /accounts/{accountId}/orders, and POST /runs/cancel with { accountId, runId } stops it as a unit.

EndpointExtra fields
POST /orders/conditionaltriggerPrice, execKind (stop-market default, or stop-limit with limitPrice)
POST /orders/ladderstartPrice, endPrice, totalOrders, sizeSkew
POST /orders/twapdurationMs, randomizeSize, randomizeTime
POST /orders/chasechaseTo (optional bound)
POST /orders/chase-twapdurationMs, chaseTo
POST /orders/swarmtotalOrders, aggression, slippageBps, irregular
POST /runs/editrunId, totalSize (a running TWAP)

Each clip or rung has to clear the minimum order value. A TWAP that is too small says so and names the size that would work:

{ "error": "MIN_NOTIONAL", "message": "A 30m TWAP is 60 orders of $4.17 - each one has to be at least $10. Raise the size to about $600, or shorten the duration." }

Rate limits

Two limits apply to a live order, and the tighter one binds:

  • Per key: 600 per minute, on every request.
  • Per user: the terminal's own limit, which your terminal and every key you hold share. 150 per minute for each of market orders, resting limits, closes, cancels, TP/SL changes and run edits or cancels, and 20 per minute for each Pro order type. Over it you get 429 rate_limited with a Retry-After header (no X-RateLimit-* headers on this one).

Errors

Live errors have the same shape as the sandbox's: error is a machine code to branch on, and message is the terminal's own sentence to show a person.

{ "error": "account_locked", "message": "Account is not active" }

Codes shared with the sandbox mean the same thing (account_locked, bad_side, bad_size, no_price, no_position, asset_disabled, invalid_tpsl). The engine's own codes keep the engine's spelling in both environments (MIN_NOTIONAL, GUARD_BAND, the cap codes). The per-market cap (ASSET_NOTIONAL_CAP) is enforced in both, resolved the same way, so a size the sandbox accepts is not refused by that cap on live. Live adds the codes only real accounts can hit, such as DAILY_DRAWDOWN_BREACH, market_paused and run_not_found; the full table is in Errors and troubleshooting. The key checks in front of every route are in Errors and troubleshooting: 401 missing_api_key, 403 wrong_environment (a pk_test_ key on a live route), 403 api_access_denied, 429 rate_limited.

StatusMeaning
400Validation or a risk cap refused the order. Branch on error.
403The account is locked or passed, a loss limit refused the order, or the market is closed to new exposure.
404Not your account, or no such position, order or run.
409The price moved beyond expectedPrice, or the reference price was stale. Retry. Also the in-flight idempotency answers below.
422idempotency_key_reused: see below.
429A rate limit (above). Back off for Retry-After.
503A fresh price or the equity check was unavailable. Transient; retry.

Retries and idempotency

Send an Idempotency-Key header on every live POST, a unique string per order (a UUID is ideal). If the request times out, resend it with the same key: you get the first response back, byte for byte, with Idempotent-Replayed: true, and nothing is placed twice. A result replays for 24 hours.

What an agent needs to know about it:

  • It is optional. Without the header a request runs as normal and nothing protects a retry.
  • It covers every live write: orders, resting limits, cancels, closes, TP/SL, Pro orders, and run edits and cancels. Reads are never deduplicated.
  • Refusals replay too. Any answer below 500, including a refusal such as a cap or a locked account, is stored and replayed for the same key and body, even if conditions have changed since. To try again after a refusal, send a new key.
  • A server error is not stored. If the first attempt came back 5xx (no fresh price, equity could not be checked), the same key runs the request again, so resending with it is correct.
  • Keys belong to you, not to one API key. The scope is your account holder, so rotating API keys between a timeout and the retry is still covered, and two of your agents that pick the same Idempotency-Key for different requests collide with 422.
  • A request that died mid-flight answers 409 idempotency_in_flight for up to 15 minutes, then the key can be claimed again.
  • The key is 1 to 255 visible ASCII characters; anything else is 400 invalid_idempotency_key.
ResponseMeaning
422 idempotency_key_reusedThat key was already used for a different request. Use a new key for a new order.
409 idempotency_in_flightThe first request with that key is still running. Wait a moment and retry with the same key.
409 idempotency_result_unknownThe first request failed part-way. Read /positions and /orders, then place it again with a new key if it is not there.
503 idempotency_unavailableThe key could not be checked, so nothing was placed. Retry with the same key.

The sandbox does not deduplicate. It accepts the header and ignores it.

Give this to your AI
I am building a trading agent on the Perps Fund LIVE API. These are my real accounts.Base URL: https://api.perpsfund.com/v1Auth: header  X-API-Key: pk_live_...   (a pk_test_ key gets 403 wrong_environment here)
List my accounts first and pick one by status:GET /accounts  ->  { accounts: [{ id, status, isTrial, isTournament, challengeSize, currentBalance, ... }] }ACTIVE / FUNDED (and legacy PURCHASED) trade normally. PASSED: closes, TP/SL and reduceOnly only. FAILED / EXPIRED: locked.
Every write carries accountId:POST /orders           { accountId, symbol, side: "long"|"short", size, leverage, orderType?, limitPrice?, reduceOnly?, takeProfitPrice?, stopLossPrice?, expectedPrice? }POST /positions/close  { accountId, symbol, closePercent? }POST /positions/tpsl   { accountId, symbol, takeProfitPrice|null, stopLossPrice|null }POST /orders/limit     { accountId, symbol, side, size, leverage, limitPrice }   then POST /orders/cancel { accountId, orderId }GET  /positions?accountId=...
The account's own rules apply exactly as in the terminal: loss limits, profit target, leverage and position caps (including the per-market cap), $10 minimum order.Success bodies carry "success": true (never "ok"). GET /accounts includes archived accounts (archived: true); evalType is one of 1-step, gauntlet, hyper, 2-step.Errors are { error: "<machine_code>", message: "<sentence>" }. Branch on error, show message.Send header Idempotency-Key: <uuid> on every POST. On a timeout, resend with the SAME key: the first response comes back and nothing is placed twice.A refusal (any status under 500) replays for the same key too, so retry a refused order with a NEW key. 409 idempotency_in_flight can last 15 minutes.Rate limits: 600 per minute per key, plus a per-user limit shared with my own terminal: 150 per minute per order route, 20 per minute per Pro order type. On 429, wait Retry-After.