Skip to content
Skip to main content

About WebSocket API

Beta

The WebSocket API is currently in beta. Features and endpoints are subject to change.

Overview

The TradeZero WebSocket API delivers real-time account data over persistent connections. Two separate streams are available, each at its own endpoint under the same base URL:

StreamEndpointWhat it delivers
P&L Stream/stream/pnlAccount value, leverage, and per-position unrealized P&L - updated on every price tick
Portfolio Stream/stream/portfolioOrder state changes and position updates driven by order fills and cancellations

Base URL: wss://webapi.tradezero.com/stream

The two streams are independent connections. You open them in parallel and they each go through the same authentication handshake.


Connection and authentication

Every stream uses the same three-step handshake before data begins flowing.

Step 1 - Open the connection

Connect to the stream URL using a standard WebSocket client. No custom HTTP headers are needed.

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

Immediately on connect the server sends a system message:

{
"@system": true,
"ts": 1700000000000,
"status": "PENDING_AUTH",
"message": "Send authenticate message"
}

The server re-sends PENDING_AUTH approximately every five seconds until credentials are received. The connection is not closed for inactivity - it stays open indefinitely waiting for auth.

Step 2 - Send credentials

Respond with your API key and secret as a JSON object. The field names are key and secret:

{
"key": "YOUR_TZ-API-KEY-ID",
"secret": "YOUR_TZ-API-SECRET-KEY"
}
Field names

The auth payload uses key and secret. The field names are case-insensitive - Key/Secret (capitalized) is accepted the same as key/secret. Using any other name such as apiKey/apiSecret is treated as missing credentials and returns FAILED_AUTH.

Step 3 - Send your subscribe payload

Once the server responds with CONNECTED, immediately send the stream-specific subscribe payload. See the individual stream pages for the exact format.


System messages

Every message from the server that has "@system": true is a control message. Your client should handle all of them.

statusWhen it appearsWhat to do
PENDING_AUTHOn connect, then every ~5 s until you authenticateSend { "key": "...", "secret": "..." }
CONNECTEDAfter successful authenticationSend the stream's subscribe payload
FAILED_AUTHCredentials rejectedDo not retry - credentials are invalid
TERMINATEDSubscribe payload was rejected (see below)Inspect message for the reason, correct the payload
INVALID_DATASubscribe payload could not be parsedInspect message for the field and value that failed

FAILED_AUTH scenarios

Auth payload sentResponse
Correct key + secretCONNECTED
Key / Secret (capitalized - field names are case-insensitive)CONNECTED
Wrong key valueFAILED_AUTH - "Unable to provide authentication"
Wrong secret valueFAILED_AUTH - "Unable to provide authentication"
Empty object {}FAILED_AUTH - "Authentication parameters not properly provided"
String / non-objectFAILED_AUTH - "Authentication parameters not properly provided"
Wrong field names (apiKey/apiSecret)FAILED_AUTH - "Authentication parameters not properly provided"

TERMINATED and INVALID_DATA

These statuses appear after a successful CONNECTED if the subscribe message is rejected. The connection remains open after both; you can send a corrected subscribe payload without reconnecting.

Both streams share the same behavior: TERMINATED signals a logical rejection (e.g. account not found, missing required fields) while INVALID_DATA signals a parse error (e.g. wrong field type, malformed value). For the P&L stream specifically, any additional messages you receive after an INVALID_DATA continue to carry live data - the stream is not paused.

{
"@system": true,
"ts": 1700000001000,
"status": "TERMINATED",
"message": "invalid account"
}

Complete connection example

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

ws.onmessage = (event) => {
const msg = JSON.parse(event.data);

// Route system messages separately from data messages.
if (msg['@system']) {
switch (msg.status) {
case 'PENDING_AUTH':
ws.send(JSON.stringify({
key: 'YOUR_TZ-API-KEY-ID',
secret: 'YOUR_TZ-API-SECRET-KEY',
}));
break;

case 'CONNECTED':
// Send the stream-specific subscribe payload.
ws.send(JSON.stringify({ account: 'YOUR_ACCOUNT_ID' }));
break;

case 'FAILED_AUTH':
console.error('Authentication failed:', msg.message);
ws.close();
break;

case 'TERMINATED':
case 'INVALID_DATA':
console.error('Subscribe rejected:', msg.status, msg.message);
break;
}
return;
}

// Handle data messages (stream-specific).
handleDataMessage(msg);
};

ws.onerror = (err) => console.error('WebSocket error', err);
ws.onclose = (event) => {
console.log(`Closed: code=${event.code}`);
// Reconnect with exponential backoff if the close was unexpected.
};

TypeScript interfaces

A complete set of types for both streams:

// ── System messages ──────────────────────────────────────────────────────────

type SystemStatus =
| 'PENDING_AUTH'
| 'CONNECTED'
| 'FAILED_AUTH'
| 'TERMINATED'
| 'INVALID_DATA';

interface SystemMessage {
'@system': true;
ts: number; // Unix milliseconds
status: SystemStatus;
message?: string; // Present on PENDING_AUTH, FAILED_AUTH, TERMINATED, INVALID_DATA
}

// ── Auth and subscribe ────────────────────────────────────────────────────────

interface AuthPayload {
key: string; // TZ-API-KEY-ID (field names are case-insensitive)
secret: string; // TZ-API-SECRET-KEY
}

// P&L stream - send after CONNECTED
interface PnlSubscribePayload {
account: string; // Account ID (e.g. "TZP12345678")
}

// Portfolio stream - send after CONNECTED
interface PortfolioSubscribePayload {
accountId: string;
subscriptions: Array<'Order' | 'Position'>; // Case-insensitive; duplicates accepted
}

// ── P&L stream messages ───────────────────────────────────────────────────────

interface PnlPosition {
positionId: string;
symbol: string; // OCC format for options
pnlCalc: {
unrealizedPnL: number;
dayUnrealizedPnL: number;
pctPnLMove: number;
dayPctPnLMove: number;
exposure: number; // Negative for short/sold-option positions
};
realizedPnl: number;
dayRealizedPnl: number;
}

interface PnlSnapshot {
accountValue: number;
availableCash: number;
optionCashUsed: number;
usedLeverage: number;
allowedLeverage: number;
sharesTraded: number;
exposure: number;
dayUnrealized: number;
dayRealized: number;
dayPnl: number;
totalUnrealized: number;
equityRatio: number;
positions: PnlPosition[];
}

interface PnlInitMessage {
ts: number;
action: 'init';
target: 'pnlReturn';
pnlReturn: PnlSnapshot;
}

// All fields are optional - only fields that changed from the previous message are included.
// Price-tick updates typically carry: accountValue, exposure, dayUnrealized, dayPnl, totalUnrealized.
interface AggCalcsUpdate {
accountValue?: number;
availableCash?: number;
optionCashUsed?: number;
usedLeverage?: number;
allowedLeverage?: number;
sharesTraded?: number;
exposure?: number;
dayUnrealized?: number;
dayRealized?: number;
dayPnl?: number;
totalUnrealized?: number;
equityRatio?: number;
}

interface PnlAggCalcsMessage {
ts: number;
action: 'update';
target: 'aggCalcs';
aggCalcs: AggCalcsUpdate; // Only changed fields are present
}

interface PnlPositionUpdate {
positionId: string;
symbol: string;
pnlCalc: Partial<PnlPosition['pnlCalc']>; // Only changed fields are present
}

interface PnlPositionMessage {
ts: number;
action: 'update';
target: 'position';
position: PnlPositionUpdate;
}

interface PnlErrorMessage {
ts: number;
action: 'error';
message: string;
}

type PnlMessage =
| PnlInitMessage
| PnlAggCalcsMessage
| PnlPositionMessage
| PnlErrorMessage;

// ── Portfolio stream messages ─────────────────────────────────────────────────

interface PortfolioMetaMessage {
ts: number;
accountId: string;
action: 'meta';
requestConfirmed: boolean;
}

interface PortfolioOrder {
accountId: string;
canceledQuantity: number;
clientOrderId: string;
executed: number;
lastPrice: number;
lastQuantity: number;
lastUpdated: string; // ISO 8601
leavesQuantity: number;
limitPrice: number;
maxDisplayQuantity: number;
openClose: string;
orderQuantity: number;
orderStatus: string;
orderType: string;
pegDifference: number;
pegOffsetType: string;
priceAvg: number;
priceStop: number;
route: string;
securityType: 'Stock' | 'Option' | 'MLEG';
side: string;
startTime: string; // ISO 8601
strikePrice: number;
symbol: string;
text: string;
timeInForce: string;
tradedSymbol: string | null;
}

interface PortfolioOrderMessage {
ts: number;
accountId: string;
action: 'update';
subscription: 'Order';
order: PortfolioOrder;
}

interface PortfolioPosition {
id: string; // Note: REST endpoints use `positionId` for the same value
accountId: string;
createdDate: string; // ISO 8601
dayOvernight: 'Day' | 'Overnight';
priceAvg: number;
priceClose: number;
priceOpen: number;
priceStrike: number;
putCall: 'Put' | 'Call' | 'None';
securityType: 'Stock' | 'Option';
shares: number; // Negative for short positions
side: 'Long' | 'Short';
symbol: string; // OCC format for options
updatedDate: string; // ISO 8601
}

interface PortfolioPositionMessage {
ts: number;
accountId: string;
action: 'update';
subscription: 'Position';
position: PortfolioPosition;
}

type PortfolioMessage =
| PortfolioMetaMessage
| PortfolioOrderMessage
| PortfolioPositionMessage;

Reconnection

The server does not send a keepalive ping. If the connection drops unexpectedly, reconnect and go through the full handshake again.

A simple exponential backoff avoids hammering the server during outages:

let retryCount = 0;
const MIN_RETRY_MS = 1_000;
const MAX_RETRY_MS = 30_000;

function scheduleReconnect() {
retryCount += 1;
const base = Math.min(MAX_RETRY_MS, MIN_RETRY_MS * Math.pow(2, retryCount - 1));
// Full jitter: random delay in [base/2, base].
const delay = Math.round(base * (0.5 + Math.random() * 0.5));
setTimeout(connect, delay);
}

Reset retryCount to 0 after a successful CONNECTED. Do not schedule a reconnect after FAILED_AUTH - the credentials themselves need to change.


Disconnecting

Close the WebSocket connection when you no longer need updates. There is no unsubscribe message; closing the connection ends the stream immediately.

ws.close(1000, 'client disconnect');

Debugging tips

Identify message types quickly

Every message from the server falls into one of two categories:

  • System message - has "@system": true. Route these to your connection state machine.
  • Data message - no @system field. Route these to your application logic using action and target (P&L) or action and subscription (Portfolio).

Timing

The PENDING_AUTH message typically arrives within a few hundred milliseconds of opening the connection. After you send credentials, CONNECTED (or FAILED_AUTH) follows shortly. The P&L initial snapshot arrives soon after the subscribe payload. You can use the ts field (Unix milliseconds) on any message to measure round-trip times.

Partial updates

P&L update messages (aggCalcs and position) only include fields that have changed since the last message. Always merge updates into your local state rather than replacing it. Do not assume every field from the initial snapshot will be present in subsequent updates.

Connection stays open on errors

TERMINATED and INVALID_DATA do not close the connection. You can send a corrected subscribe payload immediately without reconnecting. For the P&L stream, any existing subscription continues delivering updates even after an INVALID_DATA message.

After market hours

Both streams remain connected outside regular trading hours. The P&L stream delivers the initial snapshot but updates arrive infrequently or not at all (no price changes). The Portfolio stream stays open and will deliver updates if any orders change state (e.g. GTC order management).