Skip to content

API reference

Trading

Place orders, close positions, set take-profit and stop-loss, and reset your sandbox.

The four sandbox write endpoints your agent acts through. Every one fills at a fresh mark and returns the resulting position and account. Your real accounts have their own routes; see Live trading.

Place an order

POST/sandbox/ordersAPI key

Open or add to a position. Fills at a fresh single-symbol mark.

FieldTypeNotes
symbolstringRequired. A tradeable market symbol.
sidestringRequired. long or short.
sizenumberRequired. Size in base units, greater than 0.
leveragenumberOptional. Clamped to the firm max; defaults conservatively.
orderTypestringOptional. market (default) or limit. Any other value is placed as a market order, not refused, including a Pro type such as "scale" copied from a live request.
limitPricenumberRequired for a limit order. Must be marketable versus the current mark.
takeProfitPricenumberOptional.
stopLossPricenumberOptional.
expectedPricenumberOptional anti-slippage guard. The order is rejected with 409 GUARD_BAND if the fill price moved beyond tolerance of it.
maxSlippagePctnumberOptional. The max slippage for a market order, in percent. Default 8; 0.1 to 30, to one decimal place. See Max slippage.

Market orders always attempt a fill. A limit order fills only if its price is marketable against the current mark; resting limit orders and Pro orders are not supported in the sandbox. Leverage is re-derived and clamped server-side, so a request cannot raise its own cap.

curl -X POST https://api.perpsfund.com/v1/sandbox/orders \  -H "X-API-Key: $PERPSFUND_API_KEY" \  -H "Content-Type: application/json" \  -d '{"symbol":"BTC","side":"long","size":0.1,"leverage":5}'
{  "ok": true,  "fill": { "price": 64000, "repriced": false, "ageMs": 120 },  "position": {    "symbol": "BTC", "side": "long", "size": 0.1, "leverage": 5,    "entryPrice": 64000, "markPrice": 64000, "notionalValue": 6400,    "marginRequired": 1280, "unrealizedPnL": 0,    "takeProfitPrice": null, "stopLossPrice": null, "openedAt": "2026-07-01T12:00:00.000Z"  },  "account": { "id": "demo", "environment": "sandbox", "balance": 50000, "equity": 50000, "…": "…" }}

Max slippage

Before a market order opens, the server walks Hyperliquid's live order book for the order's size and compares the average fill price with the book's mid price. If that estimate is above maxSlippagePct, the whole order is rejected and nothing is opened. The rule and the codes are the same in the sandbox and live.

StatuserrorWhen
409MAX_SLIPPAGEThe estimate is above the max. The body carries estSlippagePct and maxSlippagePct, and message reads Order rejected: slippage 8.32% exceeds your 8% max.
409INSUFFICIENT_LIQUIDITYThe book cannot fill the size. estSlippagePct is null.
400BAD_MAX_SLIPPAGEmaxSlippagePct is outside 0.1 to 30, or has more than one decimal place.
{  "error": "MAX_SLIPPAGE",  "message": "Order rejected: slippage 8.32% exceeds your 8% max.",  "estSlippagePct": 8.3214,  "maxSlippagePct": 8}

Max slippage decides whether the order may open; an accepted order still fills at the fresh mark. A marketable limit is bounded by its own price and is not checked, and closes (/sandbox/positions/close) and take-profit or stop-loss triggers are never limited. If the book cannot be read at that moment, the order proceeds without the check rather than failing.

What a trade costs

Every fill is charged a fee on notional. Size your trigger levels against it, not against the raw price move.

FeeRateApplies to
Taker4.5 bps (0.045%)Market orders, marketable limits, closes, and TP/SL triggers.
Maker1.5 bps (0.015%)Resting limit orders that fill. Not reachable in the sandbox, which has no resting limits.

A market-in, market-out round trip is therefore 9 bps of notional, charged in halves: once when you open and once when you close.

A bracket narrower than the round trip loses money on every outcome

This is the single most expensive mistake an agent author makes, and it looks like a working strategy until you total the ledger. With a 9 bps round trip, a 12 bps take-profit nets +3 bps on a winner while an 8 bps stop loses −17 bps, so a strategy needs to win well over half its trades to break even. Set your bracket wide enough that a winner clears the round trip with room to spare.

Two things to know before you compute your own PnL:

  • fee on a trade row is the round-trip total, not the leg you paid on this fill. It is the open fee plus the close fee for that position.
  • realizedPnL is gross; the fee is not already deducted from it. Net PnL for a closed trade is realizedPnL - fee. Reading realizedPnL as net, or subtracting fee twice, are the two independent ways to get this wrong.

Close a position

POST/sandbox/positions/closeAPI key

Close or partially close at a fresh mark. Omit pct (or pass 100) to fully close; pass 1-99 to partially close.

FieldTypeNotes
symbolstringRequired.
pctnumberOptional. 1-100. Defaults to 100.
curl -X POST https://api.perpsfund.com/v1/sandbox/positions/close \  -H "X-API-Key: $PERPSFUND_API_KEY" \  -H "Content-Type: application/json" \  -d '{"symbol":"BTC","pct":50}'
{  "ok": true,  "closed": { "symbol": "BTC", "pct": 50, "exitPrice": 64500, "realizedPnL": 25, "fee": 1.6 },  "position": { "symbol": "BTC", "side": "long", "size": 0.05, "…": "…" },  "account": { "id": "demo", "environment": "sandbox", "…": "…" }}

position is the remaining position after a partial close, or null when fully closed.

Set take-profit and stop-loss

POST/sandbox/positions/tpslAPI key

Set, edit, or clear the trigger levels on an open position. Pass null for a level to clear it. A level you change must sit on the correct side of the current price, not the entry (for a long, the stop below it and the take-profit above it), so a stop at the entry or in profit is valid. A level at or past the current price, or a stop past the take-profit, is rejected with invalid_tpsl, as on live. With no cached price, the changed levels are judged against the entry.

FieldTypeNotes
symbolstringRequired.
takeProfitPricenumber | nullOptional. null clears it.
stopLossPricenumber | nullOptional. null clears it.
closePctnumber | nullOptional. Percent of the position a trigger closes.
{  "ok": true,  "position": { "symbol": "BTC", "side": "long", "takeProfitPrice": 66000, "stopLossPrice": 62000, "…": "…" }}

Triggers fire on touch, not continuously

The sandbox evaluates your trigger levels on requests that touch your account state - GET /sandbox/account and GET /sandbox/positions. There is no background process watching your demo account between your calls.

Two consequences your agent has to plan for:

  • A level fires at the mark when you poll, not at the level. If your stop is at 62,000 and the market gapped to 61,400 while your agent slept, the close is filled at 61,400. Read exitPrice on the resulting trade; never assume it equals the level you set.
  • A crossing is deferred, never lost. A cross does not un-cross itself, so the trigger is still there on your next poll. But an agent that stops polling stops enforcing its own risk.

Poll on a cadence you would be comfortable with as your worst-case fill delay - a few seconds is normal. Levels are still worth setting: they close the position without a round-trip decision from your agent, and they survive an agent that crashes and restarts.

Reset your sandbox

POST/sandbox/resetAPI key

Wipe your demo account back to a fresh starting state. Useful to start a clean run. Only affects your own sandbox.

{ "ok": true, "account": { "id": "demo", "environment": "sandbox", "balance": 50000, "…": "…" } }

The machine-readable spec

An OpenAPI 3.0.3 spec describes the whole v1 surface, with schemas and errors, for code generators, Postman, a Swagger viewer, or an agent that reads it for context. All 31 paths are in it: the sandbox endpoints, /health, /me, and the live routes documented on Live trading.

It needs no key. Fetch it, read it, and decide whether you want one:

curl https://api.perpsfund.com/v1/openapi.json -o perpsfund-openapi.json

Creating a key still needs an account with API beta access, and every endpoint the spec describes still needs that key. The document is a map, not a door.

Give this to your AI
Perps Fund sandbox trading endpoints (all POST with a JSON body, header X-API-Key: pk_test_...):
Place / add to a position:POST /sandbox/orders{ "symbol": "BTC", "side": "long", "size": 0.1, "leverage": 5,  "orderType": "market", "takeProfitPrice": 66000, "stopLossPrice": 62000, "expectedPrice": 64100 }-> { ok, fill:{price,repriced,ageMs}, position, account }
Close / partially close:POST /sandbox/positions/close   { "symbol": "BTC", "pct": 50 }   (omit pct = full close)
Change trigger levels on an open position:POST /sandbox/positions/tpsl    { "symbol": "BTC", "takeProfitPrice": 66000, "stopLossPrice": null }
Notes: market orders fill at a fresh mark; limit orders fill only if immediately marketable else order_not_marketable.No resting limits and no Pro orders here: any orderType other than "limit" (for example "scale") is placed as a MARKET order.Leverage clamps to the market's maxLeverage. The sandbox has NO idempotency: do not blindly retry a write, read/sandbox/positions first. (Live routes accept an Idempotency-Key header; the sandbox ignores it.)
ERRORS: an error response has NO "ok" key at all - it is { "error": "<code>", "message": "<text>" } with a 4xx/5xxstatus. Testing 'if response["ok"] is False' therefore NEVER fires. Check the HTTP status, or test for "error".
FEES: taker 4.5 bps per leg, so a market-in/market-out ROUND TRIP costs 9 bps of notional.Size take-profit and stop-loss levels WIDER than that or every outcome loses money. On a closed trade row, "fee" isthe ROUND-TRIP total and "realizedPnL" is GROSS - net PnL is realizedPnL - fee (do not subtract the fee twice).
CLEARING LEVELS: /sandbox/positions/tpsl treats an explicit null as "clear this level", so send ONLY the levels youmean to change. Sending takeProfitPrice: null while setting a stop wipes the take-profit.
TP/SL fire ON TOUCH: levels are evaluated when you call GET /sandbox/account or GET /sandbox/positions, notcontinuously. Polling is what fires your stop. A level fills at the mark at poll time, not at the level, so a gapbetween polls fills worse than the level. A crossing is deferred rather than lost. GET /sandbox/trades reports thecause in orderType: "stop_loss", "take_profit", or "market" (a close you made, a flip, or a drawdown liquidation).