Skip to content
Skip to main content

Equity Trading

The Equity Trading API provides programmatic order entry for U.S. listed equities - market, limit, stop, and stop-limit orders with direct market access routing. Orders follow the same validation, routing, and asynchronous rejection behavior as TradeZero Platforms.

You can also manage orders through TradeZero MCP - the order lifecycle is identical. See example prompts on the MCP guide.

At a glance​

MethodPathPurpose
GET/v1/api/accounts/{accountId}/routesList available routing destinations and the order-type / TIF combinations each supports
GET/v1/api/accounts/{accountId}/ordersToday's orders (all statuses)
GET/v1/api/accounts/{accountId}/order/{clientOrderId}Retrieve a single order by clientOrderId
GET/v1/api/accounts/{accountId}/orders/start-date/{startDate}Historical fills from a specific date onward (one row per execution; up to 1 week)
GET/v1/api/accounts/{accountId}/orders-with-pagination/start-date/{startDate}Paginated historical order history (up to 1 year; see below)
POST/v1/api/accounts/{accountId}/orderPlace a new order
DELETE/v1/api/accounts/{accountId}/orders/{clientOrderId}Cancel a specific order by clientOrderId
DELETE/v1/api/accounts/ordersCancel all open orders (or all for a specific symbol)
GET/v1/api/accounts/{accountId}/is-easy-to-borrow/symbol/{symbol}Pre-trade borrow check before placing a short

Authentication uses two request headers on every call - see Authentication for setup. Open holdings, closed lifecycles, historical as-of snapshots, and P&L are covered on the Positions & P&L page.

Rejection reasons and timestamps

Live orders can reject asynchronously - POST /order may return PendingNew before the reason appears. Read Order rejections for where text, startTime, and lastUpdated surface across REST and the Portfolio WebSocket.

SMART eligibility

Which equity order types and times in force are valid on SMART, and in which ET session windows, is in Order types, times in force, and session hours.


Trader actions​

Four trader actions drive equity orders. Each maps to a combination of side and openClose on the request body:

ActionsideopenCloseWhen to use
BuyBuyOpenEnter a new long position
SellSellCloseExit an existing long position
ShortSellOpenEnter a new short position (borrows shares)
CoverBuyCloseExit an existing short position

Pre-trade borrow check​

GET /v1/api/accounts/{accountId}/is-easy-to-borrow/symbol/{symbol}

Before placing a Short order (side: "Sell", openClose: "Open"), call this endpoint to determine whether shares are available to borrow.

Check borrow availability for TSLA
curl 'https://webapi.tradezero.com/v1/api/accounts/TZP12345678/is-easy-to-borrow/symbol/TSLA' \
-H 'TZ-API-KEY-ID: {YOUR_PUBLIC_KEY}' \
-H 'TZ-API-SECRET-KEY: {YOUR_SECRET}'
Response
{ "isEasyToBorrow": true }
FieldTypeMeaning
isEasyToBorrowbooleantrue - shares are freely available; you can place a short order without a locate. false - the symbol is hard-to-borrow; you must reserve shares through the Short Locates workflow before the short order will be accepted.

If isEasyToBorrow is false, the short order returns orderStatus: "Rejected" without a prior locate reservation. See Short Locates for the full reserve workflow.


HTTP semantics​

ConditionStatusBody
Successful POST /order (order received, even if orderStatus is Rejected)200JSON
Successful GET200JSON
Successful DELETE /accounts/orders (cancel-all)200{"message":"Cancel Request Submitted Successfully"}
JSON Schema validation error (bad enum, wrong type, out-of-range quantity)400Plain text, bullet format
Account ID mismatch in POST /order path400{"statusCode":"BadRequest","message":"PlaceOrderWithResponse","detail":"Account for User was not found, or User doesn't have entitlements."}
Account ID mismatch in DELETE /orders/{id} path400{"statusCode":"BadRequest","message":"CancelOrderWithResponse","detail":"Unable to fetch account orders from server. Account for User was not found, or User doesn't have entitlements."}
Missing auth headers on DELETE /orders/{id}401{"statusCode":"Unauthorized","message":"Token not provided","detail":null}
Missing or invalid auth headers on GET and POST endpoints404Not found (plain text)
Account ID mismatch on GET /orders404Not found (plain text)
Wrong HTTP method on /order (GET, PUT, PATCH)405405 method not allowed (plain text)
OPTIONS on /order (CORS preflight)200empty body, Allow: POST header
Wrong HTTP method on /orders (e.g. POST against the plural endpoint)404Not found (plain text)
HEAD on /orders405empty body
URL path with uppercase segments (e.g. /V1/API/...)404Not found - the path is case-sensitive

All successful responses carry Content-Type: application/json; charset=utf-8. Validation errors (400) carry Content-Type: text/plain.

Three behaviors to account for in your client:

  • Some 400 bodies include an internal handler label in message (for example PlaceOrderWithResponse or CancelOrderWithResponse). Treat detail and the HTTP status as the integrator-facing signal; the handler name is diagnostic only.
  • POST /order returns 200 OK when the request body is valid. Read orderStatus in the response to see whether the order was accepted for routing. Rejected orders also return HTTP 200 with orderStatus: "Rejected". On live routed orders, POST may return PendingNew first - poll GET /order/{clientOrderId} for the final reason. See Order rejections.
  • Auth failures return different status codes by endpoint. GET and POST endpoints return 404 Not found when auth headers are missing or invalid. The DELETE /orders/{id} cancel endpoint returns 401 Unauthorized with a JSON body instead. Handle both.
  • Account mismatch on POST/DELETE returns 400, not 404. A GET with the wrong account ID returns 404; a POST /order or DELETE /orders/{id} with an account that doesn't match your keys returns a 400 JSON body. The two endpoints share the same {statusCode, message, detail} envelope but use different message values (PlaceOrderWithResponse for POST /order, CancelOrderWithResponse for DELETE /orders/{id}). For TradeZero America accounts, this same 400 appears when the portal login is used instead of the 2TZ account number - use the account value from GET /v1/api/accounts (see Account Info).

Place an order​

POST /v1/api/accounts/{accountId}/order

Places a new equity order. The endpoint is stateful - the response captures the initial order state immediately after submission. HTTP 200 means the request was accepted; read orderStatus to see whether the order was routed and filled.

Request​

Buy 1 AAPL at market (paper account - route optional)
curl 'https://webapi.tradezero.com/v1/api/accounts/TZP12345678/order' \
-X POST \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H 'TZ-API-KEY-ID: {YOUR_PUBLIC_KEY}' \
-H 'TZ-API-SECRET-KEY: {YOUR_SECRET}' \
-d '{
"securityType": "Stock",
"symbol": "AAPL",
"side": "Buy",
"openClose": "Open",
"orderType": "Market",
"orderQuantity": 1,
"timeInForce": "Day",
"clientOrderId": "my-buy-001"
}'
Short 10 SPY at limit $500 GTC (live account - route required)
curl 'https://webapi.tradezero.com/v1/api/accounts/TTE12345678/order' \
-X POST \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H 'TZ-API-KEY-ID: {YOUR_PUBLIC_KEY}' \
-H 'TZ-API-SECRET-KEY: {YOUR_SECRET}' \
-d '{
"securityType": "Stock",
"symbol": "SPY",
"side": "Sell",
"openClose": "Open",
"orderType": "Limit",
"orderQuantity": 10,
"timeInForce": "GoodTillCancel",
"limitPrice": 500.00,
"clientOrderId": "my-short-002",
"route": "SMART"
}'

Request fields​

FieldTypeRequiredNotes
securityTypestringYes"Stock" for equities and ETFs. Use "Option" or "Mleg" for options (see Options Trading). Case-sensitive.
symbolstringYesTicker symbol. Regex ^[a-zA-Z0-9._-]+$ - alphanumerics, dot, dash, and underscore. Leading/trailing whitespace fails the pattern; tabs or newlines inside the JSON string fail JSON parsing. Symbols with / (e.g. BRK/B) are rejected - use the dot form (BRK.B). The server preserves case verbatim and does not normalize - always send symbols in the exchange's canonical uppercase form (e.g. "AAPL", not "aapl").
sidestringYes"Buy" or "Sell". Case-sensitive. See Trader actions for how Buy/Sell/Short/Cover map to this field.
openClosestringYes†"Open" to enter a new position; "Close" to exit one. See Trader actions. Send "Open" or "Close" with that exact casing in production code.
orderTypestringYes"Market", "Limit", "Stop", or "StopLimit". Case-sensitive.
orderQuantitynumberYesInteger share count. Minimum 1, maximum 1,000,000. Fractional shares are not supported - sending 0.5 or any non-integer value returns 400 Bad Request with - orderQuantity: Invalid type. Expected: integer, given: number.
timeInForcestringYesOne of the eight values documented under Order types, times in force, and session hours. Case-sensitive.
limitPricenumberConditionalRequired when orderType is "Limit" or "StopLimit". Omitting it on a Limit order places the order with limitPrice: 0, which the venue rejects. The JSON validator accepts up to four decimal places (e.g. 250.0001); tick-size rules are enforced at the venue. Send two decimals for stocks priced at or above $1.00 and up to four decimals for sub-$1.00 stocks.
stopPricenumberConditionalRequired when orderType is "Stop" or "StopLimit".
clientOrderIdstringRecommendedYour identifier for the order. If omitted, the server generates a numeric one. Used for cancel-by-id and deduplication. See Order identity.
routestringRecommendedRouting destination, e.g. "SMART" for smart equity routing on a live account. Send an explicit route taken from Get available routes. On live accounts, always include route - omitting it can leave the order with no route assigned and produce R54: Unable to reach the destination Route during routing. Route names vary by account - some accounts have no SMART route at all - so query /routes and send a routeName it returns rather than hard-coding one. Paper accounts auto-assign PAPER/PAPERM when omitted.

Response​

The response body is a JSON object representing the initial state of the order. Check orderStatus to determine what happened.

Order accepted - PendingNew (paper account - route auto-assigned)
{
"accountId": "TZP12345678",
"canceledQuantity": 0,
"clientOrderId": "my-buy-001",
"executed": 0,
"lastPrice": 0,
"lastQuantity": 0,
"lastUpdated": "2026-05-13T14:40:17.373Z",
"leavesQuantity": 1,
"legCount": 0,
"legs": null,
"limitPrice": 0,
"maintenanceRequirement": 0,
"marginRequirement": 0,
"maxDisplayQuantity": 0,
"openClose": "Open",
"orderQuantity": 1,
"orderStatus": "PendingNew",
"orderType": "Market",
"pegDifference": 0,
"pegOffsetType": "Price",
"priceAvg": 0,
"priceStop": 0,
"route": "PAPER",
"securityType": "Stock",
"side": "Buy",
"startTime": "2026-05-13T14:40:17.373Z",
"strikePrice": 0,
"symbol": "AAPL",
"text": null,
"timeInForce": "Day",
"tradedSymbol": "AAPL"
}

The live limit-short example above echoes back the live account id and the route you sent:

Order accepted - PendingNew (live account submitted via SMART)
{
"accountId": "TTE12345678",
"canceledQuantity": 0,
"clientOrderId": "my-short-002",
"executed": 0,
"lastPrice": 0,
"lastQuantity": 0,
"lastUpdated": "2026-05-13T14:40:17.373Z",
"leavesQuantity": 10,
"legCount": 0,
"legs": null,
"limitPrice": 500.00,
"maintenanceRequirement": 0,
"marginRequirement": 0,
"maxDisplayQuantity": 0,
"openClose": "Open",
"orderQuantity": 10,
"orderStatus": "PendingNew",
"orderType": "Limit",
"pegDifference": 0,
"pegOffsetType": "Price",
"priceAvg": 0,
"priceStop": 0,
"route": "SMART",
"securityType": "Stock",
"side": "Sell",
"startTime": "2026-05-13T14:40:17.373Z",
"strikePrice": 0,
"symbol": "SPY",
"text": null,
"timeInForce": "GoodTillCancel",
"tradedSymbol": "SPY"
}

The response shape is the same on paper and live - only accountId and route differ.

Order rejected - duplicate clientOrderId
{
"orderStatus": "Rejected",
"text": "R114: Invalid duplicate UserOrderId",
"clientOrderId": "my-buy-001",
"canceledQuantity": 1,
"executed": 0,
"leavesQuantity": 0
}

Response fields​

FieldTypeNotes
accountIdstringThe account the order was placed on.
clientOrderIdstringYour identifier, or a server-generated numeric string if you omitted it. Always assign your identifier with clientOrderId - orderId in the request body is not a recognized assignment field and the server will return its own auto-generated clientOrderId if you set it.
orderStatusstring"PendingNew" - accepted, routing in progress; "New" - acknowledged at venue, resting in book; "Filled" - fully executed; "Canceled" - successfully canceled; "Rejected" - declined (read text for reason).
textstring | nullHuman-readable reason when orderStatus is "Rejected". null on accepted or in-flight orders. May use an R##: prefix or a plain-text platform message (see Order rejections).
executednumberShares filled so far.
leavesQuantitynumberShares still open. 0 once the order is terminal.
canceledQuantitynumberShares canceled.
orderQuantitynumberOriginal quantity requested.
limitPricenumberThe limit price sent. 0 if not a limit order.
priceStopnumberThe stop price sent. 0 if not a stop order.
priceAvgnumberVolume-weighted average fill price. 0 while unfilled.
openClosestring"Open", "Close", or "Unknown" (early rejections before trader-action resolution can return "Unknown").
routestringThe routing destination the server used. Echoes the route name ("PAPER", "SMART", …) on accepted orders and on rejected orders that reached a venue. Validation-time rejections show the literal "<no value>" when no venue was selected - treat rejection by checking orderStatus === "Rejected" and reading text.
startTimestringISO 8601 timestamp when the order was received.
lastUpdatedstringISO 8601 timestamp of the most recent state change.
tradedSymbolstringFor stocks, equals symbol. For options, the OCC contract string.
strikePricenumber0 for equities.
legCountnumber0 for single-leg orders.
legsarray | nullnull for single-leg orders.
symbolstringRoot ticker on equity orders.
securityTypestring"Stock", "Option", or "Mleg".
sidestringEnriched trader action on reads (for example "Buy", "SellShort") - may differ from the raw side sent on POST /order.
orderTypestring"Market", "Limit", "Stop", or "StopLimit".
timeInForcestringTime-in-force sent with the order.
lastPricenumberLast trade price associated with the order row. 0 while unfilled.
lastQuantitynumberQuantity of the last fill. 0 while unfilled.
pegDifferencenumberReserved response field on order rows; not a supported place-order input. Default 0.
pegOffsetTypestring | nullReserved response field; not a supported place-order input. Default null or "Price".
maxDisplayQuantitynumberDisplay quantity for iceberg-style routes; 0 when not used.
marginRequirementnumber0 on order rows - use account bp / deficit fields for margin checks. See API Conventions.
maintenanceRequirementnumber0 on order rows - same guidance as marginRequirement.

Get today's orders​

GET /v1/api/accounts/{accountId}/orders

Returns today's order book for the account - every session row regardless of status (including Filled, Canceled, Rejected, DoneForDay, …), plus still-working multi-day GoodTillCancel / GTC_Plus orders from prior sessions. Filled market orders can drop out quickly; for a durable single-order view use GET /order/{clientOrderId}. Filter to working states client-side when you need a live blotter. The response is wrapped in an orders key.

Fetch today's orders
curl 'https://webapi.tradezero.com/v1/api/accounts/TZP12345678/orders' \
-H 'TZ-API-KEY-ID: {YOUR_PUBLIC_KEY}' \
-H 'TZ-API-SECRET-KEY: {YOUR_SECRET}'
Response envelope (paper account, one resting limit order)
{
"orders": [
{
"accountId": "TZP12345678",
"canceledQuantity": 0,
"clientOrderId": "my-buy-001",
"executed": 0,
"lastPrice": 0,
"lastQuantity": 0,
"lastUpdated": "2026-05-14T15:36:36.987036Z",
"leavesQuantity": 1,
"legCount": 0,
"legs": null,
"limitPrice": 1,
"maintenanceRequirement": 0,
"marginRequirement": 0,
"maxDisplayQuantity": 0,
"openClose": "Open",
"orderQuantity": 1,
"orderStatus": "New",
"orderType": "Limit",
"pegDifference": 0,
"pegOffsetType": "Price",
"priceAvg": 0,
"priceStop": 0,
"route": "PAPER",
"securityType": "Stock",
"side": "Buy",
"startTime": "2026-05-14T15:36:36.8753891Z",
"strikePrice": 0,
"symbol": "F",
"text": "TRAFIX_SIM",
"timeInForce": "Day",
"tradedSymbol": "F"
}
]
}

The same field names appear on live accounts (accountId set to your live id, route set to the route you sent).

Order shape across REST and WebSocket​

GET /orders rows use the same field names as POST /order and GET /order/{clientOrderId} - including a top-level clientOrderId and accountId. Read clientOrderId directly; no parsing required.

Portfolio WebSocket Order pushes use a WebSocket field set. Normalize these when merging stream updates with REST data:

Clean REST field (POST /order, GET /order/{cid}, GET /orders)Portfolio WebSocket Order pushNotes
accountIdaccountSame value, different key.
clientOrderIduserOrderId, format "{accountId}:{clientOrderId}"Split userOrderId on the first : to recover the bare id.
canceledQuantitycancelledQuantitySpelling difference.
lastQuantitylastQty
leavesQuantityleavesQuantity and lvsQtyBoth present on WS rows.
maxDisplayQuantitymaxDisplayQty
orderStatusstatusSame value; prefer orderStatus in new code.

WebSocket rows also carry extra fields (accountType, legIndex, mlegID, startTimeET, …) not present on the clean REST shape.

GET /orders/start-date/{date} is different again - each row is a fill-level trade record (tradeId, qty, price, fees), not an order object. See Get historical orders.

Field noteDetail
tradedSymbolEquals symbol for stocks. For options, symbol is the underlying ticker (e.g. "AAPL") and tradedSymbol carries the full OCC contract (e.g. "AAPL260717C00600000").
textPlain message string, or null. Carries the rejection reason for Rejected rows (R##: … or plain rejection text) and is null on accepted rows. On paper, accepted rows may carry a paper-session note in text instead of null. See Order rejections.
legCount / legs0 and null for single-leg equity and option orders. Multi-leg orders populate these once the spread is accepted.

Get a single order​

GET /v1/api/accounts/{accountId}/order/{clientOrderId}

Retrieve a specific order by its clientOrderId. This is more efficient than fetching all of today's orders and filtering client-side when you already know the id you're looking for.

Fetch a single order
curl 'https://webapi.tradezero.com/v1/api/accounts/TZP12345678/order/my-buy-001' \
-H 'TZ-API-KEY-ID: {YOUR_PUBLIC_KEY}' \
-H 'TZ-API-SECRET-KEY: {YOUR_SECRET}'

The response is the order object directly (not wrapped in an orders array). For stock and single-leg option orders - and for Mleg orders before their legs[] array is populated - the shape matches the POST /order response exactly. For filled multi-leg orders with populated leg data, the response may switch to the WebSocket field shape (account, userOrderId, cancelledQuantity, …) - see Options → Response. Returns 404 Not found if the clientOrderId is not found on the account.


Get historical orders​

GET /v1/api/accounts/{accountId}/orders/start-date/{startDate}

Retrieves up to one week of historical orders for your account. Only orders for live production accounts are returned - paper trading accounts do not have order history available and always respond with { "orders": [] }.

Rows here are trade-level, not order-level

Each row in the response represents an individual fill, not the originating order - the field set is different from GET /orders. Every row carries tradeId, qty, price, commission, totalFees, grossProceeds, netProceeds, tradeDate. There is no clientOrderId, no userOrderId, and no orderStatus on these rows, and a partial-filled order produces multiple rows (one per execution). Use this endpoint for post-trade reconciliation, daily P&L, fee/commission rollups, and audit trails. Today's full order book - including multi-day GTC orders from earlier sessions - lives in GET /orders.

Accepts ISO 8601 date format (YYYY-MM-DD). An ISO timestamp with a time component (URL-encoded) is also accepted.

Historical orders from 2026-05-01 onward
curl 'https://webapi.tradezero.com/v1/api/accounts/TTE12345678/orders/start-date/2026-05-01' \
-H 'TZ-API-KEY-ID: {YOUR_PUBLIC_KEY}' \
-H 'TZ-API-SECRET-KEY: {YOUR_SECRET}'
Response envelope - one row per fill
{
"orders": [
{
"tradeId": 238369493,
"accountId": "TTE12345678",
"symbol": "TSLA",
"securityType": "Stock",
"side": "Buy",
"qty": 1,
"price": 438.59,
"grossProceeds": -438.59,
"netProceeds": -439.62,
"commission": 0,
"totalFees": 0.99,
"currency": "USD",
"tradeDate": "2026-05-13T00:00:00",
"settleDate": "2026-05-14T00:00:00",
"entryDate": "2026-05-13T00:00:00",
"execTime": "06:10:12",
"canceled": false,
"mLegId": 0,
"spreadType": 0,
"notes": ""
}
]
}
FieldTypeNotes
tradeIdintegerUnique identifier for this individual fill (the primary key on every row).
accountIdstringAccount the trade settled on.
symbolstringSymbol traded.
securityTypestring"Stock" or "Option".
sidestring"Buy" or "Sell" (no enriched "SellShort" / "BuyToCover" here).
qtyintegerShares filled on this execution (not the originating order's quantity).
pricenumberPer-share fill price.
grossProceedsnumberqty × price with sign - negative on Buys (cash out), positive on Sells.
netProceedsnumbergrossProceeds minus totalFees and commission, sign-preserved.
commissionnumberCommission charged on this fill (0 on commission-free accounts).
totalFeesnumberAll non-commission fees (regulatory, exchange, etc.).
currencystringSettlement currency ("USD").
tradeDatestring (date-time)When the trade executed.
settleDatestring (date-time)T+1 settlement date.
entryDatestring (date-time)When the originating order was entered.
execTimestringHH:MM:SS local time of the execution.
canceledbooleantrue if the trade was busted/cancelled post-execution; skip these for activity counts.
mLegIdintegerMulti-leg ticket ID. 0 for single-leg orders.
spreadTypeintegerInternal spread classifier; 0 for ordinary single-leg fills.
notesstringFree-form notes string from the execution venue; empty unless the venue attaches a note.
Paper accounts have no order history

The historical-orders archive is populated only by live trades. Paper accounts always return { "orders": [] } here regardless of the date. For paper reconciliation, fall back to today's working/closed rows from GET /orders (different shape - see "Get today's orders").

Date format compatibility:

FormatExampleAccepted
YYYY-MM-DD2026-05-01✓
ISO 8601 with T and Z2026-05-01T00:00:00Z✓
ISO 8601 with UTC offset (URL-encoded)2026-05-01T00%3A00%3A00-04%3A00✓
Unpadded month/day2026-5-1✓
US-style MM-DD-YYYY05-01-2026✓
Far future2099-12-31✓ (empty array)
Unix epoch start1970-01-01✓ (empty array)
Compact YYYYMMDD20260501✗ - returns 404
Non-date stringnot-a-date✗ - returns 404

Use YYYY-MM-DD as the canonical format. Compact YYYYMMDD without separators is not accepted.

Paginated historical orders​

For longer lookbacks and large result sets, use the paginated variant:

GET /v1/api/accounts/{accountId}/orders-with-pagination/start-date/{startDate}
Query paramDefaultNotes
numberOfDays30Window length from startDate, up to 365 days
offset0Rows to skip (pagination)
limit100Page size (max 100)
symbol-Optional symbol filter

The response wraps rows in a tradingHistory array and includes a pagination object (totalRecords, currentOffset, currentLimit). Each row uses the same fill-level field set as GET /orders/start-date/{startDate} above - one row per execution, no clientOrderId. Live accounts only; paper returns empty history. Rate limit: 1 request/s per API key (API Rate Limits). Full parameter and schema details: Retrieve Historical Orders Paginated.

Common patterns​

End-of-day P&L recap. Pull the day's historical-order rows, skip rows where canceled is true, sum netProceeds for realised cash flow, and group by symbol + qty × price for a per-symbol VWAP:

async function dailyRecap(accountId: string, date: string) {
const res = await fetch(
`/v1/api/accounts/${accountId}/orders/start-date/${date}`,
{ headers: authHeaders() },
);
const { orders: rows } = await res.json() as { orders: HistoricalOrderRow[] };
const filled = rows.filter(r => !r.canceled);

const cashFlow = filled.reduce((a, r) => a + r.netProceeds, 0);
const fees = filled.reduce((a, r) => a + r.totalFees + r.commission, 0);
return { fills: filled.length, cashFlow, fees };
}

Full audit trail. For compliance exports or trade reconciliation, paginate by week (one week is the maximum history per request) and persist every row keyed on tradeId. Two fills on the same symbol at the same execTime from a partial-filled order will have distinct tradeIds.

To correlate a historical-orders row back to the originating order, match by symbol + tradeDate + side + qty + price against rows from GET /orders - there is no foreign key linking these rows to clientOrderId.


Cancel a specific order​

DELETE /v1/api/accounts/{accountId}/orders/{clientOrderId}

Attempts to cancel the order identified by clientOrderId in the path (the same value you assigned on POST /order).

Cancel by clientOrderId
curl 'https://webapi.tradezero.com/v1/api/accounts/TZP12345678/orders/my-buy-001' \
-X DELETE \
-H 'TZ-API-KEY-ID: {YOUR_PUBLIC_KEY}' \
-H 'TZ-API-SECRET-KEY: {YOUR_SECRET}'
Cancel behavior - three distinct failure modes

Cancel-by-id has three different failure shapes:

  1. 401 Unauthorized (JSON body) - auth headers are missing:

    { "statusCode": "Unauthorized", "message": "Token not provided", "detail": null }
  2. 404 Not found (plain text) - the order was not found. This covers: wrong clientOrderId, the order is not yet registered (poll GET /orders after POST /order before canceling), the order is already terminal (Filled, Canceled, Expired), or auth headers that do not grant access. For Rejected orders, cancel may return HTTP 200 and overwrite text with R130 instead of 404. Do not cancel rejected orders.

  3. 400 Bad Request (JSON body) - the path account ID doesn't match the keys you authenticated with (account mismatch on cancel):

    {
    "statusCode": "BadRequest",
    "message": "CancelOrderWithResponse",
    "detail": "Unable to fetch account orders from server. Account for User was not found, or User doesn't have entitlements."
    }

In all cases, poll GET /orders after the attempt to confirm the actual orderStatus. Do not infer success or failure solely from the cancel response.

For reliable bulk cancellation, use DELETE /v1/api/accounts/orders instead.

Canceling an already-rejected order overwrites text

If the order is already Rejected, DELETE /orders/{clientOrderId} returns HTTP 200 and replaces the original rejection reason with R130: Cancel Request Rejected: …. Cancels on other terminals (Filled, Canceled, Expired) typically return 404 Not found. Read the placement rejection from GET /order/{clientOrderId} before attempting cancel, and never cancel orders in Rejected status. See Order rejections → Do not cancel already-rejected orders.

AtTheOpening after venue cutoff

After the destination open-auction cutoff, an AtTheOpening order cannot be cancelled. See AtTheOpening (OPG).

On success, the response is a JSON object showing the canceled order's state, with canceledQuantity equal to the original orderQuantity. On failure, 404 Not found.


Cancel all orders​

DELETE /v1/api/accounts/orders

Cancels all open orders on the account. Optionally scope the cancel to a single symbol with a ?symbol= query parameter. This endpoint uses a multipart/form-data body - not JSON - to pass the account ID. The call succeeds with the same 200 message when the account has no open orders.

Cancel all orders on the account
curl 'https://webapi.tradezero.com/v1/api/accounts/orders' \
-X DELETE \
-H 'TZ-API-KEY-ID: {YOUR_PUBLIC_KEY}' \
-H 'TZ-API-SECRET-KEY: {YOUR_SECRET}' \
-F 'account=TZP12345678'
Cancel all AAPL orders only
curl 'https://webapi.tradezero.com/v1/api/accounts/orders?symbol=AAPL' \
-X DELETE \
-H 'TZ-API-KEY-ID: {YOUR_PUBLIC_KEY}' \
-H 'TZ-API-SECRET-KEY: {YOUR_SECRET}' \
-F 'account=TZP12345678'
Response (200 OK)
{
"message": "Cancel Request Submitted Successfully"
}
Cancel-all in TypeScript / fetch
const form = new FormData();
form.append('account', accountId);
const res = await fetch(
'https://webapi.tradezero.com/v1/api/accounts/orders',
{
method: 'DELETE',
headers: {
Accept: 'application/json',
'TZ-API-KEY-ID': apiKey,
'TZ-API-SECRET-KEY': apiSecret,
// Don't set Content-Type yourself - FormData generates the
// correct multipart/form-data boundary automatically.
},
body: form,
}
);

The account field in the body is required. Both multipart/form-data and application/x-www-form-urlencoded are accepted; sending a JSON body, a plain-text body, or omitting the account field entirely returns 404. The cancel is asynchronous - the 200 response confirms the request was received, not that orders are already gone. Poll GET /orders after a short delay to confirm.


Get available routes​

GET /v1/api/accounts/{accountId}/routes

Returns the routing destinations available on the account along with each route's supported order types, security types, and time-in-force values.

List routes
curl 'https://webapi.tradezero.com/v1/api/accounts/TZP12345678/routes' \
-H 'TZ-API-KEY-ID: {YOUR_PUBLIC_KEY}' \
-H 'TZ-API-SECRET-KEY: {YOUR_SECRET}'

The route inventory you get back depends on whether the credentials are paper or live - paper accounts expose a small synthetic set, live accounts expose real venues:

Response (paper account - 2 routes)
{
"routes": [
{
"orderTypes": ["Market", "Limit", "Stop", "StopLimit"],
"routeName": "PAPER",
"securityTypes": ["Stock", "Option"],
"timesInForce": ["Day", "GoodTillCancel", "GoodTillCrossing"],
"useDisplayQty": false
},
{
"orderTypes": ["Market", "Limit", "Stop", "StopLimit"],
"routeName": "PAPERM",
"securityTypes": ["MLEG"],
"timesInForce": ["Day"],
"useDisplayQty": false
}
]
}
Response (live account - five routes; orderTypes trimmed to placeable set)
{
"routes": [
{
"orderTypes": ["Market", "Limit", "Stop", "StopLimit"],
"routeName": "SMART",
"securityTypes": ["Stock"],
"timesInForce": ["Day", "GoodTillCancel", "AtTheOpening", "Day_Plus", "GTC_Plus"],
"useDisplayQty": true
},
{
"orderTypes": ["Market", "Limit", "Stop", "StopLimit"],
"routeName": "CTDL",
"securityTypes": ["Stock"],
"timesInForce": ["Day", "GoodTillCancel", "GoodTillCrossing"],
"useDisplayQty": false
},
{
"orderTypes": ["Market", "Limit", "Stop", "StopLimit"],
"routeName": "SMARTO",
"securityTypes": ["Option"],
"timesInForce": ["Day", "GoodTillCancel"],
"useDisplayQty": false
},
{
"orderTypes": ["Market", "Limit", "Stop", "StopLimit"],
"routeName": "SMARTM",
"securityTypes": ["Option", "MLEG"],
"timesInForce": ["Day"],
"useDisplayQty": false
},
{
"orderTypes": ["Market", "Limit", "Stop", "StopLimit"],
"routeName": "ARCA",
"securityTypes": ["Stock"],
"timesInForce": ["Day", "GoodTillCancel", "ImmediateOrCancel", "FillOrKill", "GoodTillCrossing"],
"useDisplayQty": true
}
]
}

A live account exposes routes such as:

RouteSecurity typesNotes
SMARTStockTradeZero's smart order router for equities. Advertises the widest equity TIF set (Day, GoodTillCancel, AtTheOpening, Day_Plus, GTC_Plus) and useDisplayQty: true for iceberg orders. See Order types, times in force, and session hours.
CTDLStockDirect-access route to a single venue. Useful when you want deterministic routing rather than smart-router discretion.
SMARTOOptionSmart router for single-leg options (Day, GoodTillCancel).
SMARTMOption, MLEGSmart router for multi-leg option spreads. Day-only.
ARCAStockDirect ECN route. Adds ImmediateOrCancel and FillOrKill TIFs that aren't on SMART. useDisplayQty: true.

The exact route set on a given live account depends on the account's subscription and configuration - query /routes and use what you actually get back rather than hard-coding a list. Route names differ from account to account, and some accounts have no SMART route at all, so always send a routeName that /routes returned for your account - for example "route": "SMART" for smart equity routing where it's available.

Send an explicit route on live accounts. Live orders submitted without route can be accepted with no route assigned and then reject with R54: Unable to reach the destination Route during routing. Paper accounts auto-assign PAPER for stocks/single-leg options and PAPERM for multi-leg option spreads when route is omitted. Sending an explicit route everywhere keeps your code portable between paper and live.

Order types accepted by POST /order

POST /order accepts only "Market", "Limit", "Stop", and "StopLimit". GET /routes can list other orderTypes; those are not accepted on place-order and must not be sent. Use /routes to discover destinations and TIFs; see Route metadata vs place-order.


Order types, times in force, and session hours​

Equity SMART eligibility for orderType × timeInForce in US Eastern (ET). Rejection codes: Rejection code reference. Options (SMARTO / SMARTM): Options Trading → Time in force.

SMART equities only

This matrix applies to route: "SMART" stock orders. Direct / DMA routes have their own type × TIF sets - check GET /routes (or MCP get_routes) for your account.

Session windows (ET)

4:00A–9:30Apre-market
9:30A–4:00Pregular hours
4:00P–8:00Pafter-hours

Placeable order types​

POST /order / MCP create_order accept only these four. Other orderTypes may appear on /routes - do not send them (Route metadata vs place-order).

API orderTypePlatformNotes
MarketMKTRTH only with Day
LimitLMTExtended Day window from 4:00A
StopStop-MKTRTH only with Day · not StopMarket
StopLimitStop-LMTOutside RTH use Day_Plus / GTC_Plus - see matrix

SMART eligibility matrix​

A time window means the pair is eligible on SMART. Not eligible means do not send that pair.

TIF (API)LabelMarketLimitStopStopLimit
DayDAY9:30A–4:00PFrom 4:00A9:30A–4:00PLive from 4:00A · paper 9:30A–4:00P
Day_PlusDAY+Not eligible (R135)4:00A–8:00PNot eligible (R135)4:00A–8:00P
GoodTillCancelGTCNot eligibleSubmit 12:30A–11:59P · active 9:30A–4:00PSubmit 12:30A–11:59P · active 9:30A–4:00PSubmit 12:30A–11:59P · active 9:30A–4:00P
GTC_PlusGTC+Not eligibleSubmit 12:30A–11:59P · active 4:00A–8:00PNot eligibleSubmit 12:30A–11:59P · active 4:00A–8:00P
AtTheOpeningOPGSubmit 4:00A–9:25A · executes at openSubmit 4:00A–9:25A · executes at openNot eligibleNot eligible

ImmediateOrCancel, FillOrKill, and GoodTillCrossing are not on SMART - see Direct-route TIF availability.

How to read the matrix​

  • RTH-only with Day: Market and Stop. Outside 9:30A–4:00P they reject. Live text is R100; paper text is R78 (Market) or R79 (Stop).
  • Extended Day Limit: eligible from 4:00A. After 4:00P, Limit Day remains eligible and can rest.
  • StopLimit × Day: on live, eligible in pre-market and RTH; after-hours rejects with R100. On paper, eligible only 9:30A–4:00P; outside that window rejects with R80. Outside RTH, send Day_Plus or GTC_Plus.
  • Day_Plus / GTC_Plus: Limit and StopLimit only. Market/Stop × Day_Plus → R135. Market × GTC/GTC_Plus → R23. Stop × GTC_Plus → R62.
  • GTC submit vs active: you can submit while inactive; non-execution (or DoneForDay "queued … regular hours") outside the active window is expected - see Submission vs execution windows.

Platform label → API enum​

Send API enums, not platform labels. Enums are case-sensitive.

Stop-MKT → Stop (there is no StopMarket). DAY+ → Day_Plus. OPG → AtTheOpening. GTC → GoodTillCancel.

PlatformAPI enumField
MKTMarketorderType
LMTLimitorderType
Stop-MKTStoporderType
Stop-LMTStopLimitorderType
DAYDaytimeInForce
DAY+Day_PlustimeInForce
GTCGoodTillCanceltimeInForce
GTC+GTC_PlustimeInForce
OPGAtTheOpeningtimeInForce
IOCImmediateOrCanceltimeInForce
FOKFillOrKilltimeInForce
GTXGoodTillCrossingtimeInForce

Submission vs execution windows​

For GoodTillCancel and GTC_Plus, submit and active windows differ.

TIFSubmit windowActive window
GoodTillCancel12:30A–11:59P ET (incl. weekends)9:30A–4:00P ET
GTC_Plus12:30A–11:59P ET (incl. weekends)4:00A–8:00P ET

Outside the active window, status stays PendingNew / New, or shows DoneForDay with queued-for-RTH text. That is expected - do not retry on "no fill." New submissions pause 11:59P–12:30A ET.

AtTheOpening (OPG)​

  • Types: Market and Limit only.
  • Submit: 4:00A–9:25A ET; executes at the symbol open (~9:30A).
  • Cutoffs are destination-specific (9:25A or 9:28A). Submit earlier in the window for consistent acceptance.
  • After the destination cutoff, cancel is not available - see Cancel a specific order.
  • Paper: paper simulation accepts AtTheOpening more broadly during RTH than live SMART. Use the SMART matrix and live route behavior as the source of truth.

Timezone​

Session rules use ET (Eastern local, including DST). API timestamps are UTC ISO-8601 - convert before applying the matrix. See API Conventions → Timestamps.

Local (ET)EDT (UTC−4)EST (UTC−5)
9:30A13:30 UTC14:30 UTC
4:00P20:00 UTC21:00 UTC

Direct-route TIF availability​

Trust GET /routes for your account. Live inventory from /routes:

RouteSecuritiesAdvertised TIFs
SMARTStockDay, GoodTillCancel, AtTheOpening, Day_Plus, GTC_Plus
CTDLStockDay, GoodTillCancel, GoodTillCrossing
ARCAStockDay, GoodTillCancel, ImmediateOrCancel, FillOrKill, GoodTillCrossing
SMARTOOptionDay, GoodTillCancel
SMARTMOption, MLEGDay
TIFOn SMART?Notes
ImmediateOrCancelNoAvailable on ARCA-class directs; not on paper.
FillOrKillNoAvailable where /routes lists it (for example ARCA); not on paper.
GoodTillCrossingNoFor SMART extended hours, use Day_Plus / GTC_Plus with Limit or StopLimit. On non-SMART directs, GTX is conventional session GTX.

Route metadata vs place-order​

GET /routes may list MarketOnClose, LimitOnClose, RangeOrder, and TrailStop. Place-order accepts only Market, Limit, Stop, and StopLimit. Use /routes for destination and TIF discovery. See API Conventions → GET /routes vs POST /order.

Paper vs live​

TopicPaperLive
AtTheOpeningPaper simulation can accept OPG during RTH, including shapes that live SMART rejects. Do not use paper to validate OPG eligibility.Market / Limit; submit 4:00A–9:25A ET
Market / Stop outside Day RTHRejected; Market R78, Stop R79Rejected; R100
StopLimit × DayEligible 9:30A–4:00P only; outside RTH → R80Eligible from 4:00A through RTH; after-hours → R100. Outside RTH use Day_Plus / GTC_Plus
IOC / FOKNot availableOnly on directs that advertise them

Order rejections​

When the order engine declines an order, the outcome is always HTTP 200 with an order object in the body - never a dedicated rejection HTTP status. The orderStatus field carries the outcome; the human-readable reason lives in text. Timestamps are on every row: startTime (submission) and lastUpdated (last state change, including when text is populated).

Confirm status after POST /order

On live routed orders (when you send an explicit route such as "SMART"), rejections fall into two patterns:

  1. Asynchronous (TradeZero route checks such as R24, R54, R95, and platform NBBO limits): POST /order returns PendingNew + text: null, then GET /order/{clientOrderId} shows Rejected with text within ~50 ms.

  2. Synchronous reject without reason text (R118 with an explicit route on live): POST /order returns orderStatus: "Rejected" and text: null, and the server rewrites clientOrderId by appending an INVALID suffix and a timestamp (for example my-order-idINVALID01/15/2026 09:30:00). GET /order/{yourOriginalClientOrderId} returns 404 even after polling. The row appears on GET /orders - still with text: null. The same INVALID suffix rewrite occurs on live R114 duplicate-clientOrderId POSTs. Clients who only poll GET /order with the original id miss the rejection until a cancel attempt surfaces R130.

R118 without an explicit route follows the async pattern instead: PendingNew → Rejected with full "R118: Stop price is on wrong side of quote" on GET.

Always read clientOrderId from the POST response for subsequent lookups - it may differ from the ID you sent. If single-order GET returns 404, scan GET /orders for a Rejected row with matching symbol, side, and startTime.

Where rejection reasons appear​

SurfaceWhen to useRejection behavior
POST /order responseImmediate ackMay show PendingNew + text: null even when the order will reject. Sync rejections (R114, R145, …) include text here. R118 with route may return sync Rejected + text: null and a rewritten clientOrderId.
GET /order/{clientOrderId}Single-order lookupSingle-order status endpoint. Poll after POST until terminal. Returns 404 if you use the pre-POST ID after an INVALID suffix rewrite - use the POST response clientOrderId or fall back to GET /orders. Immediately after POST it can return 404; retry before treating the order as missing.
GET /ordersToday's full order bookSame rejection rows as single-order lookup. For R118 with an explicit route, use GET /orders when single-order GET is 404. On async routed rejections, the book can list Rejected before single-order GET returns the terminal row.
Portfolio WebSocket Order pushReal-time blotterPushes two updates for many async rejections: first PendingNew, then Rejected with text. Subscribe before placing orders. See Portfolio Stream → Order rejections.
DELETE /orders/{clientOrderId}Cancel attemptNot a placement rejection channel. Canceling a Rejected order returns HTTP 200 and may overwrite text with R130; other terminals typically return 404 (see below).

Sync vs async rejection lifecycle​

Synchronous (reason on POST):

POST /order → orderStatus: "Rejected", text: "R145: Negative Price Is Not Allowed"
GET /order → same row, same text

Asynchronous (live routed orders - R24, R54, R95, platform NBBO; with route: "SMART"):

POST /order → orderStatus: "PendingNew", text: null
↓ ~50 ms
GET /order/{cid} → orderStatus: "Rejected", text: "R24: Your order cannot have a STOP price of Zero"
GET /orders → same row in orders[]
Portfolio WS Order → PendingNew push (no text), then Rejected push with text (if subscribed)

R118 without route follows the async pattern with full text:

POST /order → orderStatus: "PendingNew", text: null
↓ ~6 ms
GET /order/{cid} → orderStatus: "Rejected", text: "R118: Stop price is on wrong side of quote"

Synchronous reject without reason text (R118 with explicit route on live):

POST /order → orderStatus: "Rejected", text: null,
clientOrderId: "my-idINVALID07/02/2026 17:12:28" ← rewritten from "my-id"
GET /order/{originalId} → 404 (not found)
GET /order/{post.clientOrderId} → 404 (not found)
GET /orders → Rejected row present, text: null, rewritten clientOrderId

Integrators who poll only GET /order/{sentClientOrderId} will see 404 and no rejection reason. Do not cancel - that surfaces R130 instead.

Portfolio WebSocket: two pushes for async rejections​

On live routed orders, the Portfolio stream delivers two Order updates for the same clientOrderId:

Push 1 - immediately after POST
{
"action": "update",
"subscription": "Order",
"order": {
"clientOrderId": "my-stop-001",
"orderStatus": "PendingNew",
"text": null,
"symbol": "BABA",
"startTime": "2026-01-15T14:31:10.419+00:00",
"lastUpdated": "2026-01-15T14:31:10.419+00:00"
}
}
Push 2 - ~4 ms later (rejection landed)
{
"action": "update",
"subscription": "Order",
"order": {
"clientOrderId": "my-stop-001",
"orderStatus": "Rejected",
"text": "R118: Stop price is on wrong side of quote",
"symbol": "BABA",
"startTime": "2026-01-15T14:31:10.419+00:00",
"lastUpdated": "2026-01-15T14:31:10.423+00:00"
}
}

Use lastUpdated on the Rejected push as the rejection timestamp. Ignore the PendingNew push for user-facing error display - wait for terminal status or poll REST. Full WebSocket details: Portfolio Stream.

Poll until terminal after every POST /order
async function awaitOrderTerminal(accountId: string, clientOrderId: string) {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
const res = await fetch(
`${BASE}/v1/api/accounts/${accountId}/order/${encodeURIComponent(clientOrderId)}`,
{ headers: AUTH_HEADERS },
);
if (res.status === 404) {
await sleep(50);
continue; // registration race - retry
}
const order = await res.json();
if (order.orderStatus !== 'PendingNew') return order;
await sleep(50);
}
throw new Error(`Order ${clientOrderId} still PendingNew after 5s`);
}

Timestamps on rejected orders​

Every order row - including rejections - carries:

FieldMeaning
startTimeWhen the order was submitted (ISO 8601 UTC). Use this as the rejection timestamp when reporting to users if the order never reached a working state.
lastUpdatedWhen the row last changed. On async rejections, lastUpdated is a few milliseconds after startTime - that gap is when text and orderStatus: "Rejected" landed.

Example R118 rejection (route omitted):

{
"orderStatus": "Rejected",
"text": "R118: Stop price is on wrong side of quote",
"startTime": "2026-01-15T16:49:58.5373894Z",
"lastUpdated": "2026-01-15T16:49:58.5412753Z"
}

Reading the text field​

Rejection messages use two formats:

  1. R##: prefix - engine reason codes. Parse with /^(R\d+)/:

    const code = order.text?.match(/^(R\d+)/)?.[1]; // "R118"
    const message = order.text?.replace(/^R\d+:\s*/, ''); // "Stop price is on wrong side of quote"
  2. Venue rejection messages without an R## prefix - for example:

    Rejected: Broker Rejected Limit price too far from NBBO

    Treat the full string as the reason; do not assume every rejection starts with R##.

  3. null - the order is rejected but no reason string was surfaced. On R118 with an explicit route, expect this POST/GET shape - infer “stop on wrong side of quote” from order parameters, or scan GET /orders for the Rejected row. Some POST responses also rewrite clientOrderId with an INVALID suffix; log the full POST body and use GET /orders when GET /order returns 404.

On paper accounts, rejection codes and timing differ from live: invalid stops can fill, unsupported routes can be accepted, and text can carry a paper-session note instead of a live engine R-code. Build production rejection handling against live behavior.

Do not cancel already-rejected orders​

DELETE /orders/{clientOrderId} against a terminal Rejected order returns HTTP 200 (not 404), but the response overwrites the original rejection reason:

Before cancelAfter cancel
"text": "R118: Stop price is on wrong side of quote""text": "R130: Cancel Request Rejected: Original order not found or cancelable..."

R130 is a cancel failure, not the original placement rejection. If your UI only reads text after a cancel attempt, users will see R130 instead of R118/R95/etc.

Integration rule: when orderStatus === "Rejected", display text immediately and do not send cancel. Terminal orders (Rejected, Filled, Canceled, Expired) cannot be canceled.

Rejection code reference​

Codes by environment (paper vs live differ for session and order-type rejects). Session-hour codes (R78 / R79 / R80 / R100 / R135) also appear in Order types, times in force, and session hours.

CodeExample textTriggerPOST timing
R24R24: Your order cannot have a STOP price of ZeroStop/StopLimit without valid stopPriceAsync
R54R54: Unable to reach the destination RouteInvalid or missing routeAsync
R78R78: Market orders are not allowed at this timeMarket outside SMART Day RTH on paper (9:30A–4:00P ET) - see SMART route eligibility matrix. On live, the same class of reject is R100.Sync when outside that window
R79R79: Stop orders are not allowed at this timeStop outside SMART Day RTH on paper, or Stop × AtTheOpening. On live outside RTH, Stop × Day is R100.Sync
R80R80: Stop Limit orders are not allowed at this timeStopLimit outside the eligible Day window on paper, or StopLimit × AtTheOpening.Sync
R95R95: Cannot have opening buy and sell orders at the same timeOpening long and short on same symbolAsync (~4 ms)
R100R100: Order type not supported at this hourOn live: Market or Stop with Day outside RTH (pre-market and after-hours), and StopLimit with Day after-hours. Live StopLimit × Day is eligible in pre-market.Sync
R06R06: This symbol is restricted from trading at this priceLimit far from allowed bandAsync
R114R114: Invalid duplicate UserOrderIdReused clientOrderId in sessionSync
R118R118: Stop price is on wrong side of quoteStop on wrong side of quoteAsync without route; sync + text: null with route
R130R130: Cancel Request Rejected: …Cancel on a Rejected order (from DELETE, not placement). Other terminals typically return 404.-
R135R135: Only Limit and Stop-LMT orders are Day+ eligibleMarket or Stop with Day_Plus - see SMART route eligibility matrixSync
R145R145: Negative Price Is Not AllowedNegative limitPrice or stopPriceSync
(none)Rejected: Broker Rejected …Venue declined (no R## prefix)Async
(none)nullRisk check with no surfaced codeSync on POST

Parse R-codes from text:

const code = order.text?.match(/^(R\d+)/)?.[1] ?? null;

For schema-level failures (bad enum, missing field, fractional quantity), see Validation errors - those return HTTP 400 plain text, not orderStatus: "Rejected".

Handling rejections in client code​

const post = await res.json();

// Step 1: never stop at POST when status is PendingNew
const order =
post.orderStatus === 'PendingNew'
? await awaitOrderTerminal(accountId, post.clientOrderId)
: post;

// Step 2: act on terminal rejection
if (order.orderStatus === 'Rejected') {
const code = order.text?.match(/^(R\d+)/)?.[1] ?? null;
logRejection({
clientOrderId: order.clientOrderId,
code,
text: order.text ?? '(no reason surfaced)',
rejectedAt: order.lastUpdated ?? order.startTime,
});

// Do NOT call DELETE /orders/{cid} - see R130 overwrite above
return;
}

See also Handling R-code rejections and the Error handling template below.

Every order placement path - manual tickets, algos, batch submitters - should follow the same outcome resolution:

  1. Generate a unique clientOrderId before POST /order.
  2. Have a listener ready - Portfolio WebSocket subscribed to "Order" for the account before POST, or a poll loop on GET /order/{clientOrderId} and fallback to GET /orders.
  3. POST the order and read the response, but do not treat PendingNew as success.
  4. Capture post.clientOrderId from the response - it may differ from the ID you sent (R118 routed rejections append INVALID…).
  5. Resolve the final state within ~5 seconds:
    • WebSocket: wait for an Order update where orderStatus === "Rejected" (or another terminal status) and read text.
    • REST: poll GET /order/{post.clientOrderId} every 50 ms until status is no longer PendingNew (retry on 404). If still 404, scan GET /orders for a Rejected row with matching symbol / startTime.
  6. On Rejected, persist and display:
    • text (full reason string)
    • parsed R-code if present (order.text?.match(/^(R\d+)/)?.[1])
    • lastUpdated (or startTime if lastUpdated is absent) as the rejection timestamp
    • clientOrderId, symbol, side, orderType, route
  7. Do not call DELETE /orders/{clientOrderId} on rejected orders - see R130 overwrite.
Minimal REST-only rejection handler
async function placeAndResolve(accountId: string, body: OrderRequest) {
const res = await fetch(`${BASE}/v1/api/accounts/${accountId}/order`, {
method: 'POST',
headers: AUTH_HEADERS,
body: JSON.stringify(body),
});

if (!res.ok) throw new Error(`HTTP ${res.status}`);
const post = await res.json();

const final =
post.orderStatus === 'PendingNew'
? await awaitOrderTerminal(accountId, post.clientOrderId)
: post;

if (final.orderStatus === 'Rejected') {
return {
ok: false as const,
clientOrderId: final.clientOrderId,
code: final.text?.match(/^(R\d+)/)?.[1] ?? null,
reason: final.text ?? '(no reason surfaced)',
rejectedAt: final.lastUpdated ?? final.startTime,
};

}
return { ok: true as const, order: final };
}

Troubleshooting: "rejection not received over the API"​

SymptomCauseFix
POST returned PendingNew, no error shownClient stopped at POST body; rejection is asyncPoll GET /order/{post.clientOrderId} or listen on Portfolio WS
POST returned Rejected + text: null, GET → 404R118 with route (routed stop on wrong side) - server rewrote clientOrderIdUse GET /orders; do not cancel; infer R118 from order params
GET /order/{sentId} always 404 after POSTPOST response used a different clientOrderId (…INVALID… suffix)Poll with post.clientOrderId; fall back to GET /orders
User saw error only after cancel attemptCancel on Rejected order overwrote text with R130Read rejection before cancel; never cancel Rejected orders
GET /order/{cid} returned 404 once, client gave upRegistration race right after POSTRetry for 1–2 s before treating as missing
GET /orders shows Rejected but GET /order still NewAsync rejection appeared in GET /orders before GET /order updatedKeep polling GET /order or trust GET /orders row
No WebSocket rejection eventWS connected after POST, or not subscribed to "Order"Open WS and subscribe before placing; buffer messages during REST bootstrap
Only saw PendingNew on WebSocketIgnored the second pushAsync rejections send two WS updates - wait for Rejected
Reason missing from historical archiveGET /orders/start-date is fill-level onlyUse GET /orders (today's book) or your own rejection log keyed on clientOrderId

Validation errors​

There are two distinct classes of rejection to handle:

HTTP 400 - schema or body parse error. Returned when the request doesn't parse or a field fails JSON Schema validation before routing. The body is plain text (Content-Type: text/plain). Read res.text() and branch on the Content-Type header before parsing.

Condition400 plain-text body
symbol missing or contains characters outside ^[a-zA-Z0-9._-]+$ (including leading or trailing whitespace)- symbol: Does not match pattern '^[a-zA-Z0-9._-]+$'
Symbol contains an unescaped tab or newline inside the JSON string literalinvalid character '\t' in string literal
clientOrderId contains an unescaped control character (tab, newline, CR)invalid character '\t' in string literal
clientOrderId contains an unescaped double-quote or single-quoteinvalid character 'w' after object key:value pair (or similar JSON parse error)
side missing- (root): side is required
side not "Buy" or "Sell"- side: side must be one of the following: "Buy", "Sell"
orderType missing or not a valid value- orderType: orderType must be one of the following: "Limit", "Market", "Stop", "StopLimit"
securityType missing or not a valid value- securityType: securityType must be one of the following: "Stock", "Option", "Mleg"
timeInForce missing or not a valid value- timeInForce: timeInForce must be one of the following: "Day", "GoodTillCancel", "AtTheOpening", "ImmediateOrCancel", "FillOrKill", "GoodTillCrossing", "Day_Plus", "GTC_Plus"
orderQuantity is a non-integer number (e.g. 0.5)- orderQuantity: Invalid type. Expected: integer, given: number
orderQuantity missing / body not parseableinvalid character '<' looking for beginning of value (or similar parse error)
orderQuantity < 1- orderQuantity: Must be greater than or equal to 1
orderQuantity > 1,000,000- orderQuantity: Must be less than or equal to 1e+06
Empty bodyinvalid character '<' looking for beginning of value
Form-urlencoded or plain-text body404 Not found

For side, orderType, securityType, and timeInForce, wrong case (e.g. "buy", "LIMIT") returns 400. For openClose, always send "Open" or "Close" with that exact casing.

HTTP 200 with orderStatus: "Rejected". Some valid-looking requests are declined at routing time. The response is still HTTP 200 - read orderStatus and text to determine the outcome. On live routed orders, POST /order may return PendingNew first; poll GET /order/{clientOrderId} for the final reason. See Order rejections.

text field valueCondition
"R24: Your order cannot have a STOP price of Zero"Stop / StopLimit without a valid stopPrice
"R54: Unable to reach the destination Route"Invalid or unreachable route
"R78: Market orders are not allowed at this time"Market outside Day RTH on paper (live returns R100)
"R79: Stop orders are not allowed at this time"Stop outside Day RTH on paper, or Stop × AtTheOpening
"R80: Stop Limit orders are not allowed at this time"StopLimit outside the paper Day window, or StopLimit × AtTheOpening
"R100: Order type not supported at this hour"Live: Market/Stop × Day outside RTH; StopLimit × Day after-hours
"R135: Only Limit and Stop-LMT orders are Day+ eligible"Market or Stop with Day_Plus
"R95: Cannot have opening buy and sell orders at the same time"Attempted to open a long and a short on the same symbol simultaneously
"R114: Invalid duplicate UserOrderId"clientOrderId was already used in this session
"R118: Stop price is on wrong side of quote"Stop trigger on the wrong side of the current quote
"R130: Cancel Request Rejected: …"Cancel attempted on a Rejected order (from DELETE, not placement)
"R145: Negative Price Is Not Allowed"Negative limitPrice or stopPrice
"Rejected: Broker Rejected …"Venue declined the order (no R## prefix; legacy text format)
nullRejected without a surfaced reason - inspect account state; see Order rejections

HTTP 400 with JSON body - cancel-by-id account mismatch. When DELETE /orders/{clientOrderId} is sent against an account ID that does not match the keys you authenticated with, the API returns 400 with a JSON body:

{
"statusCode": "BadRequest",
"message": "CancelOrderWithResponse",
"detail": "Unable to fetch account orders from server. Account for User was not found, or User doesn't have entitlements."
}

This is distinct from the 404 Not found plain-text response, returned when the order was not found (wrong clientOrderId, order not yet registered, order already terminal, or auth headers that do not grant access).

Extra unknown fields

Sending additional JSON fields in the POST /order body (e.g. myCustomTag, strategy, notes) does not cause an error - only fields defined in the schema are processed; extra fields are ignored.

Stop-Limit price coherence

The server accepts StopLimit orders without checking that stopPrice and limitPrice are coherent for the direction of the order. A combination like Buy StopLimit with stopPrice: 100, limitPrice: 50 is accepted and placed, but the order will sit unfilled because the limit price is unreachable once the stop triggers. Check price coherence in your client before submitting a StopLimit order.


Order identity​

Dedup key, not idempotency

clientOrderId prevents duplicate submissions within a session but does not replay the original response on retry (unlike Stripe-style idempotency keys). After any accepted use - including cancel or reject - the same id cannot be reused (R114). On transport failure, reconcile with GET /order/{clientOrderId} before posting again; only submit a new order with a fresh id when the server never registered the first attempt. See API Conventions → clientOrderId.

clientOrderId​

clientOrderId is the recommended way to identify your orders. The server echoes the exact value you sent in POST /order, GET /order/{clientOrderId}, and GET /orders responses, and accepts it in the path of DELETE /v1/api/accounts/{accountId}/orders/{clientOrderId} to cancel an order. The same field is used for deduplication.

On Portfolio WebSocket Order pushes, the id appears as the suffix of userOrderId ("{accountId}:{clientOrderId}") rather than as a top-level clientOrderId - split on the first : to recover the bare id. See Order shape across REST and WebSocket.

Live exchange limits the client ID to 36 characters

On live accounts, keep clientOrderId ≤ 36 characters for reliable cancel-by-id at the venue. Paper accounts accept longer values. UUIDs without dashes are 32 characters; with dashes, 36.

Duplicate detection​

The server enforces uniqueness on clientOrderId per session. Submitting the same clientOrderId a second time returns HTTP 200 with "orderStatus": "Rejected" and "text": "R114: Invalid duplicate UserOrderId". The deduplication check also applies to concurrent submissions - if two requests carrying the same clientOrderId arrive simultaneously, exactly one is accepted and the rest are rejected with R114. Always generate a fresh unique value for each new order.

clientOrderId cannot be reused after cancellation

A clientOrderId is consumed permanently the moment it is accepted - even after the order is fully canceled or rejected, the same value cannot be sent again on a new POST /order. Re-submitting the same id will return R114. To "modify" an order, generate a new clientOrderId for the replacement (see Modifying an order). Treat clientOrderId like a UUID: one value, one order, forever.

clientOrderId character set​

The clientOrderId field has no declared character-set constraint in the schema, but certain characters cause problems at the JSON layer:

Character classBehavior
Alphanumerics, hyphens, underscoresSafe - recommended
Colons (:)Accepted by the server
Forward slashes (/)Accepted by the server
SpacesAccepted by the server (though use with care in URL paths)
Unicode (non-ASCII, including emoji)Accepted - the server handles UTF-8
Unescaped control characters (tab \t, newline \n, CR \r)400 - causes a JSON parse error
Unescaped quote characters (" or ')400 - breaks the JSON string literal
Null byte (\0)400 - causes a JSON parse error

Use URL-safe alphanumerics and hyphens (e.g. UUIDs) for maximum portability.

Auto-generated clientOrderId​

When you omit clientOrderId entirely or send clientOrderId: "" (an explicit empty string), the server generates a numeric clientOrderId in the format MMDDHHmmssSSS.NNNN - for example, 0513160835877.1906. This is constructed from the order timestamp. The generated ID is echoed back in the POST /order response and on GET /orders / GET /order/{clientOrderId} rows. It is usable for cancel-by-id.

clientOrderId: null does not auto-generate

Sending clientOrderId: null explicitly is different from omitting the field. Omit the field or send a non-empty string to receive an auto-generated ID usable for cancel-by-id. With null, the response echoes clientOrderId: "<no value>".

Opening buy + sell on the same symbol​

Placing both an opening Buy and an opening Sell (short) on the same symbol at the same time is not permitted. The second order returns HTTP 200 with "orderStatus": "Rejected" and "text": "R95: Cannot have opening buy and sell orders at the same time".

R95 rejection response
{
"orderStatus": "Rejected",
"text": "R95: Cannot have opening buy and sell orders at the same time",
"clientOrderId": "my-second-order",
"canceledQuantity": 1,
"executed": 0,
"leavesQuantity": 0
}

Order lifecycle​

A limit order moves through this lifecycle:

POST /order → orderStatus: "PendingNew" (accepted; routing in progress)
↓ (milliseconds)
GET /orders → orderStatus: "New" (acknowledged at venue, resting in book)
↓ (seconds to hours depending on market conditions)
GET /orders → orderStatus: "Filled" (fully executed)

orderStatus values (additional statuses exist for partial fills, pending replaces, and suspended orders):

orderStatusTerminal?Meaning
PendingNewNoGateway has accepted the order; routing in progress. Transitions to New within milliseconds.
AcceptedNoOrder acknowledged by the venue (returned on Portfolio WebSocket pushes). Functionally equivalent to New for blotter purposes - treat both as working.
NewNoOrder is resting in the order book at the venue, waiting to be filled.
PartiallyFilledNoSome shares have filled; order is still working for the remainder.
FilledYesFully executed. executed equals orderQuantity, leavesQuantity is 0.
PendingCancelNoCancel request received; awaiting confirmation from venue.
CanceledYesSuccessfully canceled. canceledQuantity is set to the canceled-out shares.
RejectedYesDeclined. Read text for the R-code reason. canceledQuantity carries the unfilled quantity.
DoneForDaySemiOrder expired at end of session. For GoodTillCancel and GTC_Plus orders it will resurface the next session; for all others it is effectively terminal.
ExpiredYesOrder expired (e.g. OPG order not filled at the open).

To cancel a resting order:

DELETE /orders/{clientOrderId} → 200 OK (cancel request sent)
↓
GET /orders → orderStatus: "Canceled"

Polling cadence: After placing an order, poll GET /order/{clientOrderId} every 50 ms until the status leaves PendingNew - rejections on live routed orders land within ~50 ms. When listing the full book, allow up to 1–2 seconds before GET /orders includes the new row. For real-time outcomes without polling, subscribe to the Portfolio WebSocket stream, which pushes order state changes (PendingNew, Accepted, Filled, Canceled, Rejected, etc.) as they occur. See Order rejections.

Terminal statuses: Filled, Canceled, Rejected, Expired. An order in a terminal state must not be canceled - DELETE /orders/{clientOrderId} on a Rejected order returns HTTP 200 but overwrites text with R130, replacing the original rejection reason. Other terminal orders may return 404 Not found from cancel. DoneForDay is semi-terminal: GTC/GTC+ orders may resurface the next session; Day orders in DoneForDay are effectively terminal.


Modifying an order​

There is no PUT or PATCH endpoint for orders. To change the price, quantity, time-in-force, or any other field of a working order, follow the cancel-then-replace pattern:

  1. DELETE /v1/api/accounts/{accountId}/orders/{originalClientOrderId} - cancel the working order.
  2. Wait for the cancellation to settle. Poll GET /orders until the original order's orderStatus is Canceled.
  3. POST /v1/api/accounts/{accountId}/order - submit a fresh order with a new clientOrderId and the updated fields.
interface OrderRequest {
securityType: 'Stock' | 'Option' | 'Mleg';
symbol: string;
side: 'Buy' | 'Sell';
openClose: 'Open' | 'Close';
orderType: string;
orderQuantity: number;
timeInForce: string;
clientOrderId: string;
[key: string]: unknown;
}

async function modifyOrder(
accountId: string,
originalRequest: OrderRequest,
updates: Partial<OrderRequest>,
): Promise<unknown> {
const base = 'https://webapi.tradezero.com/v1/api';
const originalId = encodeURIComponent(originalRequest.clientOrderId);

const cancel = await fetch(
`${base}/accounts/${encodeURIComponent(accountId)}/orders/${originalId}`,
{ method: 'DELETE', headers: authHeaders() },
);
if (!cancel.ok) {
throw new Error(`Cancel failed: HTTP ${cancel.status} ${await cancel.text()}`);
}

// Poll until Canceled (or Filled) - do not use a fixed sleep.
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
const res = await fetch(
`${base}/accounts/${encodeURIComponent(accountId)}/order/${originalId}`,
{ headers: authHeaders() },
);
if (!res.ok) {
throw new Error(`Status check failed: HTTP ${res.status} ${await res.text()}`);
}
const current = (await res.json()) as { orderStatus: string };
if (current.orderStatus === 'Filled') {
throw new Error('Original order filled before replacement could be submitted');
}
if (current.orderStatus === 'Canceled') {
break;
}
await new Promise((r) => setTimeout(r, 250));
}

const replacement = {
...originalRequest,
...updates,
clientOrderId: crypto.randomUUID(),
};

const create = await fetch(
`${base}/accounts/${encodeURIComponent(accountId)}/order`,
{
method: 'POST',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(replacement),
},
);
if (!create.ok) {
throw new Error(`Replacement failed: HTTP ${create.status} ${await create.text()}`);
}
return create.json();
}
Partial fills before cancel settles

If the original order partially fills before cancellation completes, recompute replacement quantity from open size - do not blindly resubmit the original orderQuantity.

Why a fresh clientOrderId?

A clientOrderId is consumed at the moment it's accepted, regardless of where the order ends up. Reusing the same id on a replacement order - even after a clean cancellation - returns R114 (Invalid duplicate UserOrderId). Generate a new id for every POST /order.

Race window between cancel and replacement

Between steps 1 and 3 there is a brief window in which a partial fill on the original order is still possible. If your strategy cannot tolerate a partial fill on the original price, place the replacement only after GET /orders confirms the original is in the Canceled (or fully Filled) terminal state.


Common workflows​

Place a limit buy with a stop-loss​

When the trader has chosen their entry and stop levels, your application can enter the long position with a limit order and then submit the trader's stop-loss once the entry fills. The key rule: poll until the entry is in Filled state before placing the protective stop - placing the stop-loss while the entry is still PendingNew may result in a dangling stop if the entry never fills.

// 1. Buy 100 AAPL at limit
const entry = await placeOrder({
securityType: 'Stock', symbol: 'AAPL',
side: 'Buy', openClose: 'Open',
orderType: 'Limit', limitPrice: 195.00,
orderQuantity: 100, timeInForce: 'Day',
clientOrderId: 'entry-aapl-001',
});

// Reject if the placement itself failed at routing time
if (entry.orderStatus === 'Rejected') {
throw new Error(`Entry order rejected: ${entry.text}`);
}

// 2. Poll until the entry order fills, then attach a stop-loss.
// The Portfolio WebSocket stream is more efficient for production use.
let filled = false;
for (let i = 0; i < 30 && !filled; i++) {
await sleep(2000);
const orders = await getOrders(); // GET /orders → match by clientOrderId
const e = orders.find(o => o.clientOrderId === 'entry-aapl-001');
filled = e?.orderStatus === 'Filled';
}

if (filled) {
await placeOrder({
securityType: 'Stock', symbol: 'AAPL',
side: 'Sell', openClose: 'Close',
orderType: 'Stop', stopPrice: 190.00,
orderQuantity: 100, timeInForce: 'GoodTillCancel',
clientOrderId: 'stop-aapl-001',
});

}

For a position-aware variant that reads priceAvg off /positions and sizes the stop at a configurable percentage, see the Stop-Loss on a Long Position recipe.

Short with pre-borrow check​

Before placing a short order, confirm the symbol is easy to borrow. Hard-to-borrow symbols require a locate reservation through the Short Locates workflow before the short can be placed.

// 1. Check borrow availability
const etb = await fetch(
`/v1/api/accounts/${accountId}/is-easy-to-borrow/symbol/TSLA`,
{ headers }
).then(r => r.json());

if (!etb.isEasyToBorrow) {
throw new Error('TSLA is not easy to borrow - use Short Locates to reserve shares');
}

// 2. Place short
await placeOrder({
securityType: 'Stock', symbol: 'TSLA',
side: 'Sell', openClose: 'Open',
orderType: 'Limit', limitPrice: 350.00,
orderQuantity: 50, timeInForce: 'Day',
clientOrderId: 'short-tsla-001',
});

When the symbol is hard-to-borrow, the Locate & Sell Short recipe shows the full quote → accept → short flow against a reserved locate.

End-of-day cleanup​

Cancel all open orders and close any remaining positions before the session ends. Always cancel first - if you close positions before canceling, the working orders may fill and reopen what you just closed.

// Cancel all open orders, then flatten any residual positions
const form = new FormData();
form.append('account', accountId);
await fetch('https://webapi.tradezero.com/v1/api/accounts/orders', {
method: 'DELETE',
headers: { 'TZ-API-KEY-ID': key, 'TZ-API-SECRET-KEY': secret },
body: form,
});

For the full cancel-then-flatten workflow with verification and per-order fallback, see the Cancel All Open Orders and Flatten All Positions recipes.

Handling R-code rejections​

Routing-time rejections arrive as HTTP 200 with orderStatus: "Rejected". Extract the R-code from the text field and dispatch accordingly:

const data = await res.json();

if (data.orderStatus === 'Rejected') {
const code = data.text?.match(/^(R\d+)/)?.[1];
switch (code) {
case 'R78':
throw new Error('Market orders are not permitted outside Regular Trading Hours. Use a Limit order with Day_Plus or GTC_Plus instead.');
case 'R95':
throw new Error('Cannot open both a long and a short on the same symbol simultaneously. Cancel the existing open order first.');
case 'R114':
// Note: a clientOrderId is consumed permanently - even after a clean
// cancellation the same id cannot be reused. Always generate a new id
// for every POST /order, including replacements / modifications.
throw new Error('Duplicate clientOrderId. Generate a new unique ID for each order (ids are not reusable after cancellation).');
case 'R118':
throw new Error('Stop price is on the wrong side of the current quote for this order direction.');
case 'R130':
// From DELETE /orders/{id} on a terminal order - not a placement rejection.
throw new Error('Cancel rejected - order is already terminal. Read text before canceling.');
default:
console.warn('Order rejected:', data.text ?? '(no reason)');
}
}

Error handling​

Every POST /order call requires two levels of error checking:

  1. HTTP-level - !res.ok catches 400 (schema error, plain-text body), 401 (cancel endpoint auth), 404 (auth/account on GET and POST), and 405 (wrong method).
  2. Application-level - the HTTP status can be 200 while the order is still rejected. Always read orderStatus after parsing.
Error handling template
const res = await fetch(url, { method: 'POST', body: JSON.stringify(order), headers });

if (!res.ok) {
// 400 = schema validation error (plain text body - branch on Content-Type)
// or account mismatch on POST/DELETE (JSON body: {statusCode, message, detail})
// 401 = missing auth on DELETE /orders/{id} (JSON body: {statusCode, message})
// 404 = auth failure or unknown account on GET / POST
// 405 = wrong HTTP method
const contentType = res.headers.get('content-type') ?? '';
const body = contentType.includes('application/json')
? await res.json()
: await res.text();
throw new Error(`HTTP ${res.status}: ${JSON.stringify(body)}`);
}

const data = await res.json();

if (data.orderStatus === 'Rejected') {
// Routing-time rejection. data.text carries the R-code, or null for unlabeled rejections.
console.warn('Order rejected:', data.text ?? '(no reason code)');
}

FAQs​

For async rejections, R130 cancel rules, and cancel semantics, stay on this page - especially Order rejections and Rejection code reference.


Building a complete order management integration​

The sections below cover the integration patterns you need once you move beyond individual order calls - response normalization, live order books, position-aware sizing, extended-hours handling, and portfolio liquidation.

Order shape across REST and WebSocket​

POST /order, GET /order/{clientOrderId}, GET /orders (each row), and the Portfolio WebSocket Order push all describe the same conceptual object - an order - but WebSocket (and some filled Mleg lookups) use different wire field names than REST.

ShapeEndpointsAccount-id fieldClient-id fieldQuantity fields
CleanPOST /order; GET /order/{clientOrderId}; GET /orders (each row); Mleg GET /order/{cid} before legs[] is populatedaccountIdclientOrderId (bare)canceledQuantity, lastQuantity, leavesQuantity, maxDisplayQuantity
WebSocket (enriched)Portfolio WebSocket Order push; GET /order/{clientOrderId} for Mleg orders once legs[] is populatedaccountuserOrderId = "{accountId}:{clientOrderId}" (split on :)cancelledQuantity (double l), lastQty, leavesQuantity (also lvsQty), maxDisplayQty

The WebSocket shape carries extra fields the clean REST shape doesn't (accountType, condition, legIndex, mlegID, startTimeET, status alias of orderStatus, …). The values that overlap match between the two shapes; only the keys differ.

Practical rules:

  • Write a small normalizer that maps the WebSocket shape onto the clean shape (account → accountId, split userOrderId into accountId + clientOrderId, cancelledQuantity → canceledQuantity, lastQty → lastQuantity, maxDisplayQty → maxDisplayQuantity). Run it on every Portfolio WebSocket Order message and every GET /order/{cid} response that has a non-null legs[]. REST responses from POST /order, GET /orders, and most GET /order/{cid} calls are already in the clean shape.
  • Pick clientOrderId as your stable join key. After normalization it's identical across all four sources, and the same value you sent on POST /order.
  • Seed your local cache from GET /orders on startup (today's order book - all statuses - plus working multi-day GTCs), then apply Portfolio WebSocket pushes to keep it live. Treat orderStatus: "Filled", "Canceled", "Rejected", "Expired", or "DoneForDay" as terminal.
  • The side field on GET /orders rows is enriched for short/cover trades - see The side field - enriched values below. WebSocket pushes carry the raw "Buy" / "Sell" you sent. If you display labels from WS messages, derive them yourself from side + openClose.
  • text carries the rejection reason for Rejected rows ("R##: …") and is null for accepted rows; on paper, accepted rows may carry a paper-session note in text. Check text explicitly when you act on rejection codes.

The side field - enriched values in order history​

When the server returns order rows from GET /orders, the side field carries an enriched value that reflects the trader action - not just the raw "Buy" or "Sell" you sent. This lets display layers show the correct label without re-deriving it from openClose.

side in requestopenClose in requestside in GET /orders responseTrader-facing label
"Buy""Open""Buy"Buy
"Sell""Close""Sell"Sell
"Sell""Open""SellShort"Short
"Buy""Close""Buy" (Cover orders return raw "Buy" - derive the Cover label client-side)Cover

Enrichment is a GET /orders read-only annotation. A POST /order response with side: "Buy", openClose: "Close" carries "side": "Buy" - not "BuyToCover". WebSocket pushes also carry the raw wire value. To label Cover orders reliably, derive the label client-side from side: "Buy" + openClose: "Close" rather than expecting a "BuyToCover" enrichment in the response.

Never send "SellShort" or "BuyToCover" in a POST /order request - the schema rejects any value other than "Buy" or "Sell" with a 400 error.

Building a live order book - and pairing it with execution history​

GET /orders returns today's order book - every session row regardless of status, plus still-working multi-day GoodTillCancel / GTC_Plus from prior sessions. Recently filled or canceled orders may appear briefly; filled market orders can drop out quickly. To get a "still working right now" blotter, filter the response to the working states:

const WORKING = new Set(['New', 'PendingNew', 'Accepted', 'PartiallyFilled'])
const HARD_TERMINAL = new Set(['Filled', 'Canceled', 'Rejected', 'Expired', 'DoneForDay'])

async function getWorkingOrderBook(): Promise<TZOrder[]> {
const all = await fetchOrders() // GET /orders → orders[]
// Note: DoneForDay rows on GTC TIFs may resurface next session; drop them
// here for a "still working right now" view.
return all.filter((o) => WORKING.has(o.orderStatus))
}

The companion endpoint, GET /orders/start-date/{date}, returns historical orders - the post-trade record at the fill level, with different fields (tradeId, qty, price, commission, totalFees, grossProceeds, netProceeds, tradeDate). It exists to support post-trade reconciliation, P&L recaps, fee/commission audits, and compliance exports, and supports up to one week of history per request. Combine it with GET /orders when you need a full picture of recent account activity:

const weekAgo = new Date(Date.now() - 7 * 86_400_000)
.toISOString().slice(0, 10) // YYYY-MM-DD
const [working, history] = await Promise.all([
getWorkingOrderBook(), // current intent
fetchHistoricalOrders(weekAgo) as Promise<TZHistoricalOrderRow[]>, // past activity
])
// `working`: order rows you can still cancel / amend.
// `history`: immutable per-fill rows (indexed by tradeId).

To correlate a historical-orders row back to its originating order, match on symbol + tradeDate + side + qty + price - these rows do not carry clientOrderId. See the End-of-Day Recap recipe for a working historical-orders example.

Which actions are available given a position​

The available trader actions for a symbol depend on whether the account holds a position and whether the symbol is easy to borrow. TradeZero Platforms apply that logic in the UI; the API accepts the order request and returns a rejection in text when the action is not allowed.

type TraderAction = 'Buy' | 'Short' | 'Sell' | 'Cover' | 'Locate'

function getAvailableActions(
positionSide: 'long' | 'short' | 'none',
isEasyToBorrow: boolean | null,
hasLocates: boolean,
): TraderAction[] {
if (positionSide === 'long') return ['Buy', 'Sell']
if (positionSide === 'short') return ['Short', 'Cover']
if (isEasyToBorrow === true || hasLocates) return ['Buy', 'Short']
if (isEasyToBorrow === false) return ['Buy', 'Locate'] // needs a locate before shorting
return ['Buy', 'Short']
}

Extended-hours order coercion​

Outside SMART Day regular hours (before 9:30A or after 4:00P ET), Market and Stop with Day are rejected - live R100, paper Market R78 / Stop R79. StopLimit with Day is eligible on live from 4:00A through RTH and rejects after-hours (R100); on paper it is RTH-only (R80 outside). Outside RTH, send StopLimit with Day_Plus or GTC_Plus. For extended-hours trading, coerce to types and TIFs that the SMART route eligibility matrix allows:

  • Market → Limit at the last traded price (or mid-point of bid/ask)
  • StopLimit with Day → keep StopLimit but switch TIF to Day_Plus (or GTC_Plus)
  • Prefer Day_Plus or GTC_Plus with Limit or StopLimit only - never Market or Stop with those TIFs (R135)
  • GoodTillCancel in the extended session → GTC_Plus (Limit / StopLimit only)
function mapTifForExtended(tif: string, orderType: string): string {
// Day_Plus / GTC_Plus accept Limit and StopLimit only on SMART.
if (orderType === 'Market' || orderType === 'Stop') {
throw new Error('Use Limit or StopLimit with Day_Plus / GTC_Plus in extended hours')
}
if (tif === 'GoodTillCancel' || tif === 'GTC' || tif === 'GTC+' || tif === 'GTC_Plus') {
return 'GTC_Plus'
}
return 'Day_Plus'
}

async function placeWithExtendedHoursCoercion(
order: TZPlaceOrderRequest,
lastPrice: number,
): Promise<TZPlaceOrderRequest> {
const extended = isExtendedHours()
if (!extended) return order

const orderType = order.orderType === 'Market' ? 'Limit' : order.orderType
return {
...order,
orderType,
timeInForce: mapTifForExtended(order.timeInForce, orderType),
...(order.orderType === 'Market' && !order.limitPrice
? { limitPrice: lastPrice }
: {}),
}
}

Position-aware order sizing​

Client applications can size orders dynamically rather than using a fixed share count. Sizing approaches:

ApproachCalculation
Fixed sharesquantity = config.shares
Dollar amountquantity = Math.floor(dollarAmount / lastPrice)
% of buying powerquantity = Math.floor((buyingPower * pct) / lastPrice)
% of current positionquantity = Math.round(Math.abs(position.shares) * pct)
Risk-based (dollar risk)quantity = Math.floor(riskAmount / stopDistance)
Risk-based (% of equity)quantity = Math.floor((equity * pct) / stopDistance)

Always enforce a minimum of 1 share and a maximum of 1,000,000 (the API's orderQuantity ceiling). For position-based sizing, validate that you are sizing in the correct direction - a Sell order should check shares > 0, a Cover should check shares < 0.

Resolving the limit price from a quote​

Rather than requiring a trader to type in a limit price each time, derive it from the current quote using rules the trader configures. Pick a price source and apply an optional offset (dollar or percentage): a Buy uses ask + offset; a Sell uses bid - offset; stop orders derive stopPrice from lowOfDay (longs) or highOfDay (shorts).

Price sources:

SourceDescription
bidNational best bid
askNational best ask
lastLast traded price
midpoint(bid + ask) / 2
highOfDayToday's session high
lowOfDayToday's session low
avgEntryThe priceAvg of the current open position on this symbol
type PriceSource = 'bid' | 'ask' | 'last' | 'midpoint'
| 'highOfDay' | 'lowOfDay' | 'avgEntry'

interface Quote {
bid: number; ask: number; price: number;
high: number; low: number;
}

function resolveLimitPrice(
source: PriceSource | undefined,
offset: number | undefined, // 0.05 = 5 cents (or 5%)
offsetType: 'dollar' | 'percent' | undefined,
quote: Quote | null,
position?: { priceAvg: number },
): number | undefined {
if (!source || !quote) return undefined

let base: number | undefined
switch (source) {
case 'bid': base = quote.bid; break
case 'ask': base = quote.ask; break
case 'last': base = quote.price; break
case 'midpoint': base = (quote.bid + quote.ask) / 2; break
case 'highOfDay': base = quote.high; break
case 'lowOfDay': base = quote.low; break
case 'avgEntry': base = position?.priceAvg; break
}

if (base === undefined || base <= 0) return undefined
const off = offset ?? 0
if (off === 0) return base
return offsetType === 'percent' ? base * (1 + off / 100) : base + off
}

Cancel filters - by side, by symbol, first, last​

The API provides two cancel primitives: cancel a specific order by ID, or cancel all open orders (optionally scoped to a symbol). More targeted patterns - cancel all buy-side orders, cancel only shorts, cancel the most recent order - are built client-side by fetching the open order list and issuing individual cancels:

async function cancelMatching(
predicate: (o: TZOrder) => boolean,
): Promise<void> {
const orders = await getOrders() // GET /orders
const targets = orders.filter(predicate)
for (const o of targets) {
await cancelOrder(o.clientOrderId) // DELETE /orders/{clientOrderId}
}
}

// Cancel all open Buy orders
await cancelMatching(o => o.side === 'Buy' && !isTerminal(o.orderStatus))

// Cancel all open Sells on AAPL
await cancelMatching(o =>
o.symbol === 'AAPL' && o.side === 'Sell' && !isTerminal(o.orderStatus))

// Cancel all opening shorts (semantic side from /orders is "SellShort")
await cancelMatching(o => o.side === 'SellShort' && !isTerminal(o.orderStatus))

// Cancel all covering orders. Cover orders return raw `side: "Buy"`,
// so derive Cover semantics from side + openClose instead of relying on `side`.
await cancelMatching(o =>
o.side === 'Buy' && o.openClose === 'Close' && !isTerminal(o.orderStatus))

// Cancel the first open order on a symbol
const open = (await getOrders())
.filter(o => o.symbol === 'AAPL' && !isTerminal(o.orderStatus))
if (open.length) await cancelOrder(open[0].clientOrderId)

// Cancel the most recent open order on a symbol
if (open.length) await cancelOrder(open[open.length - 1].clientOrderId)

function isTerminal(s: string | undefined): boolean {
// Hard terminals - order cannot be canceled and will not resurface.
// Note: DoneForDay is semi-terminal (Day TIF = effectively terminal,
// GTC / GTC_Plus = may resurface next session). For cancel-eligibility
// checks, treat DoneForDay as terminal too - it can't be canceled either way.
return s === 'Filled' || s === 'Canceled' || s === 'Rejected'
|| s === 'Expired' || s === 'DoneForDay'
}

For symbol-scoped cancel-all, prefer the API's native ?symbol= parameter - it's atomic and avoids the race window between GET /orders and DELETE /orders/{id}:

// Native symbol-scoped cancel - single round-trip
const form = new FormData()
form.append('account', accountId)
await fetch(`/v1/api/accounts/orders?symbol=AAPL`, {
method: 'DELETE',
headers: { 'TZ-API-KEY-ID': key, 'TZ-API-SECRET-KEY': secret },
body: form,
})

Liquidation workflow - flatten everything​

To flatten all open positions cleanly, cancel all working orders first (so they can't fill into the position you're trying to close), then issue a closing order for each non-zero position:

async function liquidateAll(): Promise<void> {
// 1. Cancel all open orders first - otherwise they may fill into
// a position you're trying to close, creating an overshoot.
await cancelAllOrders()

// 2. Read current positions
const positions = await getPositions()
const extended = isExtendedHours()

for (const pos of positions) {
if (!pos.shares || pos.shares === 0) continue

const action = pos.shares > 0 ? 'Sell' : 'Cover'
const { side, openClose } = resolveOrderAction(action)
const qty = Math.abs(pos.shares)

// For options, the position row has the underlying ticker in `symbol`
// and the OCC contract in `tradedSymbol`. POST /order on a single-leg
// option needs the OCC string, so fall back to tradedSymbol when present.
const orderSymbol = pos.tradedSymbol ?? pos.symbol

await placeOrder({
securityType: pos.securityType as 'Stock' | 'Option',
symbol: orderSymbol,
side,
openClose,
orderQuantity: qty,
// Use Market during RTH for guaranteed fill;
// coerce to Limit at last price during extended hours.
// Omitting limitPrice on a Limit order sets it to 0, which the venue rejects.
// pos.priceAvg is the average cost basis; substitute a live quote
// from your market-data feed for tighter fills.
orderType: extended ? 'Limit' : 'Market',
...(extended && { limitPrice: pos.priceAvg }),
timeInForce: extended ? 'Day_Plus' : 'Day',
})
}
}

Per-symbol liquidation workflow​

Closing a single symbol's position mirrors the full-account liquidation but scopes both the cancel and the close to one symbol:

async function liquidateSymbol(symbol: string): Promise<void> {
// 1. Cancel any working orders on this symbol so they can't fill
// into the position you're trying to close.
const form = new FormData()
form.append('account', accountId)
await fetch(`/v1/api/accounts/orders?symbol=${encodeURIComponent(symbol)}`, {
method: 'DELETE',
headers: { 'TZ-API-KEY-ID': key, 'TZ-API-SECRET-KEY': secret },
body: form,
})

// 2. Read the current position. For options, the position row's `symbol`
// is the underlying ticker - match the user-supplied filter on either
// `symbol` (underlying) or `tradedSymbol` (OCC) to handle both.
const positions = await getPositions()
const target = symbol.toUpperCase()
const pos = positions.find(
(p) => p.symbol.toUpperCase() === target || (p.tradedSymbol?.toUpperCase() ?? '') === target,
)
if (!pos || !pos.shares || pos.shares === 0) return // nothing to close

// 3. Place a closing order in the correct direction.
// Use tradedSymbol (OCC) for options, plain symbol for stocks.
const action = pos.shares > 0 ? 'Sell' : 'Cover'
const { side, openClose } = resolveOrderAction(action)
const extended = isExtendedHours()
const orderSymbol = pos.tradedSymbol ?? pos.symbol
const lastPrice = extended ? (await getQuote(orderSymbol)).price : undefined

await placeOrder({
securityType: pos.securityType as 'Stock' | 'Option',
symbol: orderSymbol,
side,
openClose,
orderQuantity: Math.abs(pos.shares),
orderType: extended ? 'Limit' : 'Market',
timeInForce: extended ? 'Day_Plus' : 'Day',
...(extended && lastPrice ? { limitPrice: lastPrice } : {}),
})
}

You can liquidate a partial size by multiplying Math.abs(pos.shares) by a fraction (e.g. 0.5 to close half the position). Always round to a whole share with Math.max(1, Math.round(...)).


Additional resources​

Recipes​

Ready-to-run recipes for the patterns described above:

  • Place Your First Order - buying-power check, submit, then read back the fill state. The fastest path from API key to a working order.
  • Limit Order and Cancel - submit a resting limit, confirm it's working, cancel, and poll until the row reaches a terminal status.
  • Watch a Single Order - poll GET /order/{clientOrderId} until the order reaches a terminal state, with backoff and timeout.
  • Poll Orders & Detect Fills - track a set of clientOrderIds across GET /orders and emit transition events when statuses change.
  • Cancel All Open Orders - bulk cancel via DELETE /accounts/orders plus the per-order fallback for stragglers.
  • Pre-Trade Validation - verify account status, buying power, route, position direction, and option level before submitting.
  • Trader Actions Wire Format - round-trip the four trader actions (Buy / Sell / Short / Cover) through side + openClose and decode them back from GET /orders.
  • Stop-Loss on a Long Position - read /positions, attach a stop order at a configurable percentage below priceAvg, and watch the row close out when the stop is hit.
  • End-of-Day Recap - pull historical fills and produce a daily trade recap summary.
  • Route-Aware Order - query /routes, pick a destination by securityTypes / orderTypes, and submit with an explicit route.
  • clientOrderId dedup and safe retry - reconcile before retry so a network failure never produces a duplicate order.