Skip to content
Skip to main content

Portfolio Stream

Beta - surface still evolving

The Portfolio stream is in beta. Design integrations that can pick up schema updates on a normal release cycle. See About WebSocket API and the Change Log.

Overview​

The Portfolio stream delivers order-driven updates - fills, partial fills, cancellations, acknowledgements, and rejections - for active orders and positions. It does not carry mark-to-market profit and loss; subscribe to the P&L Stream for price-driven account value changes.

Endpoint: wss://webapi.tradezero.com/stream/portfolio

Incremental updates only

The Portfolio stream does not send a full snapshot of existing orders or positions when you first connect. See the recommended usage pattern below to avoid missing updates.


Because the stream delivers only incremental changes, fetch your current state from the REST API before subscribing:

  1. Open the WebSocket connection and start buffering incoming messages - do this first so you don't miss any updates that arrive while the REST call is in flight.
  2. Fetch current orders and positions via REST:
    • GET /v1/api/accounts/{accountId}/orders - today's order book (all statuses) plus working multi-day GTCs
    • GET /v1/api/accounts/{accountId}/positions - current open positions
  3. Apply any buffered WebSocket messages on top of the REST snapshot - this gives you a consistent starting state.

Usage​

Step 1 - Connect and authenticate​

Connect to the endpoint and complete the authentication handshake described in About WebSocket API. Once you receive CONNECTED, proceed to step 2.

Step 2 - Subscribe​

Send a subscribe payload with the account ID and the channel types you want:

{
"accountId": "YOUR_ACCOUNT_ID",
"subscriptions": ["Order", "Position"]
}
ValueChannel
"Order"Order state changes (accepted, filled, partially filled, canceled, rejected)
"Position"Position changes driven by order fills

You can subscribe to one or both channels in a single message. To subscribe to additional accounts, send further subscribe payloads at any time - each one receives its own confirmation.

Channel names are case-insensitive

["order", "position"] (lowercase) is accepted the same as ["Order", "Position"]. Duplicate entries in the array (e.g. ["Order", "Order"]) are also accepted and treated as a single subscription. The subscriptions field must be a JSON array - sending a string returns INVALID_DATA.

Forgiving subscribe payload

Any extra fields you include in the subscribe payload beyond accountId and subscriptions are ignored by the server - only those two fields are read.

Step 3 - Receive the confirmation​

The server responds with a meta message confirming the subscription:

{
"ts": 1700000016653,
"accountId": "TZP12345678",
"action": "meta",
"requestConfirmed": true
}

After this confirmation, order and position updates flow in as they occur.


Real-time updates​

All update messages share the same top-level envelope:

FieldTypeDescription
tsnumberUnix timestamp in milliseconds
accountIdstringThe account the update belongs to
actionstringAlways "update" for data messages
subscriptionstring"Order" or "Position"

Order updates​

Received whenever an order changes state.

{
"ts": 1771864795549,
"accountId": "TTE12345678",
"action": "update",
"subscription": "Order",
"order": {
"account": "TTE12345678",
"cancelledQuantity": 0,
"userOrderId": "TTE12345678:my-buy-001",
"executed": 1,
"lastPrice": 398.41,
"lastQty": 1,
"lastUpdated": "2026-02-23T16:39:55.259+00:00",
"leavesQuantity": 0,
"lvsQty": 0,
"limitPrice": 398.41,
"maxDisplayQty": 0,
"openClose": "Open",
"orderQuantity": 1,
"status": "Filled",
"orderType": "Limit",
"pegDifference": 0,
"pegOffsetType": "Price",
"priceAvg": 398.41,
"priceStop": 0,
"route": "SMART",
"securityType": "Stock",
"side": "Buy",
"startTime": "2026-02-23T16:37:05.909+00:00",
"strikePrice": 0,
"symbol": "TSLA",
"text": null,
"timeInForce": "Day",
"tradedSymbol": "TSLA"
}
}

The same WebSocket field set is delivered for paper accounts, with account set to a TZP… value and route: "PAPER" (or "PAPERM" for multi-leg). On paper, accepted rows may carry a paper-session note in text instead of null.

Portfolio Order pushes use the WebSocket field set (account, userOrderId, status, cancelledQuantity, lastQty, …) - not the clean REST names. Normalize before merging with REST rows - see Order shape across REST and WebSocket. Split userOrderId on the first : to recover clientOrderId. Read status for lifecycle (same values as REST orderStatus).

When an order is rejected, the WebSocket push carries status: "Rejected" and a populated text reason - for example "R118: Stop price is on wrong side of quote". Use the Portfolio stream to read the final rejection when POST /order returned PendingNew with text: null. Do not cancel rejected orders over REST - that overwrites text with R130. See Order rejections on the Portfolio stream and the Order rejections guide.

Order rejections on the Portfolio stream​

Live routed orders that reject asynchronously produce two Order updates for the same order (same userOrderId / recovered clientOrderId):

  1. PendingNew - immediately after POST /order (text: null)
  2. Rejected - milliseconds later, with the full reason in text and an updated lastUpdated
R118 with an explicit route - confirm status over REST

On live accounts, R118 stop-on-wrong-side orders sent with an explicit route can reject with text: null and a rewritten clientOrderId. Confirm final status with REST (GET /orders / GET /order/{clientOrderId}) and do not cancel rejected orders (R130 overwrite). See Order rejections.

Example: R118 stop rejection (live, route SMART)
{
"ts": 1751475515804,
"accountId": "TTE12345678",
"action": "update",
"subscription": "Order",
"order": {
"account": "TTE12345678",
"userOrderId": "TTE12345678:my-stop-001",
"status": "Rejected",
"text": "R118: Stop price is on wrong side of quote",
"symbol": "BABA",
"orderType": "Stop",
"side": "Buy",
"route": "SMART",
"startTime": "2026-01-15T14:31:10.419+00:00",
"lastUpdated": "2026-01-15T14:31:10.423+00:00"
}
}

Integration checklist for rejections:

  • Subscribe to "Order" before calling POST /order - the stream is incremental and will not replay missed events.
  • Replace your cached order row on every push (full row replacement, not merge-by-field). Normalize WebSocket fields to your REST cache key (clientOrderId from userOrderId).
  • Surface errors when status === "Rejected" - do not stop at the first PendingNew push.
  • Use lastUpdated on the Rejected push as the rejection timestamp.
  • Do not call DELETE /orders/{clientOrderId} on rejected orders - REST cancel overwrites text with R130.

If you cannot keep a standing WebSocket open, poll GET /order/{clientOrderId} after every POST instead - see Order rejections.

Order field reference​

FieldTypeDescription
accountstringAccount the order belongs to (REST uses accountId)
cancelledQuantitynumberQuantity canceled (REST uses canceledQuantity)
userOrderIdstring"{accountId}:{clientOrderId}" - split on the first : for the bare client id
executednumberTotal quantity filled so far
lastPricenumberPrice of the most recent fill
lastQtynumberQuantity of the most recent fill (REST uses lastQuantity)
lastUpdatedstringISO 8601 timestamp of the most recent change
leavesQuantitynumberRemaining unfilled quantity
lvsQtynumberDuplicate of leavesQuantity on WebSocket rows
limitPricenumberThe order's limit price
maxDisplayQtynumberDisplayed quantity for reserve/iceberg orders (0 if not applicable); REST uses maxDisplayQuantity
openClosestring"Open" or "Close"
orderQuantitynumberTotal ordered quantity
statusstringCurrent status (REST uses orderStatus) - see order lifecycle
orderTypestring"Limit", "Market", "Stop", etc.
pegDifferencenumberReserved response field; default 0. Not a supported place-order input.
pegOffsetTypestringPeg reference type (e.g. "Price")
priceAvgnumberVolume-weighted average fill price
priceStopnumberStop price for stop or stop-limit orders (0 otherwise)
routestringRouting destination - paper accounts return "PAPER" (single-leg / equity) or "PAPERM" (multi-leg). Live accounts return real venues such as "SMART", "CTDL", "SMARTO", "SMARTM", or "ARCA".
securityTypestring"Stock", "Option", "MLEG"
sidestring"Buy" or "Sell"
startTimestringISO 8601 timestamp when the order was placed
strikePricenumberStrike price for single-leg options (0 for equities)
symbolstringTicker symbol (underlying ticker for multi-leg orders)
textstring | nullRejection reason when status is "Rejected" (R##: … or plain rejection text). null on accepted rows. On paper, accepted rows may carry a paper-session note in text. See Order rejections.
timeInForcestring"Day", "GoodTillCancel", "ImmediateOrCancel", etc.
tradedSymbolstring | nullSymbol as executed; may differ from symbol for certain option routes
Multi-leg orders

For multi-leg ("MLEG") orders, symbol is the underlying ticker (e.g. "AAPL") and securityType is "MLEG". The individual leg details are not included in the WebSocket order update; use GET /v1/api/accounts/{accountId}/orders to retrieve the full leg structure.


Position updates​

Received whenever a fill creates or modifies a position.

{
"ts": 1771864795549,
"accountId": "TTE12345678",
"action": "update",
"subscription": "Position",
"position": {
"id": "7260223163955259000",
"accountId": "TTE12345678",
"createdDate": "2026-02-23T16:39:59.559+00:00",
"dayOvernight": "Day",
"priceAvg": 398.41,
"priceClose": 0,
"priceOpen": 398.41,
"priceStrike": 0,
"putCall": "None",
"securityType": "Stock",
"shares": 1,
"side": "Long",
"symbol": "TSLA",
"updatedDate": "2026-02-23T16:39:55.259+00:00"
}
}

Position field reference​

FieldTypeDescription
idstringUnique position identifier. Note: the REST position endpoints use positionId for the same value; the WebSocket stream uses id.
accountIdstringAccount the position belongs to
createdDatestringISO 8601 timestamp when the position was first opened
dayOvernightstring"Day" (opened today) or "Overnight" (carried from prior session)
priceAvgnumberAverage cost basis
priceClosenumber0 while the position is open (it is not a previous-session close). Populated only after the position is fully closed.
priceOpennumberPrice at which the position was originally opened
priceStrikenumberStrike price for options (0 for equities)
putCallstring"Put", "Call", or "None" for equities
securityTypestring"Stock" or "Option"
sharesnumberCurrent position size. Negative for short positions.
sidestring"Long" or "Short"
symbolstringTicker symbol; OCC format for options
updatedDatestringISO 8601 timestamp of the most recent update

Error handling​

Subscription errors are returned as system messages ("@system": true) with status: "TERMINATED" or status: "INVALID_DATA". The connection stays open after these; you can send a corrected subscribe payload without reconnecting.

ScenarioStatusMessage
Account ID not found or not ownedTERMINATED"invalid account"
accountId sent as a number (e.g. 99999)TERMINATED"invalid account"
subscriptions array is empty []TERMINATED"subscription types not specified"
subscriptions field is omittedTERMINATED"subscription types not specified"
subscriptions contains an unrecognized string valueINVALID_DATADescription of the parse error
subscriptions is a string instead of an arrayINVALID_DATA"Error converting value ... to type 'ICollection[WsPortfolioSubscptionType]'"
{
"@system": true,
"ts": 1700000019288,
"status": "TERMINATED",
"message": "invalid account"
}

Multiple account subscriptions​

Send additional subscribe payloads on the same connection to subscribe to more accounts. Each subscription receives its own meta confirmation:

{ "accountId": "ACCOUNT_A", "subscriptions": ["Order", "Position"] }
{ "accountId": "ACCOUNT_B", "subscriptions": ["Order"] }

Complete example​

The pattern below implements the recommended REST-first approach: buffer WebSocket messages while fetching the REST snapshot, then apply them on top.

const accountId = 'YOUR_ACCOUNT_ID';
const apiKey = 'YOUR_TZ-API-KEY-ID';
const apiSecret = 'YOUR_TZ-API-SECRET-KEY';

const authHeaders = {
'TZ-API-KEY-ID': apiKey,
'TZ-API-SECRET-KEY': apiSecret,
};

// Local state - seeded from REST, kept in sync by WebSocket updates.
const orders = new Map<string, any>(); // keyed by clientOrderId
const positions = new Map<string, any>(); // keyed by id (WebSocket) / positionId (REST)

// Buffer messages that arrive while the REST fetch is in flight.
const messageBuffer: any[] = [];
let restReady = false;

function applyMessage(msg: any): void {
if (msg['@system']) {
switch (msg.status) {
case 'PENDING_AUTH':
ws.send(JSON.stringify({ key: apiKey, secret: apiSecret }));
break;
case 'CONNECTED':
ws.send(JSON.stringify({ accountId, subscriptions: ['Order', 'Position'] }));
break;
case 'FAILED_AUTH':
console.error('Auth failed:', msg.message);
ws.close();
break;
case 'TERMINATED':
case 'INVALID_DATA':
console.error('Subscribe error:', msg.status, msg.message);
break;
}
return;
}

if (msg.action === 'meta') {
console.log('Portfolio stream confirmed for', msg.accountId);
return;
}

if (msg.action === 'update' && msg.subscription === 'Order') {
orders.set(msg.order.clientOrderId, msg.order);
onOrderChange(msg.order);
} else if (msg.action === 'update' && msg.subscription === 'Position') {
// The stream uses `id`; REST endpoints use `positionId` for the same value.
positions.set(msg.position.id, msg.position);
onPositionChange(msg.position);
}
}

const ws = new WebSocket('wss://webapi.tradezero.com/stream/portfolio');

ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (!restReady) {
messageBuffer.push(msg);
} else {
applyMessage(msg);
}
};

// Fetch the REST snapshot in parallel - apply buffered messages after.
async function bootstrap(): Promise<void> {
const [ordersR, positionsR] = await Promise.all([
fetch(`https://webapi.tradezero.com/v1/api/accounts/${accountId}/orders`, { headers: authHeaders }),
fetch(`https://webapi.tradezero.com/v1/api/accounts/${accountId}/positions`, { headers: authHeaders }),
]);
if (!ordersR.ok) throw new Error(`orders bootstrap failed: ${ordersR.status}`);
if (!positionsR.ok) throw new Error(`positions bootstrap failed: ${positionsR.status}`);
const [ordersResp, positionsResp] = await Promise.all([ordersR.json(), positionsR.json()]);

// Both endpoints return wrapped envelopes: `{ orders: [...] }` and `{ positions: [...] }`.
for (const o of ordersResp.orders ?? []) orders.set(o.clientOrderId, o);
for (const p of positionsResp.positions ?? []) positions.set(p.positionId, p);

restReady = true;
for (const msg of messageBuffer) applyMessage(msg);
messageBuffer.length = 0;
}

bootstrap().catch(console.error);
The id / positionId key difference

REST position endpoints return positionId. The Portfolio WebSocket stream returns the same value as id. When merging WebSocket updates on top of a REST snapshot you need to reconcile these: either re-key the REST snapshot to use id, or query the REST endpoint after the first WebSocket position update to confirm the mapping.

For an end-to-end implementation of the connect → buffer → REST snapshot → apply pattern (with reconnection logic and full message handling), see the Portfolio Order Tracker recipe.