WebSocket channels
Live updates run over Phoenix channels on a single socket. Join the topics you care about; each pushes events as things change.
| Use Case | Channel |
|---|---|
| The book for a market you trade | orderbook |
| Price history for a chart | market_stats |
| Prices and trades across markets | ticker, trades |
| Traded volume for an event | events |
| Your orders as they are accepted and filled | orders:{user_id} |
| Your fills | fills:{user_id} |
| Your positions and settlements | positions:{user_id}, settlements:{user_id} |
| Your balance and exposure | balances:{user_id} |
| All of your account on one topic | account:{user_id} |
| What an order would cost before you place it | order_slip:{user_id} |
| Markets appearing, changing status, or moving | markets, market_updates |
Why the socket rather than REST
Section titled “Why the socket rather than REST”REST answers what was true when you asked. For anything you have to react to (a fill, a book move, a balance change), the socket is the only way to know promptly, and it is what STX’s own apps use.
Two things follow from that:
- Poll REST for state you can afford to be stale about, like the market list at startup. Stream everything else.
- Reconcile periodically anyway. Take a REST snapshot on a slow cadence and compare: a dropped message on a live connection is silent, and there are no sequence numbers to detect one.
This is not a plain WebSocket
Section titled “This is not a plain WebSocket”STX runs Phoenix channels over the socket, which behaves differently from what “WebSocket API” usually implies. Three differences matter before you write any code.
Connecting subscribes you to nothing. A raw WebSocket usually starts streaming once it opens. Here the socket is only a pipe: you then join one or more topics, and until you do, a healthy connection sits in complete silence. Most first integrations that look broken are a socket that connected and never joined.
One socket multiplexes every channel. You do not open a connection per feed. One socket carries your orders, your fills, your positions and the book for every market you trade, each as a separate topic on the same wire. Do not open a socket per topic: the keep-alive is per socket, so eight connections means eight heartbeats for no benefit.
Frames are arrays, not objects.
[join_ref, ref, topic, event, payload]There is a protocol layer here (joins, replies, refs, heartbeats), not just your data. A Phoenix client library handles it for you, and reading the frame format below is how you debug it when something is wrong.
Sign the socket, then join what you need
Section titled “Sign the socket, then join what you need”Sign every connection, whichever channels you plan to join. The handshake
takes the same X-STX-ACCESS-* headers as REST (see
Authentication), with one difference: the socket signs
GET and the path without its query string, so
timestamp + "GET" + "/socket/websocket" even though you connect to
/socket/websocket?vsn=2.0.0.
Send a User-Agent header on the handshake as well. A handshake without one is
refused with 403, signed or not. Browsers always send one; Node’s ws and some
other WebSocket clients do not unless you set it.
The market data channels accept an unsigned socket; the account channels need a
signed one, and refuse a join on an unsigned socket with
{"reason":"unauthorized"}. Signing anyway costs nothing: one signed connection
carries both the market feeds and your account channels, and an unsigned socket has
to be thrown away and replaced the moment you want your own fills.
Account channels
Section titled “Account channels”Topics suffixed with your user id, so you only ever receive your own data. Get
that id from GET /api/v1/me; it is a UUID. Joining a topic whose id is not
yours fails with {"reason":"unauthorized"}.
| Channel | Topic | Snapshot on join | Pushes |
|---|---|---|---|
| Orders | orders:{user_id} |
all_orders |
new_open_order: order accepted or filled. |
| Fills | fills:{user_id} |
all_trades |
trade, one per execution. |
| Positions | positions:{user_id} |
all_positions |
updated_positions: position opened, changed or closed. |
| Settlements | settlements:{user_id} |
none | new_settlements: a market you hold settles. |
| Balances | balances:{user_id} |
balances |
update and payment_update. |
| Account | account:{user_id} |
all four of the above | everything the five above push. |
| User info | user_info:{user_id} |
see page | user_updated: profile, limits and account state. |
| Order slip | order_slip:{user_id} (also betslip:{user_id}, deprecated) |
none | order_numbers_batch: projected cost of orders you registered, as the book moves. self_match_batch: one of them became placeable, or stopped being. |
The first five take an optional market_ids filter;
balances takes an optional account_id instead.
Market data channels
Section titled “Market data channels”Not scoped to your account: the same data every participant sees.
| Channel | Topic | Pushes |
|---|---|---|
| Order book, ticker and trades | orderbook, ticker, trades |
The aggregated book, per-market price summaries, and executed trades. The feeds to trade from: one join covers every market you name. |
| Markets | markets |
market_created and market_updated, for every market. market_updated is a delta: it carries the market’s id, a timestamp and just the fields that changed. |
| Events | events |
event: traded volume per event, across every market on it. The whole current value, not a delta. Join carries each event’s value so a client paints before the next trade. |
| Market stats | market_stats |
market_stats and market_stats_snapshot, the price series for markets you name. The feed behind a price chart: history on join, then changed buckets. |
| Market updates | market_updates |
created and updated, for markets you watch; nothing is pushed until you do. A bandwidth-conscious alternative to markets. |
Frame format
Section titled “Frame format”Phoenix channels use a five-element JSON array on the wire:
[join_ref, ref, topic, event, payload]| Element | What it is |
|---|---|
join_ref |
The ref you used when you joined this topic. Reuse it for every later message to that topic: a frame carrying the wrong join_ref is dropped without a reply, which looks like the server ignoring you. |
ref |
A per-message id. The reply echoes it, so use it to match replies to requests. |
topic |
e.g. orders:<user_id>, orderbook, markets |
event |
phx_join, heartbeat, ping, select_market_ids, or a server event name |
payload |
A JSON object. Use {}, not "", when there is nothing to send. |
If a channel message or a join appears to be ignored, check the join_ref before
anything else; it is the most common cause.
What the socket does not promise
Section titled “What the socket does not promise”Pushes are batched. Market data is coalesced: the order book publishes on roughly a 200 ms cadence and market updates about every 2 seconds, so a channel is a stream of current state, not a tick-by-tick tape.
Channels are not ordered relative to each other. orders and fills are
separate processes: a fill can arrive before the order update that explains it,
and several fills can arrive for one order update. Key off ids and reconcile,
rather than assuming arrival order means anything.
A cancel does not stop a fill already in flight. A fill can land after you send a cancel, because someone took the liquidity before the cancel reached the book. Treat a cancel as a request, and the resulting fill or status as the answer.
Balance pushes are event-driven, not price-driven. balances fires when
you deposit or withdraw, place or cancel, get filled, or a market settles. It
does not fire when prices move, so marking your positions to market is your
job, from the order book feed.
Keep the connection alive
Section titled “Keep the connection alive”The server closes a socket that has been silent for 60 seconds. Send a
heartbeat on the phoenix topic well inside that window. Every 15 to 30
seconds is comfortable:
["3","3","phoenix","heartbeat",{}]The heartbeat is per socket, not per channel: one heartbeat keeps every channel on that connection alive. Inbound server pushes do not reset the timer: a market that is quiet will not keep your socket up, so heartbeat on a timer rather than only when idle.
Use a Phoenix client rather than raw frames
Section titled “Use a Phoenix client rather than raw frames”The frame format above is documented so you can implement it anywhere, but the
Phoenix client libraries already handle
join_ref bookkeeping, socket heartbeats, and rejoining topics after a
reconnect. There are implementations for JavaScript, Python, Java and C#.
What they do not do for you: sign the handshake, send any channel-level pings a feature asks for, or reconcile a book after a gap. Those are yours either way.
After a reconnect
Section titled “After a reconnect”Rejoin your topics and pull fresh state rather than assuming yours survived:
- Rejoin every topic (a Phoenix client does this for you)
- Replace the book you hold from the next
orderbookpush rather than merging into it - Take a REST snapshot of your live orders
(
GET /api/v1/orders?status=created,requested,accepted,delayed,open) and positions (GET /api/v1/positions) and reconcile
Step 2 matters because orderbook carries no sequence number, so a client cannot
tell a gap from a quiet market. Every push is a full snapshot, so replacing is both
safe and cheap.

