Skip to content

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

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.

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

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.

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.

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.

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.

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.

Rejoin your topics and pull fresh state rather than assuming yours survived:

  1. Rejoin every topic (a Phoenix client does this for you)
  2. Replace the book you hold from the next orderbook push rather than merging into it
  3. 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.

v1.5.9Changelogllms.txtllms-full.txt