API

Get Started

WelcomeQuickstartGuidesChangelogSDKs and ToolsAdditional InfoFAQ

Streaming

WSSOrder UpdatesWSSTrades

Authorization

POSTCreate personal access token

List Accounts

GETGet accounts

Account Details

GETGet account portfolio v2GETGet history

Tax Lot Selling

GETGet unrealized tax lotsGETGet unrealized tax lots for symbolGETGet unrealized tax lots csv

Instrument Details

GETGet all instrumentsGETGet instrumentGETSearch bonds

Market Data

POSTGet quotesGETGet bond detailsPOSTGet option expirationsPOSTGet option chainGETGet bars v2GETGet bars v2 with aggregation

Orders

POSTPreflight single legPOSTPreflight multi legPOSTPlace orderPUTReplace orderPOSTSearch ordersPOSTPlace multileg orderGETGet orderDELETECancel order

Option Details

GETGet option greeksPOSTGet strategy quote

Event Contracts

POSTGet event summaryGETGet event categoriesGETGet event details
HelpFeedback

Streaming

Trades WebSocket

Receive every trade of your accounts as it executes, and again if it is cancelled, without polling.

Overview

A trade is one execution: a quantity at a price on a side, for one instrument, in one account. An order that fills in three parts produces three trades. The Trades WebSocket pushes each trade to you as it is recorded, and again if it is cancelled.

Trades are one channel of the Public API WebSocket. The other is the order channel, documented in Order Updates WebSocket. A connection receives only the channels it subscribes to:

ChannelEvent typeWhat you receive
orderORDER_UPDATEThe complete state of an order every time it changes.
tradeTRADEA single trade when it executes, and again if it is cancelled. This page.

Subscribe with channels=order,trade to receive both on one connection. The WebSocket is read-only, and trades do not include fees or commissions. For those, use Get history once the transaction posts.

The WebSocket is served on the same host as the REST API: wss://api.public.com.

Prerequisites

  • Authentication. An OAuth2 bearer token with the trading.read scope, the same token you use for the REST API.
  • Account. The account numbers you subscribe to must belong to the user the token was issued for. Get them from Get accounts. The accountId of each returned account is the account number to use here, for example 5RE12345.
  • A client that can set request headers. The token is sent in the Authorization header of the handshake. Browsers cannot set headers on a WebSocket, so connect from a server-side client and relay to the browser yourself if you need to. Do not put the token in the URL.

Connecting

wss://api.public.com/userapigateway?accountIds={accounts}&channels={channels}
GET /userapigateway?accountIds=5RE12345&channels=trade
Authorization: Bearer <token>
Upgrade: websocket
Connection: Upgrade

Your WebSocket library sends the upgrade headers. You supply the URL and the Authorization header. The path has no version number: a future version of this API will be selected by you when you connect.

Query parameters

ParameterRules
accountIdsRequired. A comma-separated list of account numbers, for example 5RE12345,5RE67890, or * for all accounts of the token's owner. * cannot be combined with account numbers. Every account number must belong to the token's owner, or the connection is rejected.
channelsRequired. A comma-separated list of channels: order, trade. Use trade for this stream. An unknown channel, such as trades, rejects the whole connection.

Duplicates in either list are ignored. There is no default subscription: a connection without accountIds or channels is rejected. accountIds=* stands for the accounts the user has at the moment of connecting. An account opened later is not added to the open connection: reconnect to receive its trades.

Example: wscat

npx wscat \
  -c "wss://api.public.com/userapigateway?accountIds=5RE12345&channels=trade" \
  -H "Authorization: Bearer $TOKEN"

Example: Node.js

import WebSocket from "ws";

const url = "wss://api.public.com/userapigateway?accountIds=5RE12345&channels=trade";
const socket = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } });

let heartbeat;

socket.on("open", () => {
  console.log("connected");
  heartbeat = setInterval(() => socket.send(JSON.stringify({ action: "ping" })), 60_000);
});

socket.on("message", (data) => {
  const event = JSON.parse(data.toString());
  if (event.channel === "trade" && event.type === "TRADE") {
    handleTrade(event.payload);
  }
  // Ignore every other type: new event types can be added without notice.
});

socket.on("close", () => {
  clearInterval(heartbeat);
  // Reconnect with a fresh token and backoff, then reload state from the REST API.
});

socket.on("unexpected-response", (_request, response) => {
  console.error("rejected with HTTP", response.statusCode);
});

What a successful connection looks like

The handshake completes with HTTP 101 and the server sends nothing until the next trade in one of the accounts. There is no welcome message. A connection that is rejected never upgrades: your client sees an HTTP error status on the handshake. See Errors.

Keeping the connection open

An idle connection is closed after 10 minutes, and any connection is closed after 2 hours at the latest, even when it is busy. An account can go much longer than 10 minutes without a trade, so send a heartbeat to stay under the idle limit, and reconnect when the connection closes.

Send this text message:

{"action":"ping"}

and you receive:

{"type":"PONG"}

Any message you send resets the idle timer. No other message is supported, so do not send anything else.

Recommended

Send {"action":"ping"} every 60 seconds. When you are done, close the connection with a normal close frame so its slot is freed at once.

The token is checked only when you connect. An open connection stays open after the token expires, until the 2 hour limit closes it. When you reconnect, use a fresh token.

Events

The frame

Every message pushed to you is a JSON text frame with the same four fields, on every channel:

{
  "channel": "trade",
  "type": "TRADE",
  "payload": { ... },
  "timestamp": "2026-10-28T20:00:00Z"
}
FieldTypeMeaning
channelstringThe channel the event belongs to. trade for this stream.
typestringThe kind of event. TRADE on the trade channel. Ignore types you do not know.
payloadobjectThe event. For TRADE, the trade itself, see below.
timestampstringWhen the frame was sent, as an ISO-8601 UTC timestamp. It is not the time the trade executed, which is the trade's timestamp.

TRADE

Sent when a trade is recorded in one of the accounts you subscribed to, and again with the same tradeId if the trade is cancelled. Unlike ORDER_UPDATE, the payload is the trade itself, not wrapped in another object.

The trade object

The trade has the fields of the trades in the trades array of Get order, plus orderId, originalTradeId, accountNumber, and status. Nullable fields are always present, with the value null when they do not apply.

FieldTypeMeaning
tradeIdstringThe ID of the trade. Keep your state by tradeId.
orderIdstring (UUID), nullableThe order the trade belongs to. null for a trade that does not belong to an order, so do not require it.
originalTradeIdstring, nullableThe trade this trade corrects. null when the trade is not a correction.
accountNumberstringThe account that owns the trade, for example 5RE12345. Use it to route the event when you subscribed to several accounts.
statusstringNEW when the trade executed, CANCELLED when it was taken back. See Trade lifecycle.
instrumentobject{"symbol": "AAPL", "type": "EQUITY"}. The symbol is the plain symbol, with no suffix. See Instrument types.
quantitydecimal stringThe number of shares or contracts traded. Always positive: side gives the direction.
pricedecimal stringThe price per share or contract.
sidestringBUY or SELL.
timestampstringWhen the trade executed, ISO-8601 UTC. Fractional seconds are included when available.
marketIdentificationCodestring, nullableThe ISO 10383 market identifier code of the venue that executed the trade, such as XNAS, when the venue reported it. See Fill details.

Recommended

quantity and price are strings in plain decimal notation, never with an exponent ("0.00001", not 1E-5). Parse them with a decimal type, not a binary floating-point type. This differs from the order channel, where prices and quantities are JSON numbers.

Instrument types

instrument.type is one of:

EQUITY · OPTION · MULTI_LEG_INSTRUMENT · CRYPTO · ALT · TREASURY · BOND · INDEX · EVENTCONTRACT

The symbol is the one you use in REST requests: BTC, not BTC-CRYPTO, and AAPL251219C00150000, not AAPL251219C00150000-OPTION. The kind of instrument is in instrument.type. Trades in instruments the Public API does not support are not published.

Example: equity

A whole share bought on a venue that reported its market code.

{
  "channel": "trade",
  "type": "TRADE",
  "payload": {
    "tradeId": "T-1",
    "orderId": "0b0b6a6e-6a41-4b5b-9d33-0d9c6a3a8f10",
    "originalTradeId": null,
    "accountNumber": "5RE12345",
    "status": "NEW",
    "instrument": { "symbol": "AAPL", "type": "EQUITY" },
    "quantity": "1",
    "price": "170.00",
    "side": "BUY",
    "timestamp": "2026-10-28T19:59:59Z",
    "marketIdentificationCode": "XNAS"
  },
  "timestamp": "2026-10-28T20:00:00Z"
}

Example: option

Two contracts sold. The symbol has no -OPTION suffix and the venue did not report a market code.

{
  "channel": "trade",
  "type": "TRADE",
  "payload": {
    "tradeId": "T-2",
    "orderId": "5d2c7a0e-1f64-4d0b-8a0f-6c3b2f9e41aa",
    "originalTradeId": null,
    "accountNumber": "5RE12345",
    "status": "NEW",
    "instrument": { "symbol": "AAPL251219C00150000", "type": "OPTION" },
    "quantity": "2",
    "price": "4.35",
    "side": "SELL",
    "timestamp": "2026-10-28T15:12:03Z",
    "marketIdentificationCode": null
  },
  "timestamp": "2026-10-28T15:12:03Z"
}

Example: crypto

A fractional quantity. Decimals stay strings, so no precision is lost.

{
  "channel": "trade",
  "type": "TRADE",
  "payload": {
    "tradeId": "T-3",
    "orderId": "9a3f1e55-2b7c-4c9d-9e11-0d4f6a7b8c90",
    "originalTradeId": null,
    "accountNumber": "5RE12345",
    "status": "NEW",
    "instrument": { "symbol": "BTC", "type": "CRYPTO" },
    "quantity": "0.5",
    "price": "65000.10",
    "side": "BUY",
    "timestamp": "2026-10-28T19:59:59Z",
    "marketIdentificationCode": null
  },
  "timestamp": "2026-10-28T20:00:01Z"
}

Example: cancelled trade

The equity trade above, taken back. Same tradeId, status now CANCELLED. The rest of the payload repeats the trade.

{
  "channel": "trade",
  "type": "TRADE",
  "payload": {
    "tradeId": "T-1",
    "orderId": "0b0b6a6e-6a41-4b5b-9d33-0d9c6a3a8f10",
    "originalTradeId": null,
    "accountNumber": "5RE12345",
    "status": "CANCELLED",
    "instrument": { "symbol": "AAPL", "type": "EQUITY" },
    "quantity": "1",
    "price": "170.00",
    "side": "BUY",
    "timestamp": "2026-10-28T19:59:59Z",
    "marketIdentificationCode": "XNAS"
  },
  "timestamp": "2026-10-29T08:30:00Z"
}

Example: trade without an order

A trade that does not belong to an order. orderId is null.

{
  "channel": "trade",
  "type": "TRADE",
  "payload": {
    "tradeId": "A-77",
    "orderId": null,
    "originalTradeId": null,
    "accountNumber": "5RE12345",
    "status": "NEW",
    "instrument": { "symbol": "AAPL", "type": "EQUITY" },
    "quantity": "3",
    "price": "168.40",
    "side": "BUY",
    "timestamp": "2026-10-28T14:00:00Z",
    "marketIdentificationCode": null
  },
  "timestamp": "2026-10-28T14:00:02Z"
}

Trade lifecycle

StatusWhen it is sent
NEWWhen the trade is recorded in the account. Most trades only ever get this one event.
CANCELLEDWhen the trade is taken back. The same tradeId is sent again with the rest of the trade repeated. The trade did not happen: reverse its effect in your records.

Recommended

Keep one record per tradeId and let each event replace the earlier one. The events of a single trade arrive in order, so the latest event is the current status.

A trade that corrects an earlier trade has the ID of the earlier trade in originalTradeId. Corrections and cancellations are rare, but handle both: the fields are always in the payload.

Recommended workflow

  1. Get a token with the trading.read scope and list the accounts with Get accounts.
  2. Open the WebSocket for the accounts and the trade channel (and order, if you use it). Start reading events immediately and buffer them.
  3. Once the connection is open, load the current state from the REST API. Search orders returns orders created in the last 30 days, up to 500 per call, each with its trades. Get order returns one order with its trades. Store the trades by tradeId.
  4. Apply the buffered events in the order received, then keep applying new events as they arrive. The latest event per tradeId wins.
  5. Send {"action":"ping"} every 60 seconds.
  6. When the connection closes, for any reason, reconnect with a fresh token and repeat from step 2. Do not assume you saw every trade while you were disconnected.

Buffering first and reading second closes the gap between the two: a trade recorded while you read the REST API is in the buffer, and applying it on top of the REST state is harmless. Load the REST state after the connection is open, not before: a trade recorded in the instant right after you connect can otherwise be missed.

Trades returned by the REST API have no status, orderId, or accountNumber field. Treat them as NEW trades of the order and account you read them from, and let events update them.

Example: a complete client in Node.js

A skeleton with the ws package (Node.js 20 or later) that buffers, loads, and reconnects. The REST call is left for you to fill in.

import WebSocket from "ws";

const URL = "wss://api.public.com/userapigateway?accountIds=5RE12345&channels=trade";
const PING_EVERY_MS = 60_000;          // idle connections are closed after 10 minutes
const MAX_BACKOFF_MS = 30_000;
const FATAL = new Set([400, 401, 403]); // do not retry these unchanged

// The latest known state per tradeId. A later frame replaces an earlier one.
const trades = new Map();

function applyFrame(frame) {
  if (frame.channel !== "trade" || frame.type !== "TRADE") {
    return; // ignore PONG and every channel and type this client does not know
  }
  trades.set(frame.payload.tradeId, frame.payload);
}

async function loadTradesFromRest() {
  // Call Search orders for the account and put the trades of every order in `trades`.
}

export function connect(getToken, attempt = 0) {
  const ws = new WebSocket(URL, { headers: { Authorization: `Bearer ${getToken()}` } });
  let buffered = [];   // frames that arrive while the REST state is loading
  let live = false;
  let rejectedWith;    // the HTTP status of a rejected handshake
  let pingTimer;

  ws.on("open", async () => {
    attempt = 0;
    pingTimer = setInterval(() => ws.send(JSON.stringify({ action: "ping" })), PING_EVERY_MS);
    try {
      await loadTradesFromRest();
    } catch (error) {
      console.error("Could not load the trades, reconnecting:", error.message);
      ws.terminate(); // do not go live on partial state
      return;
    }
    buffered.forEach(applyFrame);
    buffered = [];
    live = true;
  });

  ws.on("message", (data) => {
    const frame = JSON.parse(data.toString());
    if (live) applyFrame(frame); else buffered.push(frame);
  });

  ws.on("unexpected-response", (_request, response) => {
    rejectedWith = response.statusCode;
    response.resume();
    ws.terminate(); // ends in "close"
  });

  ws.on("error", (error) => console.error("WebSocket error:", error.message));

  ws.on("close", () => {
    clearInterval(pingTimer);
    if (FATAL.has(rejectedWith)) {
      console.error(`Connection rejected with ${rejectedWith}, not retrying`);
      return;
    }
    // Exponential backoff with jitter. 429, 500, and 503 end up here as well.
    const delay = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** attempt) * (0.5 + Math.random() / 2);
    setTimeout(() => connect(getToken, attempt + 1), delay);
  });

  return ws;
}

Recommended

This shows the shape, not everything a production client needs: refresh the token before it expires, and alert when the client keeps retrying.

Behavior to design around

At-most-once, no replay

A trade is posted to the connections that are open at that moment. If you are disconnected, or a trade cannot be delivered, it is not retried and not replayed when you reconnect.

Recommended

Read the current state from the REST API after every connect and reconnect, and use the REST API to reconcile if you suspect you missed something.

Apply events idempotently

An event with the same tradeId and status as one you already have changes nothing, which comes for free when the latest event per tradeId wins. If you hold more than one connection for an account, each connection receives every trade.

Ordering

QuestionAnswer
Are the trades of one order in order?Yes. They arrive in the order they were recorded.
Are the events of one trade in order?Yes. NEW always arrives before CANCELLED.
Are trades of different orders in order?No. Sort by the trade's timestamp if you need a timeline.
Does a trade arrive before or after the update of its order?Either. The order and trade channels are delivered independently, even for the same order on the same connection. Never wait for one to explain the other: join them by orderId when both are in.

Be liberal in what you accept

Fields, enum values, event types, and channels can be added without notice. Ignore fields you do not know, ignore event types you do not handle, and do not fail on an enum value you have not seen. Do not depend on the order of the fields in the JSON.

Subscriptions are fixed per connection

The accounts and channels are chosen in the URL when you connect. You cannot add or remove them on an open connection. To change them, open a new connection and close the old one.

Several accounts on one connection

One connection can carry several accounts. It counts as one of the connections of each of those accounts. See Limits. Use accountNumber in the trade to tell the accounts apart.

Authorization is checked when you connect

If the user loses access to an account while a connection is open, the connection keeps receiving that account's trades until it is closed or reaches the 2 hour limit. Reconnect regularly rather than relying on a connection to enforce access.

Reconnect with backoff

Recommended

Reconnect after a short delay that grows on repeated failures, for example 1, 2, 4, and up to 30 seconds, with some random jitter. A tight reconnect loop uses up your connection slots and gets rejected with 429.

Errors

A rejected handshake returns an HTTP status instead of upgrading. The body is a short plain-text message and is not meant to be parsed: rely on the status.

StatusCauseWhat to do
400accountIds or channels is missing or empty, an account number is not a valid account number, * is combined with account numbers, or a channel is misspelled (trades instead of trade).Fix the request. Do not retry unchanged.
401The Authorization header is missing, is not of the form Bearer <token>, or the token is invalid, expired, or for another environment.Get a new token. Test it against Get accounts.
403The token lacks the trading.readscope, or one of the requested accounts does not belong to the token's owner. The response does not say which account failed. With accountIds=* and a user that has no accounts, you also get 403.Do not retry unchanged.
429One of the requested accounts already has the maximum number of open connections. Often these are leftovers of a client that restarts without closing its connections.Close connections you no longer use, or retry later with backoff. A connection that dropped without closing frees its slot shortly after.
500, 503The handshake could not be processed, for example because the account lookup was unavailable.Retry with backoff.

After the connection is open, the server can close it at any time, including at the 2 hour limit and for idle timeouts. Treat every close as a reason to reconnect.

Troubleshooting

SymptomLikely cause
Connected, but no framesNo trade has happened yet in the subscribed accounts. Place a test order, and check that the connection includes the right account and the trade channel, not only order.
Connection drops after 10 minutesNo heartbeat. See Keeping the connection open.
Connection drops after 2 hoursThe maximum connection time. Reconnect and reload the state.
A trade I expected never cameIt happened while you were disconnected (there is no replay), it is in an instrument the Public API does not support, or it is in an account opened after you connected with accountIds=*.
Totals differ from the REST APIFloating-point rounding. Parse quantity and price with a decimal type.

Limits

LimitValue
Open connections per account5, across all channels. A connection for several accounts counts against each of them. Connection number 6 for an account is rejected with 429. A connection frees its slot when it closes.
Idle timeout10 minutes without a message in either direction. Send a ping to avoid it.
Maximum connection time2 hours. The connection is closed after that, however active it is.
Channelsorder, trade

Opening a connection makes one request to the REST API on your behalf to check your token and accounts. The REST rate limits (per client, shared across all endpoints) apply to your REST calls as usual. On a 429 from a REST call, back off and retry.