Errors and troubleshooting
The success and error shapes, the full error-code tables for the sandbox and live, with a what-to-do column, and the mistakes to watch for.
Every error, sandbox and live, is a JSON body with a stable, machine-readable error code and a message sentence for a person, plus the matching HTTP status. Some carry more fields (reason, symbol):
{ "error": "bad_symbol", "message": "Unknown market symbol.", "symbol": "DOGE" }Branch on error. Show message. Never parse message: its wording can change.
Success and error are different shapes
Compare them directly, because the difference is what most integration bugs turn on. The two environments also mark a successful write differently:
// 200 on the sandbox: a successful write carries "ok": true{ "ok": true, "fill": { "price": 64000, "repriced": false, "ageMs": 120 }, "position": { "…": "…" } } // 200 on live: a successful write carries "success": true, and no "ok" key{ "success": true, "action": "opened", "fill": { "price": 78065.5, "size": 0.001, "fee": 0.0351 }, "position": { "…": "…" } } // 400 in either environment: a rejection. Note what is NOT here.{ "error": "bad_size", "message": "Size must be greater than 0." }
A sandbox success carries "ok": true; a live success carries "success": true. An error body has neither key: not false, absent. Live reads (GET /accounts, /positions and so on) carry neither key either, only their data.
The guard that never fires
This is the most common bug we see in a first integration, and it fails silently in the worst possible place: the code path that handles a rejected order.
# BROKEN. r.get("ok") is None on an error, and None is not False,# so this branch never runs and every rejection returns normally.if r.get("ok") is False: raise RuntimeError(r["error"])
The mirror-image bug is if not r.get("ok") on live, which treats every live success as a failure, because a live success has no ok. Check the HTTP status, or test for the presence of error, which works in both environments:
if not response.ok: # requests: any 4xx/5xx raise RuntimeError(data.get("error"), data.get("message"))if "error" in data: # belt and braces raise RuntimeError(data["error"])
The same trap has a second form: if the body does not parse as JSON at all, you are not talking to the API. A proxy, a captive portal, or an edge security challenge answered instead. Treat a JSONDecodeError as a transport failure and surface it, rather than letting it surface later as a KeyError with no error text. The reusable client handles both.
Codes in front of every route
Both environments check the key before the route runs:
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
missing_api_key | 401 | No key was sent. | Add the X-API-Key header. If it vanished, you may be following the /api/v1 redirect: call /v1 directly. |
invalid_api_key | 401 | Malformed or unknown key. | Recheck the key. Recreate it in Settings if needed. |
revoked_api_key | 401 | The key was revoked. | Create a fresh key. |
wrong_environment | 403 | A real key whose environment does not match the route. | Use a pk_test_ key on /sandbox/* and a pk_live_ key on the live routes. See the note below: a mistyped key gives invalid_api_key, not this. |
api_access_denied | 403 | The account is not in the API beta, or it has been suspended, banned or deleted. | Request access on the developer page. If you had access, it has been withdrawn. |
rate_limited | 429 | A rate limit was hit: the per-key limit, the per-IP failed-auth throttle, or (live orders) the per-user trade limit. | Back off for Retry-After seconds, then retry. |
Sandbox codes
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
invalid_json | 400 | The body was not valid JSON. | Send a valid JSON body and Content-Type: application/json. |
bad_symbol | 400 | Symbol missing or not tradeable. | Use a symbol from /sandbox/markets. |
bad_side | 400 | side was not long or short. | Send "long" or "short". |
bad_size | 400 | size was not greater than 0. | Send a positive base-unit size. |
bad_limit_price | 400 | A limit order needs limitPrice greater than 0. | Include a positive limitPrice. |
bad_pct | 400 | pct was outside 1-100. | Send a percent between 1 and 100. |
account_locked | 403 | The sandbox account is locked after a breach, or is not active. | Call /sandbox/reset to start a fresh account. |
asset_disabled | 403 | The market is not currently tradeable. | Pick another market from /sandbox/markets. |
MIN_NOTIONAL, PER_POSITION_CAP, TOTAL_MARGIN_CAP, ASSET_NOTIONAL_CAP, NO_EQUITY | 400 | A risk cap refused the order. The code is the specific cap; message says which limit and by how much. ASSET_NOTIONAL_CAP is the per-market position cap, resolved exactly as on live. | Lower size or leverage. |
cap_exceeded | 400 | Fallback for a cap refusal that carried no specific code. Rare. | As above. Branch on the specific codes first. |
order_not_marketable | 409 | A limit order was not marketable at the current mark. The sandbox has no resting limits. | Move the limit price to the marketable side, or send a market order. |
GUARD_BAND | 409 | The fill moved beyond tolerance of your expectedPrice. | Re-read the price and retry with an expectedPrice near the current mark. |
STALE_PRICE, NO_PRICE | 409 | The reference price was too old, or missing, at fill time. | Retry shortly. |
fill_rejected | 409 | Fallback for a fill refusal that carried no specific code. | Re-read the price and retry shortly. |
order_rejected | 400 | The order could not be opened. | Check message for specifics. |
no_price | 503 / 404 | A real market the feed did not price on this read. | Skip and re-read; the feed self-heals. A symbol that is not a market at all returns bad_symbol instead, and that one is not worth retrying. |
no_position | 404 | No open position for the symbol. | Read /sandbox/positions before closing or editing. |
invalid_tpsl | 400 | A trigger level is on the wrong side of the position. | Put the take-profit and stop-loss on the correct sides of entry. |
concurrent_request | 409 | Another write to your sandbox was still in flight. | Retry. Your sandbox takes one write at a time; overlapping writes queue briefly and only give up if the queue does not clear. |
Live codes
A live error is the terminal's own refusal, translated: message is the sentence the terminal would show, and error is a code for it. The engine's own codes keep the engine's upper-case spelling.
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
missing_fields | 400 | A required field was missing (for an order: accountId, symbol, side, size, leverage). | Send every required field. |
bad_side, bad_size, bad_limit_price, invalid_json | 400 | As on the sandbox. | Fix the field. |
account_not_found | 404 | The accountId is not one of yours, or does not exist. | List your accounts with GET /accounts. |
account_locked | 403 | The account is failed or expired, or it has passed and the order would add exposure. | A passed account takes closes, TP/SL and reduceOnly orders only. |
ACCOUNT_LOCKED, DAILY_DRAWDOWN_BREACH, GLOBAL_DRAWDOWN_BREACH | 403 | The account's live equity check refused the order: it is locked, or the order would come at or past a loss limit. | Do not retry; read the account. |
asset_disabled | 403 | The market is not currently tradeable. | Pick another market. |
market_paused | 403 | New positions are paused on this market while its price feed is unavailable. Closing still works. | Retry later, or trade another market. |
NOT_MARKETABLE | 400 | A limit sent to POST /orders has not reached its price. | Send it to POST /orders/limit to rest, or send a market order. |
MIN_NOTIONAL, PER_POSITION_CAP, TOTAL_MARGIN_CAP, ASSET_NOTIONAL_CAP, NO_EQUITY | 400 | A risk cap refused the order. ASSET_NOTIONAL_CAP is the per-market position cap. On a Pro order, MIN_NOTIONAL means one clip or rung is below the minimum order value. | Lower size, leverage, or (Pro) the number of clips. |
GUARD_BAND, STALE_PRICE, NO_PRICE | 409 | The price moved beyond your expectedPrice, or the reference price was stale or missing. | Retry at the current price. |
no_price | 503 / 400 | No fresh price could be fetched for the order or the close. | Retry shortly. |
PRICE_UNAVAILABLE, equity_unavailable | 503 | The account's equity could not be verified right now. | Retry shortly. Nothing was placed. |
no_position | 404 / 400 | No open position for the symbol, or a reduceOnly order had nothing to reduce. | Read /positions first. |
invalid_tpsl | 400 | A take-profit or stop-loss is on the wrong side. | Put each level on its correct side. |
run_not_found, order_not_found | 404 | No such Pro run or resting order on that account. | Read GET /accounts/{accountId}/orders. |
size_below_executed | 400 | A TWAP edit asked for less than the run has already filled. | Send a larger totalSize. |
Pro order codes (BAD_DURATION, BAD_ORDER_COUNT, BAD_PRICE, BAD_TRIGGER, TOO_MANY_RUNS, CHASE_EXISTS and others) | 4xx | A Pro order field is out of range, or a run limit was reached. | Read message: it says what to change. |
rate_limited | 429 | You hit the per-user trade limit, which your terminal and all your keys share. | Back off for Retry-After seconds. |
When a refusal has no more specific code, error falls back to one named for the status: bad_request (400), unauthorized (401), forbidden (403), not_found (404), conflict (409), unprocessable (422), internal_error (500) or unavailable (502, 503). Branch on the specific codes above and treat these as the default case.
Idempotency codes (live only)
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
invalid_idempotency_key | 400 | The Idempotency-Key header was empty, too long, or had spaces. | Send 1 to 255 visible ASCII characters, such as a UUID. |
idempotency_key_reused | 422 | That key was already used for a different request. | Use a new key for a new order. |
idempotency_in_flight | 409 | The first request with that key is still running. | Wait a moment, then retry with the same key. |
idempotency_result_unknown | 409 | The first request with that key failed part-way. | Read your positions and orders, then use a new key if the order is not there. |
idempotency_unavailable | 503 | The key could not be checked, so nothing was placed. | Retry with the same key. |
The full behaviour, including which results replay, is on Live trading.
Common mistakes
A wrong key gives invalid_api_key, not wrong_environment
wrong_environment sounds like the error you get for using the wrong kind of key, and it is not. You will almost certainly never see it while integrating.
Authentication resolves the key before it compares environments: it hashes what you sent and looks it up, and an unknown hash is rejected as 401 invalid_api_key without ever reaching the environment check. That ordering is deliberate: it means the API never reveals whether a given key exists. So a key you mistyped, invented, or copied wrong returns 401 invalid_api_key whatever its prefix says:
curl https://api.perpsfund.com/v1/sandbox/account \ -H "X-API-Key: pk_live_000000000000000000000000"# → 401 { "error": "invalid_api_key", "message": "That API key is not valid." } ← not 403 wrong_environment
403 wrong_environment happens only when the key is real and yours and on the other environment's route: a pk_live_ key on /sandbox/*, or a pk_test_ key on a live route. If you are seeing 401, the problem is the key itself, not which environment it belongs to.
bad_symbol on a real ticker
{ "error": "bad_symbol" } almost always means the symbol is not in the tradeable universe, or it is decorated. Send the plain symbol as /sandbox/markets lists it (for example BTC), not a prefixed or suffixed form. When in doubt, read /sandbox/markets and use a symbol from that list verbatim.
A doubled position after a timeout
On live, send an Idempotency-Key header on every write and resend a timed-out request with the same key: the first response comes back and nothing is placed twice (see Live trading). The sandbox does not deduplicate, so a retried POST /sandbox/orders can open a second position there. If a sandbox write times out, call /sandbox/positions first and only resend if the position is not there.
This is a different problem from concurrent_request, which is the sandbox telling you it declined to run a write rather than run it twice. A concurrent_request is safe to retry; a timeout is not.