WebSocket
Connect to the 4rho WebSocket for real-time market data and user notifications.
Connection
wss://api.4rho.com/ws
Authentication
Market channels need a key with stream:market; the private user channel needs stream:user (see Channel scopes). Authenticate via one of:
Option 1 (recommended for browsers): streaming ticket + first-message auth
- From a logged-in browser cookie session, call
POST /v1/auth/ws-ticketto mint a single-usewst_…streaming ticket (valid for 60 seconds). API keys cannot mint tickets; bots use Option 2 so their key identity and scopes remain attached. - Open the socket, then send the ticket as the first message:
{ "type": "auth", "token": "wst_…" }
Raw session JWTs are rejected. The ticket is consumed atomically and can never be replayed. (An Authorization: Bearer wst_… upgrade header works too, but browsers can't set it - hence the first-message path.)
Option 2 (direct-to-origin only): HMAC API key headers on the upgrade
When connecting directly to the origin (not through the production edge - the edge strips these headers on WebSocket upgrade, so this does not work against wss://api.4rho.com/ws in production), you can include the standard HMAC headers on the upgrade request. The signature is computed over GET, the request path /ws, and an empty body, exactly like any other HMAC request (see Authentication):
X-4RHO-API-KEYX-4RHO-SIGNATUREX-4RHO-TIMESTAMPX-4RHO-PASSPHRASEX-4RHO-NONCE
Message Format
All messages are JSON with this envelope:
{
"channel": "market",
"type": "trade",
"market_id": "...",
"data": { ... },
"seq": 12345,
"ts": 1709136000000
}
| Field | Type | Description |
|---|---|---|
channel | string | Message category |
type | string | Specific message type |
market_id | string | Market ID (market channel only) |
data | object | Message payload |
seq | int | Sequence number |
ts | int | Timestamp (Unix ms) |
Subscribing
Send a subscribe message after connecting:
{
"channel": "subscribe",
"data": {
"markets": ["market-id-1", "market-id-2"],
"user": true
}
}
markets binds you to those markets' public feeds; user: true binds you to your private feed. Both can appear in one message.
Channel scopes
| Request field | Delivers | Scope required (API key) |
|---|---|---|
markets: [...] | Market channel: trade, book_delta, bbo per market | stream:market |
user: true | User channel: your fill + order_status events | stream:user (and an authenticated connection) |
A subscribe for a channel your key lacks the scope for is rejected with an INSUFFICIENT_SCOPE error frame; the rest of the subscribe still applies. Under the hood these map to the server's internal trade.> / orderbook.> / bbo.> (market) and order.fill.> / order.status.> (user) event subjects - you never address those directly; the subscribe envelope above is the only client surface.
Confirmation
{
"channel": "system",
"type": "subscribed",
"data": {
"channel": "market",
"market_id": "market-id-1"
}
}
Unsubscribing
{
"channel": "unsubscribe",
"data": {
"markets": ["market-id-1"],
"user": false
}
}
Market Channel Messages
Trade
Emitted when a trade executes in a subscribed market.
{
"channel": "market",
"type": "trade",
"market_id": "...",
"data": {
"market_id": "...",
"trade_id": "...",
"price": "0.50",
"amount": "1000000",
"side": "BUY",
"outcome_index": 0,
"timestamp": 1709136000000
}
}
Book Delta
Emitted for individual order changes (placed, cancelled, or filled). This is a mutation hint, not an aggregated price-level replacement. A different order can remain at the same price after a removal, and an opposite-outcome BUY can change the synthetic ask on this outcome.
{
"channel": "market",
"type": "book_delta",
"market_id": "...",
"data": {
"market_id": "...",
"action": "add",
"side": "buy",
"price": "0.49",
"quantity": "2000000",
"outcome_index": 0
}
}
| Action | Description |
|---|---|
add | An individual order was added |
remove | An individual order was removed; other orders may remain at that price |
update | An individual order changed; quantity may be absent/empty |
The legacy quantity is maker-denominated (BUY USDC, SELL outcome tokens),
not canonical dollar depth. Coalesce hints and refresh
GET /v1/orders/book/:market_id?both=true; render the explicit
notional_usdc_micro / token_quantity_atomic fields described in
Orders. Never replace a whole level from this payload or infer FIFO
priority. Market frames may be dropped, so retain periodic recovery reads.
BBO
Emitted when the best bid or offer changes.
{
"channel": "market",
"type": "bbo",
"market_id": "...",
"data": {
"market_id": "...",
"best_bid": "0.49",
"best_ask": "0.51",
"bid_size": "5000000",
"ask_size": "3000000",
"outcome_index": 0
}
}
User Channel Messages
Requires authentication. Subscribe with "user": true.
Fill
Emitted when your order is filled (you are either maker or taker).
{
"channel": "user",
"type": "fill",
"data": {
"trade_id": "...",
"order_id": "...",
"order_hash": "0x...",
"market_id": "...",
"side": "BUY",
"role": "maker",
"price": "0.50",
"amount": "1000000",
"notional_usdc_micro": "1000000",
"token_quantity_atomic": "2000000",
"outcome_index": 0,
"timestamp": 1709136000000
}
}
notional_usdc_micro and token_quantity_atomic are a pair of canonical
positive decimal strings: your party's gross match-time USDC posting and
gross matched tokens, before fees. Both use 6-decimal base units. They are
not settlement confirmation or a balance update; settlement may still fail.
Missing or malformed evidence omits both fields.
The legacy amount stays maker-denominated (BUY USDC, SELL tokens), so do not
label it as dollars. A complementary BUY maker has its own USDC contribution
and outcome; legacy price is not its own price. Use the canonical amount
pair for that party's match-price display. With allowed self-trading the same
account receives both maker and taker events under one trade_id; dedupe
party notifications by trade_id plus role, not trade ID alone.
Order Status
Emitted when your order's status changes.
{
"channel": "user",
"type": "order_status",
"data": {
"order_id": "...",
"order_hash": "0x...",
"market_id": "...",
"status": "cancelled",
"side": "BUY",
"price": "0.50",
"remaining": "0",
"filled_amount": "500000",
"reason": "user_cancelled",
"timestamp": 1709136000000
}
}
Ping/Pong
Send pings to keep the connection alive:
{ "channel": "ping" }
Response:
{
"channel": "system",
"type": "pong"
}
The server also sends WebSocket-level pings every 45 seconds. Connections that don't respond with a pong within 60 seconds are closed.
Connection Limits
- Maximum message size: 4 KB
- Send buffer: 256 messages
- Slow consumers will have messages dropped (non-blocking delivery)