Receive every trade of your accounts as it executes, and again if it is cancelled, without polling.
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:
| Channel | Event type | What you receive |
|---|---|---|
order | ORDER_UPDATE | The complete state of an order every time it changes. |
trade | TRADE | A 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.
trading.read scope, the same token you use for the REST API.accountId of each returned account is the account number to use here, for example 5RE12345.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.wss://api.public.com/userapigateway?accountIds={accounts}&channels={channels}GET /userapigateway?accountIds=5RE12345&channels=trade
Authorization: Bearer <token>
Upgrade: websocket
Connection: UpgradeYour 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.
| Parameter | Rules |
|---|---|
accountIds | Required. 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. |
channels | Required. 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.
npx wscat \
-c "wss://api.public.com/userapigateway?accountIds=5RE12345&channels=trade" \
-H "Authorization: Bearer $TOKEN"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);
});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.
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
{"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.
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"
}| Field | Type | Meaning |
|---|---|---|
channel | string | The channel the event belongs to. trade for this stream. |
type | string | The kind of event. TRADE on the trade channel. Ignore types you do not know. |
payload | object | The event. For TRADE, the trade itself, see below. |
timestamp | string | When the frame was sent, as an ISO-8601 UTC timestamp. It is not the time the trade executed, which is the trade's timestamp. |
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 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.
| Field | Type | Meaning |
|---|---|---|
tradeId | string | The ID of the trade. Keep your state by tradeId. |
orderId | string (UUID), nullable | The order the trade belongs to. null for a trade that does not belong to an order, so do not require it. |
originalTradeId | string, nullable | The trade this trade corrects. null when the trade is not a correction. |
accountNumber | string | The account that owns the trade, for example 5RE12345. Use it to route the event when you subscribed to several accounts. |
status | string | NEW when the trade executed, CANCELLED when it was taken back. See Trade lifecycle. |
instrument | object | {"symbol": "AAPL", "type": "EQUITY"}. The symbol is the plain symbol, with no suffix. See Instrument types. |
quantity | decimal string | The number of shares or contracts traded. Always positive: side gives the direction. |
price | decimal string | The price per share or contract. |
side | string | BUY or SELL. |
timestamp | string | When the trade executed, ISO-8601 UTC. Fractional seconds are included when available. |
marketIdentificationCode | string, nullable | The 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.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.
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"
}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"
}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"
}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"
}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"
}| Status | When it is sent |
|---|---|
NEW | When the trade is recorded in the account. Most trades only ever get this one event. |
CANCELLED | When 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
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.
trading.read scope and list the accounts with Get accounts.trade channel (and order, if you use it). Start reading events immediately and buffer them.trades. Get order returns one order with its trades. Store the trades by tradeId.tradeId wins.{"action":"ping"} every 60 seconds.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.
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
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
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.
| Question | Answer |
|---|---|
| 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. |
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.
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.
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.
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.
Recommended
429.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.
| Status | Cause | What to do |
|---|---|---|
400 | accountIds 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. |
401 | The 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. |
403 | The 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. |
429 | One 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, 503 | The 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.
| Symptom | Likely cause |
|---|---|
| Connected, but no frames | No 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 minutes | No heartbeat. See Keeping the connection open. |
| Connection drops after 2 hours | The maximum connection time. Reconnect and reload the state. |
| A trade I expected never came | It 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 API | Floating-point rounding. Parse quantity and price with a decimal type. |
| Limit | Value |
|---|---|
| Open connections per account | 5, 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 timeout | 10 minutes without a message in either direction. Send a ping to avoid it. |
| Maximum connection time | 2 hours. The connection is closed after that, however active it is. |
| Channels | order, 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.