# WebSocket channels


Source: https://docs.stxapp.io/websockets/

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`](/websockets/channels/market-data/#orderbook) |
| Price history for a chart | [`market_stats`](/websockets/channels/market-stats/) |
| Prices and trades across markets | [`ticker`, `trades`](/websockets/channels/market-data/) |
| Traded volume for an event | [`events`](/websockets/channels/events/) |
| Your orders as they are accepted and filled | [`orders:{user_id}`](/websockets/channels/orders/) |
| Your fills | [`fills:{user_id}`](/websockets/channels/fills/) |
| Your positions and settlements | [`positions:{user_id}`](/websockets/channels/positions/), [`settlements:{user_id}`](/websockets/channels/settlements/) |
| Your balance and exposure | [`balances:{user_id}`](/websockets/channels/balances/) |
| All of your account on one topic | [`account:{user_id}`](/websockets/channels/account/) |
| What an order would cost before you place it | [`order_slip:{user_id}`](/websockets/channels/order-slip/) |
| Markets appearing, changing status, or moving | [`markets`](/websockets/channels/markets/), [`market_updates`](/websockets/channels/market-updates/) |

:::tip[Money is a string]
The account channels and the three market-data feeds write money as dollar
strings (`"39.0000"`) and contract counts as quantity strings (`"100.00"`), the same
as the REST API; see [Wire format](/websockets/channels/wire-format/). The two
market-metadata feeds, `markets` and `market_updates`, are the exception: their prices
are JSON numbers in cents, as each page states field by field.

[`betslip:`](/websockets/channels/order-slip/), the topic `order_slip:` supersedes,
is a different exception: its money **is** in dollars, but as a string rounded to two
decimal places (`"145.00"`, not `"145.0000"`), and its quantities are JSON numbers. It
is not in cents.
:::

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

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

```json
[join_ref, ref, topic, event, payload]
```

There is a protocol layer here (joins, replies, refs, heartbeats), not just
your data. A [Phoenix client library](#use-a-phoenix-client-rather-than-raw-frames)
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

**Sign every connection**, whichever channels you plan to join. The handshake
takes the same `X-STX-ACCESS-*` headers as REST (see
[Authentication](/api/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

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](/websockets/channels/orders/) | `orders:{user_id}` | `all_orders` | `new_open_order`: order accepted or filled. |
| [Fills](/websockets/channels/fills/) | `fills:{user_id}` | `all_trades` | `trade`, one per execution. |
| [Positions](/websockets/channels/positions/) | `positions:{user_id}` | `all_positions` | `updated_positions`: position opened, changed or closed. |
| [Settlements](/websockets/channels/settlements/) | `settlements:{user_id}` | *none* | `new_settlements`: a market you hold settles. |
| [Balances](/websockets/channels/balances/) | `balances:{user_id}` | `balances` | `update` and `payment_update`. |
| [Account](/websockets/channels/account/) | `account:{user_id}` | all four of the above | everything the five above push. |
| [User info](/websockets/channels/user-info/) | `user_info:{user_id}` | *see page* | `user_updated`: profile, limits and account state. |
| [Order slip](/websockets/channels/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](/websockets/channels/wire-format/#filtering);
`balances` takes an optional `account_id` instead.

## Market data channels

Not scoped to your account: the same data every participant sees.

| Channel | Topic | Pushes |
| --- | --- | --- |
| [Order book, ticker and trades](/websockets/channels/market-data/) | `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](/websockets/channels/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](/websockets/channels/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](/websockets/channels/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](/websockets/channels/market-updates/) | `market_updates` | `created` and `updated`, for markets you [`watch`](/websockets/channels/market-updates/#watching-markets); nothing is pushed until you do. A bandwidth-conscious alternative to `markets`. |

:::tip[One call, several channels]
A single `POST /api/v1/orders` normally produces four separate pushes, in order:
`orders` → `fills` → `positions` → `balances`. Subscribe to all four if you are
reconciling state, not just watching orders, or join
[`account`](/websockets/channels/account/) once and get all of them.
:::

## Frame format

Phoenix channels use a five-element JSON array on the wire:

```json
[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

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

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:

```json
["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.

:::caution[Timers]
There are **two independent timers**, and the socket heartbeat only resets one
of them.

| | Resets it | Deadline | Consequence of missing it |
| --- | --- | --- | --- |
| Socket keep-alive | `heartbeat` on the `phoenix` topic | 60s | The connection closes |
| `cancel_on_disconnect` | `ping` on the `orders` topic | **5–20s**, whatever you asked for at join | **Your flagged orders are cancelled** |

A 30-second heartbeat keeps the socket up and still misses the `ping` deadline,
so your book is cancelled on a connection that never dropped. When
`cancel_on_disconnect` is on, send the channel `ping` inside your configured
`ping_timeout`, and send it on a timer, not in response to traffic.

See [Risk controls](/risk-controls/) for the join payload and the grace period.
:::

## 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](https://hexdocs.pm/phoenix/js/) 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

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.
