Orders open and close positions in a trading session. The endpoints have the same shape on both surfaces — an account-less practice session (/practice/sessions/{sid}/orders) and a live session under an account (/accounts/{id}/sessions/{sid}/orders) — so the request/response bodies below apply to both; only the path prefix differs (live sessions additionally offer dry-run and PUT replace, which practice does not). This page covers placing, previewing, inspecting, replacing, and canceling them. For the concepts — order types, fill rules, SPX ticks — see Orders & order types.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/accounts/{id}/sessions/{sid}/orders |
List orders |
POST |
/accounts/{id}/sessions/{sid}/orders |
Place an order |
POST |
/accounts/{id}/sessions/{sid}/orders/dry-run |
Validate + preview without persisting |
GET |
/accounts/{id}/sessions/{sid}/orders/{orderId} |
Get one order |
PUT |
/accounts/{id}/sessions/{sid}/orders/{orderId} |
Replace a resting limit order's price |
DELETE |
/accounts/{id}/sessions/{sid}/orders/{orderId} |
Cancel/delete an order |
The runnable blocks on this page use the practice prefix, because a practice day can be opened at any hour and thrown away afterwards — the request and response bodies are the ones documented below either way. Start by opening a day and moving its clock past data-start-time; the session id is then reused by every block that follows.
POST /practice/sessions
Opens a throwaway $100,000 practice day. Any past trading date works — take one from GET /market-data/sessions if this one isn't in the archive.
Interactive — run this request from the docs
PATCH /practice/sessions/{{session_id}}
Sets the session's replay clock to the given time. Orders are refused before the day's data-start-time, so park it a few minutes into the session; keep the date in step with the session you opened.
Interactive — run this request from the docs
Instrument strings
Legs name instruments by string:
| Type | Format | Example |
|---|---|---|
| Equity | <SYMBOL> |
SPX |
| Equity option | <ROOT><YYMMDD><C|P><strike×1000, 8-digit> (21-char OSI) |
SPXW 250115C05950000 |
The equity-option string is canonical OPRA/OSI: a 6-char root left-justified and space-padded, the YYMMDD expiry date, a C or P side letter, and the strike × 1000 zero-padded to 8 digits. SPX 0DTE options use the SPXW weekly root (so SPXW followed by two spaces). An option is identified by root + date + side + strike; the session-close time is implied, not encoded.
Place an order
A limit debit call vertical (buy the 5950 call, sell the 5970 call) — multi-leg orders must be limit:
curl -s https://api.0dtespx.com/accounts/$ACCT/sessions/$SID/orders \
-X POST -H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
-d '{
"type": "limit",
"price": "10.50",
"price_effect": "debit",
"legs": [
{"instrument":"SPXW 250115C05950000","quantity":"1","action":"buy to open"},
{"instrument":"SPXW 250115C05970000","quantity":"1","action":"sell to open"}
]
}'
POST /practice/sessions/{{session_id}}/orders
Places the vertical in your practice day. The legs' YYMMDD has to match the session date (0DTE only) and the strikes have to exist on it, or the order is rejected with 400.
Interactive — run this request from the docs
The response carries status, fees, and a buying-power-effect block projecting the impact on margin and available buying power; once the order is filled it also carries fill_price, fill_datetime, fill_price_effect and the per-leg transactions — and, on a live or bot session, fill_quotes, the market quotes at the time of the fill:
{
"id": "a7e2c9d1-…",
"type": "limit",
"price": "10.50",
"price_effect": "debit",
"status": "filled",
"fill_price": "10.50",
"fill_price_effect": "debit",
"slippage": "0.05",
"execution_price": "10.45",
"fees": "3.44",
"legs": [ … ],
"transactions": [ … ],
"buying-power-effect": {
"change-in-buying-power": "1053.44",
"change-in-buying-power-effect": "Debit",
"new-buying-power": "98946.56"
}
}
A single-leg market order fills immediately at the natural price (ask for a buy, bid for a sell); market and stop orders must be single-leg.
Limit and stop orders
How a limit or stop order fills — two rules, identical in practice, live, and backtests:
- A price past the market fills at once; anything else takes 3 consecutive seconds. The order is checked once a second. A
pricestrictly past the market — better than the mid-based execution price, not merely level with it — fills on the very check that sees the crossing, with no waiting period: on the second you placed it, if it already reached past the market then, or on the first second the market moves through it afterwards. Everything else has to have its condition hold on three checks in a row, so the earliest it can fill is 2 seconds after placement. (When the platform cannot price the second you placed on, a crossing order rests instead and then fills at its ownpriceon the first check that sees the market through it — staleness can delay or downgrade such a fill, never manufacture one.) So a one-second flicker to your price fills nothing and a second the market can't be priced at breaks the run and starts it over, but a one-second move through your price does fill. - A limit the market came to fills at its own
price. Not at the mid it touched —fill_priceequals thepriceyou asked for (per unit of the structure). That holds both ways such an order fills: after its three checks at the level, and on the second the market moves through it while it is working (a resting order trades at its limit, and the incoming side keeps the improvement). An order priced at the mid is resting too: it asked for the mid, it did not reach past it. Three cases fill marketable instead, at the mid-based execution price shifted by your slippage and bounded at the natural — never above the net ask for a debit, never below the net bid for a credit: amarketorder, a triggeredstop, and a limit whosepricewas already strictly past the market at the second that price was set — that one crossed the spread on purpose, so it gets the price the market was showing at that second, which is strictly better than thepriceit asked for.
"The second that price was set" is not always the second you placed the order. Giving an order a new price starts it over — a PUT replace, every step of a smart liquidation's re-pricing ladder, and every step of the automatic re-pricing the platform runs on a strategy's own orders, in a bot session and in a strategy's results alike, each count as a fresh placement here: the new price is tested against the market at the second it takes effect, and the three-second run restarts from there. So a re-priced order that steps through the market crosses the spread at that step: it fills right there, and comes back with a fill_price better than its own price even though you never placed it that way.
In the example above, price is 10.50 and slippage is $0.05, so execution_price — the mid the order had to reach — is 10.45. If the mid was at or above 10.45 when the order went in, the order rested, and once the mid sat at or below 10.45 for three seconds it filled at the 10.50 it asked for. If the mid was already below 10.45, the order crossed the spread and the POST came straight back filled, at the mid-based price as it stood at that second — shifted by slippage, but never past the natural.
There is one more marketable case, and you don't place it by hand: an automatic stop-loss close — the closing order a strategy or the Strategy Builder generates to cap a loss. If the market runs through that order's price and stays through it for three consecutive seconds, it fills at the market instead of resting at a price the market has left behind. Without it a stop-loss on a runaway move would sit unfilled exactly when it is needed.
Add price for a limit and stop_trigger for a stop, plus a price_effect of debit or credit. Slippage is not a request field — it is a single per-account setting (in Settings → Simulator costs, defaulting to $0.05 on new accounts) that the server applies to the fill condition, and is echoed back on the order's slippage/execution_price — see Slippage:
{"type":"limit","price":"1.50","price_effect":"credit","legs":[ … ]}
{"type":"stop","stop_trigger":"12.00","price_effect":"debit","legs":[ /* single leg */ ]}
A limit whose price reaches strictly past the market comes back filled — it crossed the spread on arrival — with fill_price, fill_datetime, and, on a live or bot session, fill_quotes. On a live or bot session that fill runs inside the call at the exchange, so the call can also fail the way a market order's can there: 400 execution_failed when the fill cannot be executed against the market, and 400 no_fill when nothing filled against it; neither leaves an order working. (503 market_data_error belongs to market orders, which read the market inside the call — a crossing limit has already read it to decide that it crosses, so a market it cannot read simply leaves the order resting.) A practice session has none of them — it pre-computes the fill from historical data, so a market it cannot read is already the 400 of validation step 5 below. Every other limit, and every stop, comes back live — resting on the book. On a practice session its fill_datetime is pre-computed; advance the session clock past that moment and it shows filled. On a live session it stays live until three matching ticks arrive in a row, or until the market moves through it. A stop whose trigger is already met is rejected with 400.
The market at the fill
An order filled on a live or bot session also carries fill_quotes: the market quotes at the time of the fill, per leg, plus the order's net bid and ask at that moment. It is there so you can see the market your order filled in without reconstructing it afterwards.
{
"id": "a7e2c9d1-…",
"status": "filled",
"fill_price": "10.50",
"fill_price_effect": "debit",
"fill_datetime": "2026-08-06T14:30:05Z",
"fill_quotes": {
"quotes": {
"SPXW 260806C05950000": { "bid": "12.30", "ask": "12.60" },
"SPXW 260806C05970000": { "bid": "1.90", "ask": "2.15" }
},
"quotes_at": {
"SPXW 260806C05950000": "2026-08-06T14:30:04Z",
"SPXW 260806C05970000": "2026-08-06T14:30:05Z"
},
"net_bid": "10.15",
"net_ask": "10.70"
}
}
quotes— each leg's bid and ask, keyed by that leg's option symbol. Every leg of the order is present: the snapshot is all-or-nothing, so a partial one is never returned. Every price is a decimal string.quotes_at— the time of the quote used for each leg, keyed by the same symbols. This is the quote's own timestamp, not the fill second. A leg takes the closest quote within a second of the fill, so on an order the market came to a value here can sit up to one second on either side offill_datetime— trailing it or leading it. Amarketorder is a wider case: it fills on the spot against the latest quotes available while its fill is stamped with the moment you placed it, so its timestamps can trailfill_datetimeby a few seconds. A limit that crossed the spread at placement is not that case: it is priced off the placement second's own market. Readquotes_at; never assume the fill second. It is the same field the bot decision log reports on its pricing entries.net_bid/net_ask— the order's net bid and ask at that moment, per unit: the leg-quantity greatest common divisor is divided out (a 2×/2× vertical reports the same numbers a 1×/1× does), and the credit/debit direction comes from the order'sprice_effect/fill_price_effect, never from a sign here. That is also why, on a ratio spread, you cannot re-derive them by adding upquotes. Do not assumenet_bidis positive: on a near-zero or very wide combo the natural bid side crosses through zero, so it can come back as0or as a negative number — a wide debit vertical can report anet_bidof-0.25against anet_askof0.55. The only ordering guarantee isnet_bid≤net_ask.
These are the quotes at the fill, not the inputs to the fill price. fill_price follows the rules above, and in most cases it is not arithmetic on this snapshot: a limit the market came to fills at its own price, and a limit priced past the market books the market it crossed at the second that price was set. So fill_price can sit outside net_bid/net_ask — the pair does not bracket it, and none of these numbers is an execution-quality or slippage readout. There is no midpoint field either: the reference price a limit or stop is measured against comes from the tick rules above, not from averaging the two bounds.
The whole object is absent — never null — when there is nothing to report: on any status other than filled, on an order that filled before this field shipped, and on the rare fill where a leg's quote could not be read. It is also always absent on a practice order, which is priced against historical data and never captures it; that is why the runnable blocks on this page never show it. Live and bot orders carry it everywhere they are returned — the REST reads, the POST / PUT / dry-run bodies, and the order_update event.
Validation order
Independently of the checks below, order placement requires the session clock to be at or after the day's data-start-time (the moment historical data begins, from GET /market-data/sessions); an order placed while the clock is earlier is rejected with 400 (trading is available from <time>). The clock itself can be moved anywhere in the session — only order placement is gated.
These checks run in this order, each with its own 400:
- Order type and leg shape parse;
marketandstoporders must be single-leg. Every leg must be a 0DTE SPX index option (only 0DTE SPX index options can be traded). pricerequired forlimit,stop_triggerforstop,price_effectfor both. (Slippage is taken from your account setting, not the request body.)- SPX option tick rules — single-leg < $3 → $0.05, ≥ $3 → $0.10, multi-leg → $0.05.
- A limit
pricecan't exceed the structural maximum profit of the legs. - Market data must exist for every leg at the current time.
- A stop can't already be triggered.
- Available-to-close: a
to closeleg can't exceed the quantity you hold (insufficient quantity to close). - Defined-risk only: the resulting position can't hold an uncovered short option (
naked short positions are not allowed) — checked before buying power, so a naked-adding order returns this, not a buying-power error, regardless of capital. - Buying-power check, including all existing working orders.
Rejections are JSON {"message": …} on practice sessions. The naked-short and available-to-close checks read your resulting book (current filled + working positions plus the new order's legs), so they also block stripping the long leg out of a spread you already hold.
Dry-run
POST /accounts/{id}/sessions/{sid}/orders/dry-run runs every validation and returns the same response (minus transactions) without persisting anything — no order, no trades, no history change. It returns 200 instead of 201. Use it for pre-trade confirmation; a successful dry-run strongly implies the real POST will succeed too. (On a live session the result is advisory — the market may move between the dry-run and the real place.)
A dry run reports the same verdict the real placement would. A limit whose price reaches strictly past the market previews as filled — and, since the preview fills it at the exchange exactly as a real placement would, it can surface that path's 400 execution_failed / 400 no_fill — while a limit merely level with the market previews as live, as does every stop. A market order previews as filled.
Order status and fees
GET /practice/sessions/{{session_id}}/orders
Every order in the day, with each status computed against the current clock.
Interactive — run this request from the docs
GET /practice/sessions/{{session_id}}/orders/{{order_id}}
The single order you just placed — rewind the clock with PATCH and this same read flips it back to live.
Interactive — run this request from the docs
On a historical session, order status is computed from fill_datetime versus the session's current clock time — filled if the fill is at or before now, otherwise live; rewind the clock and a filled order can revert to live. On a live session, status is the persisted lifecycle state and never time-travels. Fees follow suit: a filled order reports the actual summed fees; an unfilled one shows a schedule-based estimate. See Fees.
A live order walks one ladder, always forward, and may skip a rung:
pending → routed → live → {filled, canceled, expired, rejected}
| Status | Meaning |
|---|---|
pending |
We accepted the order; nothing downstream has confirmed it yet. Transient — today it lasts a fraction of a second. |
routed |
The next hop confirmed receipt, but the order is not on a book yet. Reserved — nothing emits it today; handle it so it costs you nothing when it starts occurring. |
live |
Resting on the book: fillable, cancelable, replaceable. Code your working-order view against this one. |
filled canceled expired rejected |
Terminal. The first terminal recorded wins; nothing overwrites it. |
The three non-terminal states are all working: they reserve buying power, they count toward your open orders, and a cancel is accepted on any of them. Only live accepts a replace. accepted_seq — the per-session book ordinal, carried on the WebSocket order_update payload — is null for as long as the order is pending or routed, and is stamped the moment the order is sequenced. That is normally the move to live; an order refused in the same instant it was sequenced goes straight to rejected carrying its ordinal, so a terminal order with an accepted_seq did not necessarily rest on the book.
One more field is not a status at all: cancel_requested is present, and true, from the moment a cancel is accepted for the order until the order resolves. It rides any status, terminal ones included. See Cancel.
Replace
curl -s -X PUT https://api.0dtespx.com/accounts/$ACCT/sessions/$SID/orders/$ORDER \
-H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
-d '{"price":"10.40","price_effect":"debit"}'
Only price and price_effect may change — type, legs, and underlying are fixed. On a live session this is an atomic cancel + replace: the order named in the path goes terminal and the replacement carries a new id.
A replace is valid only while the order is live — before it reaches the book there is no resting price to revise. Replacing a non-limit order returns 400; three cases return 409:
| Body | Cause |
|---|---|
order is not yet live |
The order is still pending or routed. Retry in a moment, or cancel it. |
order is being canceled |
A cancel was already accepted for it (cancel_requested). |
order is no longer working |
It is already terminal. |
Cancel
curl -s -X DELETE https://api.0dtespx.com/accounts/$ACCT/sessions/$SID/orders/$ORDER -H "Authorization: $TOKEN"
DELETE /practice/sessions/{{session_id}}/orders/{{order_id}}
Deletes the order captured above, along with its trades, and recomputes the day's history. Practice only — nothing here touches a live account.
Interactive — run this request from the docs
Returns 204. On a practice session, deletion removes the order and its trades (including any settlement trades it affected) and recomputes history — it runs against historical data, so it takes effect at once.
On a live session a cancel is a request, and best-effort. It is accepted in every non-terminal state (pending, routed, live), because flattening risk must never be blocked by where the order sits on the ladder; only an already-terminal order returns 409. The 204 means the cancel was accepted and queued behind whatever is already in flight for that order — not that the order is gone. From that moment the order reads cancel_requested: true (show it as "canceling…"), and one of three things follows:
- The order was resting → it goes
canceled. - The order never reached the book → it is stamped
rejected, and the cancel is a no-op. - The order fills first → it ends
filled, withcancel_requestedstilltrue.
Case 3 is irreducible: any exchange can match in the moment before a cancel lands, which is why every broker treats cancels as best-effort. Take the terminal status, not the 204, as the outcome — read the order back, or watch the session's order_update events.
Liquidations
A liquidation closes every open position on a session in one call — the API behind the app's Close all positions button. You pick a method, the server derives the closing orders from what you currently hold, and it works them until the book is flat, the session ends, or you cancel.
| Method | Path | Purpose |
|---|---|---|
POST |
/accounts/{id}/sessions/{sid}/liquidations |
Start a liquidation on a live session |
GET |
/accounts/{id}/sessions/{sid}/liquidations/current |
Progress, or the last one's summary |
DELETE |
/accounts/{id}/sessions/{sid}/liquidations/current |
Cancel a running liquidation |
POST |
/practice/sessions/{sid}/liquidations |
Run one on a practice session — synchronous |
curl -s https://api.0dtespx.com/accounts/$ACCT/sessions/$SID/liquidations \
-X POST -H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
-d '{"method":"smart"}'
The two methods
Both surfaces take the same body — {"method": "smart"} or {"method": "aggressive"}.
smart is priced to capture the best available exit. Your positions are grouped into multi-leg limit orders of up to four legs, each placed at a favorable price, and every working order is re-priced every 5 seconds — one tick at a time toward the market, never back away from it — until it fills. It is slower than a market order, but you keep the spread.
aggressive closes each position on its own with a market order, in two rounds: every short is bought back first, then the longs those closes release. It is unconditional and immediate, but you take whatever the market offers, which on a wide 0DTE spread can be materially worse than the mid. One exception: a leg with no usable natural price (nothing bid when selling, nothing offered when buying) gets a one-tick ($0.05) limit order instead — a market order there would print at zero — so that leg may rest instead of filling straight away.
Grouping keeps you covered
Closing orders are grouped so that no partial set of fills can ever leave you holding an uncovered short. Inside a single order, the long quantity being closed never exceeds the short quantity being closed in the same expiration and option right, and long-only closes are capped at the longs you hold in excess of your shorts. That invariant is also why aggressive buys the shorts back before it sells the longs. Whatever subset of the closing orders fills first, your book is never more naked than it started — so a liquidation is never rejected by the defined-risk rule.
Starting one cancels your working orders
A liquidation first cancels every working order on the session — including stop-losses and take-profits — and they are not restored. Not when it completes, not when it ends partially, and not when you cancel it. Re-place any protective orders you still want afterwards.
While one is running on a live session, placing (POST) or replacing (PUT) an order on that session returns 409 liquidation_in_progress. Cancelling an order (DELETE …/orders/{orderId}) stays available — you can always reduce risk by hand.
The liquidation snapshot
All four endpoints answer with the same object:
{
"method": "smart",
"status": "running",
"started_at": "2026-07-17T14:31:02Z",
"orders": [
{
"order_id": "9f3c1a24-…",
"status": "live",
"legs_summary": "buy to close 1 SPXW 250115P05900000 + sell to close 1 SPXW 250115P05880000",
"current_price": "1.35"
}
],
"positions_remaining": 2,
"errors": []
}
status |
Meaning |
|---|---|
running |
still working — live sessions only |
completed |
the book is flat |
partial |
it ended with positions still open — a session that closed mid-liquidation, or a group whose price never came |
canceled |
you cancelled it, and its orders were cancelled with it |
failed |
it stopped on an internal error |
orders[].order_id is each closing order's current id — re-pricing replaces an order, so the id moves as it walks; each one is an ordinary order under GET …/orders in the meantime. status on an order is its real lifecycle state (pending, routed, live, filled, canceled, expired, rejected), plus two extras: stalled for a live order the workflow parked after the exchange rejected it, and unfilled for a practice group that reached the close without filling (no order_id — nothing was recorded for it). positions_remaining is how many positions were still open at the last look; positions_unresolved (present only when non-zero) counts longs the aggressive method could not sell because the shorts covering them never bought back. errors is an array of deduplicated messages — a workflow can carry errors and still finish completed.
Live: start, poll, cancel
POST returns 202 with the initial snapshot; the workflow then runs server-side and you follow it with GET …/liquidations/current (polling every couple of seconds is plenty). A finished liquidation's summary stays readable for about 15 minutes, or until a new one starts on the session — after that the GET returns 404 no_liquidation.
DELETE …/liquidations/current stops it and cleans up after it: nothing further is placed or re-priced, everything it has working is cancelled, and the 200 only comes back once those cancellations are terminal — so by the time you hold the final snapshot the book has stopped moving. Positions it already closed stay closed.
A liquidation is live progress, not stored state. In the rare case one is interrupted, the closing orders it already placed simply keep working as ordinary orders — visible under GET …/orders, cancelable, and re-running the liquidation adopts whatever is still open.
Practice: one synchronous call
POST /practice/sessions/{sid}/liquidations runs the same close plan through the day's historical data, forward from the session's replay clock, and answers 200 with the finished summary. There is nothing to poll and nothing to cancel. Your slippage setting governs how quickly its limit orders fill — a wider setting needs the market to move further before a close prints, exactly as for an order you place by hand.
The cancellations and the closing orders are applied atomically — if anything fails, nothing changes at all and your working orders are untouched — and your account statistics are recomputed immediately afterwards. Every timestamp in the summary is a session time, and the orders it records carry the session times they were placed and filled at — which can be after the current clock. Advance the clock to watch them fill.
A group that never reached its price before the close is not recorded: it comes back in orders with status unfilled and no order_id, its positions stay open, and the summary's status is partial. Running the liquidation again is the retry.
Liquidation errors
| Response | When |
|---|---|
409 liquidation_in_progress |
one is already running on the session — also what order place/replace returns while it runs |
409 no_open_positions |
live: the book is flat, so there is nothing to close |
409 no_liquidatable_positions |
practice: every open position is already covered by a working closing order |
409 session_closed |
the session is settled or being replayed — it cannot be traded |
404 no_liquidation |
GET/DELETE with nothing running and no retained summary |
400 |
method missing or not one of smart, aggressive |
A rejection the exchange returns mid-flight does not end a live workflow: it lands in errors[] and the affected order is parked as stalled, so the rest of the book keeps closing.
For today's session, see the live trading API — the same paths, with idempotency keys scoped per session.