Skip to content
Skip to main content

About WebSocket API

Beta - surface still evolving

The WebSocket API is in beta. The P&L and Portfolio streams are available on paper and live today. Handshake details, subscription commands, message shapes, and field names may change before GA.

Design integrations that can pick up schema updates on a normal release cycle. Watch the Change Log for announcements.

Overview​

The WebSocket API delivers real-time account updates over a persistent connection. Authenticate with the same API key pair as REST, then subscribe to two independent streams:

  • P&L Stream - account value, leverage, and per-position unrealized profit and loss, updated on every price tick.
  • Portfolio Stream - order state changes and position updates driven by fills and cancellations.

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

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

Paper and live use the same WebSocket hosts as the REST API. Send the API key pair for the environment you want - paper keys stream paper account data; live keys stream live data. See Authentication.

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 every 5 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. Handle every @system status in your connection state machine.

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 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 {
account: string; // REST uses `accountId`
cancelledQuantity: number; // REST uses `canceledQuantity`
userOrderId: string; // `"{accountId}:{clientOrderId}"` - split on first `:`
executed: number;
lastPrice: number;
lastQty: number; // REST uses `lastQuantity`
lastUpdated: string; // ISO 8601
leavesQuantity: number;
lvsQty?: number; // Duplicate of leavesQuantity on WS rows
limitPrice: number;
maxDisplayQty: number; // REST uses `maxDisplayQuantity`
openClose: string;
orderQuantity: number;
status: string; // REST uses `orderStatus`
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 | null;
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.

Exponential backoff avoids reconnect storms when the connection drops:

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');

Connection and message handling​

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

PENDING_AUTH arrives on connect. After you send credentials, the server responds with CONNECTED or FAILED_AUTH. After a successful P&L subscribe, the server sends the init snapshot. Use the ts field (Unix milliseconds) on any message to measure latency.

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; when prices are unchanged it does not emit aggCalcs / position updates. During premarket and after-hours, updates arrive when a held position's quote changes. The Portfolio stream stays open and delivers updates when orders change state (for example GTC management).