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

  1. From a logged-in browser cookie session, call POST /v1/auth/ws-ticket to mint a single-use wst_… streaming ticket (valid for 60 seconds). API keys cannot mint tickets; bots use Option 2 so their key identity and scopes remain attached.
  2. 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-KEY
  • X-4RHO-SIGNATURE
  • X-4RHO-TIMESTAMP
  • X-4RHO-PASSPHRASE
  • X-4RHO-NONCE

Message Format

All messages are JSON with this envelope:

{
  "channel": "market",
  "type": "trade",
  "market_id": "...",
  "data": { ... },
  "seq": 12345,
  "ts": 1709136000000
}
FieldTypeDescription
channelstringMessage category
typestringSpecific message type
market_idstringMarket ID (market channel only)
dataobjectMessage payload
seqintSequence number
tsintTimestamp (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 fieldDeliversScope required (API key)
markets: [...]Market channel: trade, book_delta, bbo per marketstream:market
user: trueUser channel: your fill + order_status eventsstream: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
  }
}
ActionDescription
addAn individual order was added
removeAn individual order was removed; other orders may remain at that price
updateAn 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)