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:
| Status | What the API accepts |
|---|---|
ACTIVE | Everything: trial, paid challenge or tournament entry. A paid challenge is ACTIVE from the moment it is issued. |
PURCHASED | Everything, like ACTIVE. A legacy value: no account has been issued in it since 2026-09-23. |
FUNDED | Everything. |
PASSED | Closes, TP/SL and reduceOnly orders only. New exposure is refused with 403 account_locked ("Account is not active"). |
FAILED | Nothing. 403 account_locked ("Account is locked"). |
EXPIRED | Nothing (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
| Endpoint | Returns |
|---|---|
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}/orders | Resting limit orders (orders) and running Pro runs (runs). |
GET /accounts/{accountId}/runs | Finished Pro runs (history). |
GET /accounts/{accountId}/funding | Funding payments. |
Place an order
/ordersAPI keyA market order, or a marketable limit. It fills at the server's own fresh price.
| Field | Type | Notes |
|---|---|---|
accountId | string | Required. |
symbol | string | Required. |
side | string | Required. long or short. |
size | number | Required. Base units, greater than 0. |
leverage | number | Required. Clamped to the market's cap. |
orderType | string | market (default) or limit. |
limitPrice | number | With limit: a bound, never a price. Fills only if the market has reached it; otherwise 400 NOT_MARKETABLE (use /orders/limit to rest it). |
reduceOnly | boolean | Only shrinks an opposite position; never opens one. |
takeProfitPrice, stopLossPrice | number | Optional. |
expectedPrice | number | Optional slippage guard: 409 GUARD_BAND if the fill moved beyond tolerance of it. |
maxSlippagePct | number | Optional, 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
/orders/limitAPI keyRests 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": "…", … } }.
/orders/cancelAPI key{ "accountId": "…", "orderId": "…" }.
Close, and TP/SL
/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.
/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.
| Endpoint | Extra fields |
|---|---|
POST /orders/conditional | triggerPrice, execKind (stop-market default, or stop-limit with limitPrice) |
POST /orders/ladder | startPrice, endPrice, totalOrders, sizeSkew |
POST /orders/twap | durationMs, randomizeSize, randomizeTime |
POST /orders/chase | chaseTo (optional bound) |
POST /orders/chase-twap | durationMs, chaseTo |
POST /orders/swarm | totalOrders, aggression, slippageBps, irregular |
POST /runs/edit | runId, 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_limitedwith aRetry-Afterheader (noX-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.
| Status | Meaning |
|---|---|
400 | Validation or a risk cap refused the order. Branch on error. |
403 | The account is locked or passed, a loss limit refused the order, or the market is closed to new exposure. |
404 | Not your account, or no such position, order or run. |
409 | The price moved beyond expectedPrice, or the reference price was stale. Retry. Also the in-flight idempotency answers below. |
422 | idempotency_key_reused: see below. |
429 | A rate limit (above). Back off for Retry-After. |
503 | A 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-Keyfor different requests collide with422. - A request that died mid-flight answers
409 idempotency_in_flightfor 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.
| Response | Meaning |
|---|---|
422 idempotency_key_reused | That key was already used for a different request. Use a new key for a new order. |
409 idempotency_in_flight | The first request with that key is still running. Wait a moment and retry with the same key. |
409 idempotency_result_unknown | The first request failed part-way. Read /positions and /orders, then place it again with a new key if it is not there. |
503 idempotency_unavailable | The 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.
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.