Receive the complete state of your orders every time they change, without polling.
There are two ways to follow an order after you place it:
| Approach | How it works | Trade-off |
|---|---|---|
| Polling | Call Get order or Search orders repeatedly. | Simple, but every update costs a request and arrives only as often as you poll. Counts against your rate limit. |
| Order Updates WebSocket | Open one WebSocket connection. The complete order is pushed to you every time it changes. | Low latency and no polling. Delivery is at-most-once with no replay, so you also need the REST API to recover state. |
The WebSocket is read-only. You still place and cancel orders with the REST API. The only message you send over the connection is the heartbeat described in Keeping the connection open.
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. Placing orders still needs trading.write, but receiving events does not.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. Do not put the token in the URL.wss://api.public.com/userapigateway?accountIds={accounts}&channels={channels}GET /userapigateway?accountIds=5RE12345&channels=order
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 order for this stream, and see Trades WebSocket for trade. A connection receives only the events of the channels it subscribed to, so channels added in the future never reach existing clients. |
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 events.
npx wscat \
-c "wss://api.public.com/userapigateway?accountIds=5RE12345&channels=order" \
-H "Authorization: Bearer $TOKEN"import WebSocket from "ws";
const url = "wss://api.public.com/userapigateway?accountIds=5RE12345&channels=order";
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.type === "ORDER_UPDATE") {
handleOrder(event.payload.order);
}
// 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 first order changes. 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. 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.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:
{
"channel": "order",
"type": "ORDER_UPDATE",
"payload": { "order": { ... } },
"timestamp": "2026-10-28T20:00:00Z"
}| Field | Type | Meaning |
|---|---|---|
channel | string | The channel the event belongs to. order for this stream. |
type | string | The kind of event. ORDER_UPDATE on the order channel. Ignore types you do not know. |
payload | object | The event, which depends on type. |
timestamp | string | When the frame was sent, as an ISO-8601 UTC timestamp. It is not the time the order changed, which is the order's lastModified. |
Sent every time an order changes: when it is accepted, partially filled, filled, cancelled, rejected, expired, or replaced. The payload has a single field, order, which is the complete order after the change, not the difference from the previous event. Use version to order the events of an order. See Behavior to design around.
You receive the events of the accounts you subscribed to. Delivery is best-effort.
The order has the same shape as the orders in the orders list of Get account portfolio v2. It is not identical to the order returned by the order endpoints. See REST orders vs. WebSocket orders. Optional fields are null or absent when they do not apply.
| Field | Type | Meaning |
|---|---|---|
orderId | string (UUID) | The ID of the order. |
accountNumber | string | The account that owns the order, for example 5RE12345. Use it to route the event when you subscribed to several accounts. |
version | integer | Increases with every change of the order. Keep the state with the highest version per orderId. |
instrument | object | {"symbol": "AAPL", "type": "EQUITY"}. The symbol is the plain symbol, with no suffix. For a multi-leg order the type is MULTI_LEG_INSTRUMENT. |
type | string | The order type: MARKET, LIMIT, STOP, STOP_LIMIT. Other values, such as trailing stop and on-close types, can occur. |
side | string | BUY or SELL. |
status | string | The status. See Statuses. |
quantity | number, optional | The quantity, for orders placed by quantity. Mutually exclusive with notionalValue. |
notionalValue | number, optional | The dollar amount, for orders placed by amount. Mutually exclusive with quantity. |
expiration | object | {"timeInForce": "DAY", "expirationTime": null}. expirationTime is set when timeInForce is GTD. |
limitPrice | number, optional | For limit and stop limit orders. |
stopPrice | number, optional | For stop and stop limit orders. |
trailingStop | object, optional | The trail of a trailing stop order: type (AMOUNT or PERCENTAGE), value, and limitOffset. A percentage is a fraction, so 0.05 is 5%. |
highWatermark | number, optional | The best price a trailing stop currently trails. |
session | string, optional | For equity orders: CORE, EXTENDED, or TWENTY_FOUR_HOURS. |
openCloseIndicator | string, optional | OPEN or CLOSE, for single-leg option orders and short selling orders. |
filledQuantity | number, optional | The cumulative filled quantity, once the order has at least one trade. |
averagePrice | number, optional | The average fill price, once the order has at least one trade. |
legs | array, optional | For multi-leg orders. Each leg has instrument (symbol and type, EQUITY or OPTION), side, openCloseIndicator, and ratioQuantity. |
bracketId | string (UUID), optional | For bracket orders: the orderId of the entry order, shared by all orders of the bracket. |
rejectReason | string, optional | Text that accompanies the status, for example why an order was rejected. Human readable and may change. |
effective | boolean | Whether the order is working. false while the order waits for another order to fill, like the exit orders of a bracket before the entry order has filled. |
editable | boolean | Whether the order can be edited. |
cancellable | boolean | Whether the order can be cancelled. |
createdAt | string | When the order was created, ISO-8601 UTC. |
lastModified | string | When the order last changed, ISO-8601 UTC. The timestamp of the frame is the same or later. |
editedAt | string, optional | Not provided yet, always null. |
Recommended
| Status | Final | Meaning |
|---|---|---|
NEW | No | The order was accepted. |
PARTIALLY_FILLED | No | Part of the order has executed. |
PENDING_REPLACE | No | A replacement of the order is pending. |
PENDING_CANCEL | No | A cancellation of the order is pending. |
QUEUED_CANCELLED | No | The order will be cancelled at the next market open. |
REPLACED | No | The order was replaced. |
FILLED | Yes | The whole order has executed. |
CANCELLED | Yes | The order was cancelled. It can have executions from before the cancellation. |
REJECTED | Yes | The order was rejected when it was entered. See rejectReason. |
EXPIRED | Yes | The order expired according to its time in force. |
{
"channel": "order",
"type": "ORDER_UPDATE",
"payload": {
"order": {
"orderId": "0b0b6a6e-6a41-4b5b-9d33-0d9c6a3a8f10",
"accountNumber": "5RE12345",
"version": 1,
"instrument": { "symbol": "AAPL", "type": "EQUITY" },
"type": "LIMIT",
"side": "BUY",
"status": "NEW",
"quantity": 10,
"expiration": { "timeInForce": "DAY", "expirationTime": null },
"limitPrice": 180.00,
"session": "CORE",
"effective": true,
"editable": true,
"cancellable": true,
"createdAt": "2026-10-28T20:00:00Z",
"lastModified": "2026-10-28T20:00:00Z"
}
},
"timestamp": "2026-10-28T20:00:00.120Z"
}{
"channel": "order",
"type": "ORDER_UPDATE",
"payload": {
"order": {
"orderId": "0b0b6a6e-6a41-4b5b-9d33-0d9c6a3a8f10",
"accountNumber": "5RE12345",
"version": 3,
"instrument": { "symbol": "AAPL", "type": "EQUITY" },
"type": "LIMIT",
"side": "BUY",
"status": "FILLED",
"quantity": 10,
"expiration": { "timeInForce": "DAY", "expirationTime": null },
"limitPrice": 180.00,
"session": "CORE",
"filledQuantity": 10,
"averagePrice": 179.85,
"effective": true,
"editable": false,
"cancellable": false,
"createdAt": "2026-10-28T20:00:00Z",
"lastModified": "2026-10-28T20:00:02Z"
}
},
"timestamp": "2026-10-28T20:00:02.150Z"
}trading.read scope and list the accounts with Get accounts.order channel. Start reading events immediately and buffer them.orderId, keep the order with the highest version.{"action":"ping"} every 60 seconds.The order endpoints (Get order and Search orders) return a slightly different order than the WebSocket. They include closedAt, filledAt, and trades, call the session equityMarketSession, and have no version, accountNumber, effective, editable, or cancellable.
Recommended
version, treat a REST read as the baseline and apply events that arrive afterwards, comparing lastModified when you must decide which of two states is newer.An event is posted to the connections that are open at that moment. If you are disconnected, or an event cannot be delivered, it is not retried and not replayed when you reconnect.
Recommended
NEW far longer than expected.The events of an order are published in order. After a restart on the server side, events that were already sent can be sent again, including older versions of an order you already have a newer version of. Each event carries the complete order, so you never need to merge fields.
Recommended
version per orderId and drop any event with a lower or equal version.Every change of an order gives it a new version and produces one event with the complete order after that change, so a partial fill is one event and the next partial fill is another. The exception is a multi-leg order: executions on the individual legs do not change the order until the legs are balanced, so they are reported together in one event. Order events do not list individual trades. Subscribe to the trade channel of the Trades WebSocket to receive each trade, or use Get order. Order and trade events are delivered independently, so do not expect one to arrive before the other.
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 order 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 events 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 |
|---|---|
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 not one that is offered. |
401 | The Authorization header is missing, is not of the form Bearer <token>, or the token is invalid or expired. |
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. |
429 | One of the requested accounts already has the maximum number of open connections. See Limits. |
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.
| Limit | Value |
|---|---|
| Open connections per account | 5. 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.