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

Order Updates WebSocket

Receive the complete state of your orders every time they change, without polling.

Overview

There are two ways to follow an order after you place it:

ApproachHow it worksTrade-off
PollingCall 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 WebSocketOpen 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.

Prerequisites

  • Authentication. An OAuth2 bearer token with the trading.read scope, the same token you use for the REST API. Placing orders still needs trading.write, but receiving events does not.
  • 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. Do not put the token in the URL.

Connecting

wss://api.public.com/userapigateway?accountIds={accounts}&channels={channels}
GET /userapigateway?accountIds=5RE12345&channels=order
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 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.

Example: wscat

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

Example: Node.js

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

What a successful connection looks like

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.

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. 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.

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:

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

ORDER_UPDATE

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 object

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.

FieldTypeMeaning
orderIdstring (UUID)The ID of the order.
accountNumberstringThe account that owns the order, for example 5RE12345. Use it to route the event when you subscribed to several accounts.
versionintegerIncreases with every change of the order. Keep the state with the highest version per orderId.
instrumentobject{"symbol": "AAPL", "type": "EQUITY"}. The symbol is the plain symbol, with no suffix. For a multi-leg order the type is MULTI_LEG_INSTRUMENT.
typestringThe order type: MARKET, LIMIT, STOP, STOP_LIMIT. Other values, such as trailing stop and on-close types, can occur.
sidestringBUY or SELL.
statusstringThe status. See Statuses.
quantitynumber, optionalThe quantity, for orders placed by quantity. Mutually exclusive with notionalValue.
notionalValuenumber, optionalThe dollar amount, for orders placed by amount. Mutually exclusive with quantity.
expirationobject{"timeInForce": "DAY", "expirationTime": null}. expirationTime is set when timeInForce is GTD.
limitPricenumber, optionalFor limit and stop limit orders.
stopPricenumber, optionalFor stop and stop limit orders.
trailingStopobject, optionalThe trail of a trailing stop order: type (AMOUNT or PERCENTAGE), value, and limitOffset. A percentage is a fraction, so 0.05 is 5%.
highWatermarknumber, optionalThe best price a trailing stop currently trails.
sessionstring, optionalFor equity orders: CORE, EXTENDED, or TWENTY_FOUR_HOURS.
openCloseIndicatorstring, optionalOPEN or CLOSE, for single-leg option orders and short selling orders.
filledQuantitynumber, optionalThe cumulative filled quantity, once the order has at least one trade.
averagePricenumber, optionalThe average fill price, once the order has at least one trade.
legsarray, optionalFor multi-leg orders. Each leg has instrument (symbol and type, EQUITY or OPTION), side, openCloseIndicator, and ratioQuantity.
bracketIdstring (UUID), optionalFor bracket orders: the orderId of the entry order, shared by all orders of the bracket.
rejectReasonstring, optionalText that accompanies the status, for example why an order was rejected. Human readable and may change.
effectivebooleanWhether 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.
editablebooleanWhether the order can be edited.
cancellablebooleanWhether the order can be cancelled.
createdAtstringWhen the order was created, ISO-8601 UTC.
lastModifiedstringWhen the order last changed, ISO-8601 UTC. The timestamp of the frame is the same or later.
editedAtstring, optionalNot provided yet, always null.

Recommended

Prices and quantities are JSON numbers. Parse them with a decimal type, not a binary floating-point type.

Statuses

StatusFinalMeaning
NEWNoThe order was accepted.
PARTIALLY_FILLEDNoPart of the order has executed.
PENDING_REPLACENoA replacement of the order is pending.
PENDING_CANCELNoA cancellation of the order is pending.
QUEUED_CANCELLEDNoThe order will be cancelled at the next market open.
REPLACEDNoThe order was replaced.
FILLEDYesThe whole order has executed.
CANCELLEDYesThe order was cancelled. It can have executions from before the cancellation.
REJECTEDYesThe order was rejected when it was entered. See rejectReason.
EXPIREDYesThe order expired according to its time in force.

Example: new limit order

{
  "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"
}

Example: the same order, filled

{
  "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"
}

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 order channel. Start reading events immediately and buffer them.
  3. Load the current state from the REST API. Get account portfolio v2 returns the open orders in the same shape as the events, and Search orders returns orders created in the last 30 days, up to 500 per call.
  4. Merge the buffered events into the loaded state and keep applying new events. For each orderId, keep the order with the highest version.
  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 event while you were disconnected.

REST orders vs. WebSocket orders

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

Map both to your own model rather than deserializing them into one type. Because REST orders have no 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.

Behavior to design around

At-most-once, no replay

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

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, for example when an order stays NEW far longer than expected.

Events can be repeated

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

Keep the order with the highest version per orderId and drop any event with a lower or equal version.

One event per state, not per trade

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.

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 order 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 events 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.

StatusCause
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 not one that is offered.
401The Authorization header is missing, is not of the form Bearer <token>, or the token is invalid or expired.
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.
429One of the requested accounts already has the maximum number of open connections. See Limits.
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.

Limits

LimitValue
Open connections per account5. 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.