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
| Method | Path | Purpose |
|---|---|---|
GET | /v1/api/accounts/{accountId}/routes | List available routing destinations and the order-type / TIF combinations each supports |
GET | /v1/api/accounts/{accountId}/orders | Today'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}/order | Place a new order |
DELETE | /v1/api/accounts/{accountId}/orders/{clientOrderId} | Cancel a specific order by clientOrderId |
DELETE | /v1/api/accounts/orders | Cancel 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.
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.
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:
| Action | side | openClose | When to use |
|---|---|---|---|
| Buy | Buy | Open | Enter a new long position |
| Sell | Sell | Close | Exit an existing long position |
| Short | Sell | Open | Enter a new short position (borrows shares) |
| Cover | Buy | Close | Exit 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.
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}'
{ "isEasyToBorrow": true }
| Field | Type | Meaning |
|---|---|---|
isEasyToBorrow | boolean | true - 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
| Condition | Status | Body |
|---|---|---|
Successful POST /order (order received, even if orderStatus is Rejected) | 200 | JSON |
Successful GET | 200 | JSON |
Successful DELETE /accounts/orders (cancel-all) | 200 | {"message":"Cancel Request Submitted Successfully"} |
| JSON Schema validation error (bad enum, wrong type, out-of-range quantity) | 400 | Plain text, bullet format |
Account ID mismatch in POST /order path | 400 | {"statusCode":"BadRequest","message":"PlaceOrderWithResponse","detail":"Account for User was not found, or User doesn't have entitlements."} |
Account ID mismatch in DELETE /orders/{id} path | 400 | {"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 endpoints | 404 | Not found (plain text) |
Account ID mismatch on GET /orders | 404 | Not found (plain text) |
Wrong HTTP method on /order (GET, PUT, PATCH) | 405 | 405 method not allowed (plain text) |
OPTIONS on /order (CORS preflight) | 200 | empty body, Allow: POST header |
Wrong HTTP method on /orders (e.g. POST against the plural endpoint) | 404 | Not found (plain text) |
HEAD on /orders | 405 | empty body |
URL path with uppercase segments (e.g. /V1/API/...) | 404 | Not 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
400bodies include an internal handler label inmessage(for examplePlaceOrderWithResponseorCancelOrderWithResponse). Treatdetailand the HTTP status as the integrator-facing signal; the handler name is diagnostic only. POST /orderreturns200 OKwhen the request body is valid. ReadorderStatusin the response to see whether the order was accepted for routing. Rejected orders also return HTTP200withorderStatus: "Rejected". On live routed orders, POST may returnPendingNewfirst - pollGET /order/{clientOrderId}for the final reason. See Order rejections.- Auth failures return different status codes by endpoint.
GETandPOSTendpoints return404 Not foundwhen auth headers are missing or invalid. TheDELETE /orders/{id}cancel endpoint returns401 Unauthorizedwith a JSON body instead. Handle both. - Account mismatch on
POST/DELETEreturns400, not404. AGETwith the wrong account ID returns404; aPOST /orderorDELETE /orders/{id}with an account that doesn't match your keys returns a400JSON body. The two endpoints share the same{statusCode, message, detail}envelope but use differentmessagevalues (PlaceOrderWithResponseforPOST /order,CancelOrderWithResponseforDELETE /orders/{id}). For TradeZero America accounts, this same400appears when the portal login is used instead of the2TZaccount number - use theaccountvalue fromGET /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
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"
}'
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
| Field | Type | Required | Notes |
|---|---|---|---|
securityType | string | Yes | "Stock" for equities and ETFs. Use "Option" or "Mleg" for options (see Options Trading). Case-sensitive. |
symbol | string | Yes | Ticker 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"). |
side | string | Yes | "Buy" or "Sell". Case-sensitive. See Trader actions for how Buy/Sell/Short/Cover map to this field. |
openClose | string | Yes† | "Open" to enter a new position; "Close" to exit one. See Trader actions. Send "Open" or "Close" with that exact casing in production code. |
orderType | string | Yes | "Market", "Limit", "Stop", or "StopLimit". Case-sensitive. |
orderQuantity | number | Yes | Integer 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. |
timeInForce | string | Yes | One of the eight values documented under Order types, times in force, and session hours. Case-sensitive. |
limitPrice | number | Conditional | Required 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. |
stopPrice | number | Conditional | Required when orderType is "Stop" or "StopLimit". |
clientOrderId | string | Recommended | Your identifier for the order. If omitted, the server generates a numeric one. Used for cancel-by-id and deduplication. See Order identity. |
route | string | Recommended | Routing 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.
{
"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:
{
"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.
{
"orderStatus": "Rejected",
"text": "R114: Invalid duplicate UserOrderId",
"clientOrderId": "my-buy-001",
"canceledQuantity": 1,
"executed": 0,
"leavesQuantity": 0
}
Response fields
| Field | Type | Notes |
|---|---|---|
accountId | string | The account the order was placed on. |
clientOrderId | string | Your 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. |
orderStatus | string | "PendingNew" - accepted, routing in progress; "New" - acknowledged at venue, resting in book; "Filled" - fully executed; "Canceled" - successfully canceled; "Rejected" - declined (read text for reason). |
text | string | null | Human-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). |
executed | number | Shares filled so far. |
leavesQuantity | number | Shares still open. 0 once the order is terminal. |
canceledQuantity | number | Shares canceled. |
orderQuantity | number | Original quantity requested. |
limitPrice | number | The limit price sent. 0 if not a limit order. |
priceStop | number | The stop price sent. 0 if not a stop order. |
priceAvg | number | Volume-weighted average fill price. 0 while unfilled. |
openClose | string | "Open", "Close", or "Unknown" (early rejections before trader-action resolution can return "Unknown"). |
route | string | The 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. |
startTime | string | ISO 8601 timestamp when the order was received. |
lastUpdated | string | ISO 8601 timestamp of the most recent state change. |
tradedSymbol | string | For stocks, equals symbol. For options, the OCC contract string. |
strikePrice | number | 0 for equities. |
legCount | number | 0 for single-leg orders. |
legs | array | null | null for single-leg orders. |
symbol | string | Root ticker on equity orders. |
securityType | string | "Stock", "Option", or "Mleg". |
side | string | Enriched trader action on reads (for example "Buy", "SellShort") - may differ from the raw side sent on POST /order. |
orderType | string | "Market", "Limit", "Stop", or "StopLimit". |
timeInForce | string | Time-in-force sent with the order. |
lastPrice | number | Last trade price associated with the order row. 0 while unfilled. |
lastQuantity | number | Quantity of the last fill. 0 while unfilled. |
pegDifference | number | Reserved response field on order rows; not a supported place-order input. Default 0. |
pegOffsetType | string | null | Reserved response field; not a supported place-order input. Default null or "Price". |
maxDisplayQuantity | number | Display quantity for iceberg-style routes; 0 when not used. |
marginRequirement | number | 0 on order rows - use account bp / deficit fields for margin checks. See API Conventions. |
maintenanceRequirement | number | 0 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.
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}'
{
"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 push | Notes |
|---|---|---|
accountId | account | Same value, different key. |
clientOrderId | userOrderId, format "{accountId}:{clientOrderId}" | Split userOrderId on the first : to recover the bare id. |
canceledQuantity | cancelledQuantity | Spelling difference. |
lastQuantity | lastQty | |
leavesQuantity | leavesQuantity and lvsQty | Both present on WS rows. |
maxDisplayQuantity | maxDisplayQty | |
orderStatus | status | Same 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 note | Detail |
|---|---|
tradedSymbol | Equals symbol for stocks. For options, symbol is the underlying ticker (e.g. "AAPL") and tradedSymbol carries the full OCC contract (e.g. "AAPL260717C00600000"). |
text | Plain 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 / legs | 0 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.
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": [] }.
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.
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}'
{
"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": ""
}
]
}
| Field | Type | Notes |
|---|---|---|
tradeId | integer | Unique identifier for this individual fill (the primary key on every row). |
accountId | string | Account the trade settled on. |
symbol | string | Symbol traded. |
securityType | string | "Stock" or "Option". |
side | string | "Buy" or "Sell" (no enriched "SellShort" / "BuyToCover" here). |
qty | integer | Shares filled on this execution (not the originating order's quantity). |
price | number | Per-share fill price. |
grossProceeds | number | qty × price with sign - negative on Buys (cash out), positive on Sells. |
netProceeds | number | grossProceeds minus totalFees and commission, sign-preserved. |
commission | number | Commission charged on this fill (0 on commission-free accounts). |
totalFees | number | All non-commission fees (regulatory, exchange, etc.). |
currency | string | Settlement currency ("USD"). |
tradeDate | string (date-time) | When the trade executed. |
settleDate | string (date-time) | T+1 settlement date. |
entryDate | string (date-time) | When the originating order was entered. |
execTime | string | HH:MM:SS local time of the execution. |
canceled | boolean | true if the trade was busted/cancelled post-execution; skip these for activity counts. |
mLegId | integer | Multi-leg ticket ID. 0 for single-leg orders. |
spreadType | integer | Internal spread classifier; 0 for ordinary single-leg fills. |
notes | string | Free-form notes string from the execution venue; empty unless the venue attaches a note. |
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:
| Format | Example | Accepted |
|---|---|---|
YYYY-MM-DD | 2026-05-01 | ✓ |
ISO 8601 with T and Z | 2026-05-01T00:00:00Z | ✓ |
| ISO 8601 with UTC offset (URL-encoded) | 2026-05-01T00%3A00%3A00-04%3A00 | ✓ |
| Unpadded month/day | 2026-5-1 | ✓ |
US-style MM-DD-YYYY | 05-01-2026 | ✓ |
| Far future | 2099-12-31 | ✓ (empty array) |
| Unix epoch start | 1970-01-01 | ✓ (empty array) |
Compact YYYYMMDD | 20260501 | ✗ - returns 404 |
| Non-date string | not-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 param | Default | Notes |
|---|---|---|
numberOfDays | 30 | Window length from startDate, up to 365 days |
offset | 0 | Rows to skip (pagination) |
limit | 100 | Page 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).
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-by-id has three different failure shapes:
-
401 Unauthorized(JSON body) - auth headers are missing:{ "statusCode": "Unauthorized", "message": "Token not provided", "detail": null } -
404 Not found(plain text) - the order was not found. This covers: wrongclientOrderId, the order is not yet registered (pollGET /ordersafterPOST /orderbefore canceling), the order is already terminal (Filled,Canceled,Expired), or auth headers that do not grant access. ForRejectedorders, cancel may returnHTTP 200and overwritetextwith R130 instead of404. Do not cancel rejected orders. -
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.
textIf 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.
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.
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'
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'
{
"message": "Cancel Request Submitted Successfully"
}
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.
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:
{
"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
}
]
}
{
"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:
| Route | Security types | Notes |
|---|---|---|
SMART | Stock | TradeZero'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. |
CTDL | Stock | Direct-access route to a single venue. Useful when you want deterministic routing rather than smart-router discretion. |
SMARTO | Option | Smart router for single-leg options (Day, GoodTillCancel). |
SMARTM | Option, MLEG | Smart router for multi-leg option spreads. Day-only. |
ARCA | Stock | Direct 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.
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.
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)
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 orderType | Platform | Notes |
|---|---|---|
Market | MKT | RTH only with Day |
Limit | LMT | Extended Day window from 4:00A |
Stop | Stop-MKT | RTH only with Day · not StopMarket |
StopLimit | Stop-LMT | Outside 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) | Label | Market | Limit | Stop | StopLimit |
|---|---|---|---|---|---|
Day | DAY | 9:30A–4:00P | From 4:00A | 9:30A–4:00P | Live from 4:00A · paper 9:30A–4:00P |
Day_Plus | DAY+ | Not eligible (R135) | 4:00A–8:00P | Not eligible (R135) | 4:00A–8:00P |
GoodTillCancel | GTC | Not eligible | Submit 12:30A–11:59P · active 9:30A–4:00P | Submit 12:30A–11:59P · active 9:30A–4:00P | Submit 12:30A–11:59P · active 9:30A–4:00P |
GTC_Plus | GTC+ | Not eligible | Submit 12:30A–11:59P · active 4:00A–8:00P | Not eligible | Submit 12:30A–11:59P · active 4:00A–8:00P |
AtTheOpening | OPG | Submit 4:00A–9:25A · executes at open | Submit 4:00A–9:25A · executes at open | Not eligible | Not eligible |
ImmediateOrCancel, FillOrKill, and GoodTillCrossing are not on SMART - see Direct-route TIF availability.
How to read the matrix
- RTH-only with
Day:MarketandStop. Outside 9:30A–4:00P they reject. Livetextis R100; papertextis R78 (Market) or R79 (Stop). - Extended
DayLimit: eligible from 4:00A. After 4:00P, LimitDayremains 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, sendDay_PlusorGTC_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.
| Platform | API enum | Field |
|---|---|---|
| MKT | Market | orderType |
| LMT | Limit | orderType |
| Stop-MKT | Stop | orderType |
| Stop-LMT | StopLimit | orderType |
| DAY | Day | timeInForce |
| DAY+ | Day_Plus | timeInForce |
| GTC | GoodTillCancel | timeInForce |
| GTC+ | GTC_Plus | timeInForce |
| OPG | AtTheOpening | timeInForce |
| IOC | ImmediateOrCancel | timeInForce |
| FOK | FillOrKill | timeInForce |
| GTX | GoodTillCrossing | timeInForce |
Submission vs execution windows
For GoodTillCancel and GTC_Plus, submit and active windows differ.
| TIF | Submit window | Active window |
|---|---|---|
GoodTillCancel | 12:30A–11:59P ET (incl. weekends) | 9:30A–4:00P ET |
GTC_Plus | 12: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:
MarketandLimitonly. - 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
AtTheOpeningmore 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:30A | 13:30 UTC | 14:30 UTC |
| 4:00P | 20:00 UTC | 21:00 UTC |
Direct-route TIF availability
Trust GET /routes for your account. Live inventory from /routes:
| Route | Securities | Advertised TIFs |
|---|---|---|
SMART | Stock | Day, GoodTillCancel, AtTheOpening, Day_Plus, GTC_Plus |
CTDL | Stock | Day, GoodTillCancel, GoodTillCrossing |
ARCA | Stock | Day, GoodTillCancel, ImmediateOrCancel, FillOrKill, GoodTillCrossing |
SMARTO | Option | Day, GoodTillCancel |
SMARTM | Option, MLEG | Day |
| TIF | On SMART? | Notes |
|---|---|---|
ImmediateOrCancel | No | Available on ARCA-class directs; not on paper. |
FillOrKill | No | Available where /routes lists it (for example ARCA); not on paper. |
GoodTillCrossing | No | For 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
| Topic | Paper | Live |
|---|---|---|
AtTheOpening | Paper 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 RTH | Rejected; Market R78, Stop R79 | Rejected; R100 |
StopLimit × Day | Eligible 9:30A –4:00P only; outside RTH → R80 | Eligible from 4:00A through RTH; after-hours → R100. Outside RTH use Day_Plus / GTC_Plus |
| IOC / FOK | Not available | Only 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).
POST /orderOn live routed orders (when you send an explicit route such as "SMART"), rejections fall into two patterns:
-
Asynchronous (TradeZero route checks such as R24, R54, R95, and platform NBBO limits):
POST /orderreturnsPendingNew+text: null, thenGET /order/{clientOrderId}showsRejectedwithtextwithin ~50 ms. -
Synchronous reject without reason text (R118 with an explicit route on live):
POST /orderreturnsorderStatus: "Rejected"andtext: null, and the server rewritesclientOrderIdby appending anINVALIDsuffix and a timestamp (for examplemy-order-idINVALID01/15/2026 09:30:00).GET /order/{yourOriginalClientOrderId}returns404even after polling. The row appears onGET /orders- still withtext: null. The sameINVALIDsuffix rewrite occurs on live R114 duplicate-clientOrderIdPOSTs. Clients who only pollGET /orderwith 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
| Surface | When to use | Rejection behavior |
|---|---|---|
POST /order response | Immediate ack | May 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 lookup | Single-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 /orders | Today's full order book | Same 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 push | Real-time blotter | Pushes 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 attempt | Not 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:
{
"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"
}
}
{
"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.
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:
| Field | Meaning |
|---|---|
startTime | When 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. |
lastUpdated | When 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:
-
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" -
Venue rejection messages without an
R##prefix - for example:Rejected: Broker Rejected Limit price too far from NBBOTreat the full string as the reason; do not assume every rejection starts with
R##. -
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 scanGET /ordersfor theRejectedrow. Some POST responses also rewriteclientOrderIdwith anINVALIDsuffix; log the full POST body and useGET /orderswhenGET /orderreturns404.
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 cancel | After 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.
| Code | Example text | Trigger | POST timing |
|---|---|---|---|
| R24 | R24: Your order cannot have a STOP price of Zero | Stop/StopLimit without valid stopPrice | Async |
| R54 | R54: Unable to reach the destination Route | Invalid or missing route | Async |
| R78 | R78: Market orders are not allowed at this time | Market 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 |
| R79 | R79: Stop orders are not allowed at this time | Stop outside SMART Day RTH on paper, or Stop × AtTheOpening. On live outside RTH, Stop × Day is R100. | Sync |
| R80 | R80: Stop Limit orders are not allowed at this time | StopLimit outside the eligible Day window on paper, or StopLimit × AtTheOpening. | Sync |
| R95 | R95: Cannot have opening buy and sell orders at the same time | Opening long and short on same symbol | Async (~4 ms) |
| R100 | R100: Order type not supported at this hour | On 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 |
| R06 | R06: This symbol is restricted from trading at this price | Limit far from allowed band | Async |
| R114 | R114: Invalid duplicate UserOrderId | Reused clientOrderId in session | Sync |
| R118 | R118: Stop price is on wrong side of quote | Stop on wrong side of quote | Async without route; sync + text: null with route |
| R130 | R130: Cancel Request Rejected: … | Cancel on a Rejected order (from DELETE, not placement). Other terminals typically return 404. | - |
| R135 | R135: Only Limit and Stop-LMT orders are Day+ eligible | Market or Stop with Day_Plus - see SMART route eligibility matrix | Sync |
| R145 | R145: Negative Price Is Not Allowed | Negative limitPrice or stopPrice | Sync |
| (none) | Rejected: Broker Rejected … | Venue declined (no R## prefix) | Async |
| (none) | null | Risk check with no surfaced code | Sync 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.
Recommended production pattern
Every order placement path - manual tickets, algos, batch submitters - should follow the same outcome resolution:
- Generate a unique
clientOrderIdbeforePOST /order. - Have a listener ready - Portfolio WebSocket subscribed to
"Order"for the account before POST, or a poll loop onGET /order/{clientOrderId}and fallback toGET /orders. - POST the order and read the response, but do not treat
PendingNewas success. - Capture
post.clientOrderIdfrom the response - it may differ from the ID you sent (R118 routed rejections appendINVALID…). - Resolve the final state within ~5 seconds:
- WebSocket: wait for an
Orderupdate whereorderStatus === "Rejected"(or another terminal status) and readtext. - REST: poll
GET /order/{post.clientOrderId}every 50 ms until status is no longerPendingNew(retry on404). If still404, scanGET /ordersfor aRejectedrow with matchingsymbol/startTime.
- WebSocket: wait for an
- On
Rejected, persist and display:text(full reason string)- parsed R-code if present (
order.text?.match(/^(R\d+)/)?.[1]) lastUpdated(orstartTimeiflastUpdatedis absent) as the rejection timestampclientOrderId,symbol,side,orderType,route
- Do not call
DELETE /orders/{clientOrderId}on rejected orders - see R130 overwrite.
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"
| Symptom | Cause | Fix |
|---|---|---|
POST returned PendingNew, no error shown | Client stopped at POST body; rejection is async | Poll GET /order/{post.clientOrderId} or listen on Portfolio WS |
POST returned Rejected + text: null, GET → 404 | R118 with route (routed stop on wrong side) - server rewrote clientOrderId | Use GET /orders; do not cancel; infer R118 from order params |
GET /order/{sentId} always 404 after POST | POST response used a different clientOrderId (…INVALID… suffix) | Poll with post.clientOrderId; fall back to GET /orders |
| User saw error only after cancel attempt | Cancel on Rejected order overwrote text with R130 | Read rejection before cancel; never cancel Rejected orders |
GET /order/{cid} returned 404 once, client gave up | Registration race right after POST | Retry for 1–2 s before treating as missing |
GET /orders shows Rejected but GET /order still New | Async rejection appeared in GET /orders before GET /order updated | Keep polling GET /order or trust GET /orders row |
| No WebSocket rejection event | WS connected after POST, or not subscribed to "Order" | Open WS and subscribe before placing; buffer messages during REST bootstrap |
Only saw PendingNew on WebSocket | Ignored the second push | Async rejections send two WS updates - wait for Rejected |
| Reason missing from historical archive | GET /orders/start-date is fill-level only | Use 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.
| Condition | 400 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 literal | invalid 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-quote | invalid 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 parseable | invalid 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 body | invalid character '<' looking for beginning of value |
| Form-urlencoded or plain-text body | 404 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 value | Condition |
|---|---|
"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) |
null | Rejected 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).
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.
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
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.
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 cancellationA 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 class | Behavior |
|---|---|
| Alphanumerics, hyphens, underscores | Safe - recommended |
Colons (:) | Accepted by the server |
Forward slashes (/) | Accepted by the server |
| Spaces | Accepted 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-generateSending 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".
{
"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):
orderStatus | Terminal? | Meaning |
|---|---|---|
PendingNew | No | Gateway has accepted the order; routing in progress. Transitions to New within milliseconds. |
Accepted | No | Order acknowledged by the venue (returned on Portfolio WebSocket pushes). Functionally equivalent to New for blotter purposes - treat both as working. |
New | No | Order is resting in the order book at the venue, waiting to be filled. |
PartiallyFilled | No | Some shares have filled; order is still working for the remainder. |
Filled | Yes | Fully executed. executed equals orderQuantity, leavesQuantity is 0. |
PendingCancel | No | Cancel request received; awaiting confirmation from venue. |
Canceled | Yes | Successfully canceled. canceledQuantity is set to the canceled-out shares. |
Rejected | Yes | Declined. Read text for the R-code reason. canceledQuantity carries the unfilled quantity. |
DoneForDay | Semi | Order expired at end of session. For GoodTillCancel and GTC_Plus orders it will resurface the next session; for all others it is effectively terminal. |
Expired | Yes | Order 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:
DELETE /v1/api/accounts/{accountId}/orders/{originalClientOrderId}- cancel the working order.- Wait for the cancellation to settle. Poll
GET /ordersuntil the original order'sorderStatusisCanceled. POST /v1/api/accounts/{accountId}/order- submit a fresh order with a newclientOrderIdand 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();
}
If the original order partially fills before cancellation completes, recompute replacement quantity from open size - do not blindly resubmit the original orderQuantity.
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.
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:
- HTTP-level -
!res.okcatches400(schema error, plain-text body),401(cancel endpoint auth),404(auth/account on GET and POST), and405(wrong method). - Application-level - the HTTP status can be
200while the order is still rejected. Always readorderStatusafter parsing.
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.
| Shape | Endpoints | Account-id field | Client-id field | Quantity fields |
|---|---|---|---|---|
| Clean | POST /order; GET /order/{clientOrderId}; GET /orders (each row); Mleg GET /order/{cid} before legs[] is populated | accountId | clientOrderId (bare) | canceledQuantity, lastQuantity, leavesQuantity, maxDisplayQuantity |
| WebSocket (enriched) | Portfolio WebSocket Order push; GET /order/{clientOrderId} for Mleg orders once legs[] is populated | account | userOrderId = "{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, splituserOrderIdintoaccountId+clientOrderId,cancelledQuantity→canceledQuantity,lastQty→lastQuantity,maxDisplayQty→maxDisplayQuantity). Run it on every Portfolio WebSocketOrdermessage and everyGET /order/{cid}response that has a non-nulllegs[]. REST responses fromPOST /order,GET /orders, and mostGET /order/{cid}calls are already in the clean shape. - Pick
clientOrderIdas your stable join key. After normalization it's identical across all four sources, and the same value you sent onPOST /order. - Seed your local cache from
GET /orderson startup (today's order book - all statuses - plus working multi-day GTCs), then apply Portfolio WebSocket pushes to keep it live. TreatorderStatus: "Filled","Canceled","Rejected","Expired", or"DoneForDay"as terminal. - The
sidefield onGET /ordersrows is enriched for short/cover trades - see Thesidefield - enriched values below. WebSocket pushes carry the raw"Buy"/"Sell"you sent. If you display labels from WS messages, derive them yourself fromside+openClose. textcarries the rejection reason forRejectedrows ("R##: …") and isnullfor accepted rows; on paper, accepted rows may carry a paper-session note intext. Checktextexplicitly 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 request | openClose in request | side in GET /orders response | Trader-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→Limitat the last traded price (or mid-point of bid/ask)StopLimitwithDay→ keepStopLimitbut switch TIF toDay_Plus(orGTC_Plus)- Prefer
Day_PlusorGTC_PluswithLimitorStopLimitonly - neverMarketorStopwith those TIFs (R135) GoodTillCancelin 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:
| Approach | Calculation |
|---|---|
| Fixed shares | quantity = config.shares |
| Dollar amount | quantity = Math.floor(dollarAmount / lastPrice) |
| % of buying power | quantity = Math.floor((buyingPower * pct) / lastPrice) |
| % of current position | quantity = 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:
| Source | Description |
|---|---|
bid | National best bid |
ask | National best ask |
last | Last traded price |
midpoint | (bid + ask) / 2 |
highOfDay | Today's session high |
lowOfDay | Today's session low |
avgEntry | The 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
- Positions & P&L - read open holdings, P&L, closed lifecycles, and historical as-of snapshots, including how filled orders become position rows.
- Short Locates - reserve borrow for hard-to-borrow symbols before placing a Short order.
- Account Info - buying power, margin, and account-level risk metrics.
- Options Trading - single-leg and multi-leg options orders using the same
POST /orderendpoint. - Portfolio WebSocket - real-time order state changes without polling.
- P&L WebSocket - real-time account P&L, balance, and exposure stream.
- API Reference - POST /order - OpenAPI reference.
- API Reference - GET /orders - OpenAPI reference.
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 acrossGET /ordersand emit transition events when statuses change. - Cancel All Open Orders - bulk cancel via
DELETE /accounts/ordersplus 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+openCloseand decode them back fromGET /orders. - Stop-Loss on a Long Position - read
/positions, attach a stop order at a configurable percentage belowpriceAvg, 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 bysecurityTypes/orderTypes, and submit with an explicitroute. clientOrderIddedup and safe retry - reconcile before retry so a network failure never produces a duplicate order.