# User data WebSocket — `/v1/ws`

Topic-based stream of account state, order lifecycle, and fills. One
connection can hold several subscriptions.

Production URL: `wss://production.mosaiq.0d.finance/v1/ws`.

The socket is not authenticated. Identify the account with
`?user=<evm_address>` or `user` in the subscription. If omitted, the server
uses its configured default user.

```ts
const url = new URL('/v1/ws', 'https://production.mosaiq.0d.finance')
url.protocol = 'wss:'
url.searchParams.set('user', '0x0000000000000000000000000000000000000001')
const ws = new WebSocket(url)
```

## Rate limits

Shared per client IP across `/v1/ws`, `/v1/market-data/ws`, and
`/v1/exchange/ws`:

| Limit | Value | Behavior |
| --- | --- | --- |
| Concurrent connections | `10` | Extra upgrades are rejected with HTTP `429`. |
| New connections | `30`/minute | Extra upgrades are rejected with HTTP `429`. |
| Incoming messages | `2000`/minute | Read sockets drop extra frames silently. The write socket closes. |

Budgets refill continuously. Prefer one connection with several subscriptions
and back off after a `429`. An over-budget Pong still answers the heartbeat.
A message that cannot be written to the client within 2 seconds closes the
connection.

## Heartbeats

Mosaiq sends a WebSocket Ping every 10 seconds on all three sockets and closes
the connection when no Pong with the matching payload arrives within 30
seconds. Most libraries answer automatically; keep reading the socket so they
can. Client Pings and application messages do not count as a Pong.

## Subscribe

```jsonc
{ "method": "subscribe", "subscription": { "type": "clearinghouseState", "user": "0x..." } }
{ "method": "subscribe", "subscription": { "type": "orderUpdates", "user": "0x..." } }
{ "method": "subscribe", "subscription": { "type": "userFills", "user": "0x..." } }
```

There is no `unsubscribe`. Re-subscribing to an active topic replaces its
parameters without touching other topics; `orderUpdates` and `userFills` then
send a fresh snapshot. Market-data topics are rejected here;
use [`/v1/market-data/ws`](/api/market-data-websocket). Venue maintenance is
also rejected here; subscribe to
[`maintenance` on `/v1/exchange/ws`](/api/exchange-websocket#maintenance-announcements).
An internal failure on a topic sends a `websocket_error` message and closes
the connection.

## Envelope

```ts
interface WebSocketMessage<T> {
  status: 'ok'
  topic: 'clearinghouseState' | 'orderUpdates' | 'userFills'
  data: T
  server_time_ms: number
}

interface WebSocketErrorMessage {
  status: 'error'
  data: { code: 'websocket_error'; msg: string }
  server_time_ms: number
}
```

Route by `topic`. `server_time_ms` is when Mosaiq produced the message; it is
not a cursor. Topics are independent: there is no shared cursor, atomic
snapshot, or causal order between them. Within an `orderUpdates` or `userFills`
subscription, live messages follow the order in which Mosaiq applied its
in-memory projections. A reconnect starts a new subscription with a new
snapshot and does not replay what was missed.

## `clearinghouseState`

One subscription emits four message types, distinguished by `data.type`. On
subscribe or reconnect, Mosaiq sends the latest `assetData`, `venueData`,
`marketData`, then `userData`, each with `data.messageType: "snapshot"`. The
four domains refresh independently, so these initial messages are not an
atomic snapshot.

After that, a chunk is re-sent only when its contents change, with
`data.messageType: "update"`. A message replaces the whole chunk identified by
`data.type`, including empty arrays and maps; it is not a field-level patch. A
slow connection may skip intermediate values but always receives the latest
one. An unchanged chunk stays silent indefinitely: these messages are not
heartbeats.

Each chunk is refreshed on its own timer. These are refresh cadences, not
minimum gaps between messages, and operators can change them.

| Chunk | Default refresh | Setting |
| --- | --- | --- |
| `assetData` | 1 s, from the catalog cache (`MARKET_CATALOG_CACHE_TTL_SECS`) | fixed |
| `venueData` | 100 ms; venue health itself polls every `VENUE_STATUS_POLL_INTERVAL_SECS` | `WS_UNIVERSE_PUSH_INTERVAL_MS` |
| `marketData` | 100 ms | `WS_UNIVERSE_PUSH_INTERVAL_MS` |
| `userData` | 100 ms | `WS_PUSH_INTERVAL_MS` |

The optional `markets` field selects canonical asset names across all venues.
It filters `marketCtxs` and `openInterestUsdc` from the first message onward;
asset definitions, venue metadata, positions, margin, and caps stay complete.
Changes outside the selection produce no message.

```json
{
  "method": "subscribe",
  "subscription": {
    "type": "clearinghouseState",
    "markets": ["WTI", "XAG"]
  }
}
```

Omitting `markets` selects all markets; an empty list selects none. Names are
trimmed, uppercased, and deduplicated; use the canonical names from
`assetData.universe[].name`. Unknown names match nothing until they appear in
the catalog, and empty names are rejected. Subscribing again with a different
list replaces the selection and resends the four snapshots; the same list does
not restart the subscription.

This protocol replaces `universeData` and is not backward compatible: asset
definitions moved to `assetData`, venue status to `venueData`, and prices,
funding, and open interest to `marketData`. The REST endpoint keeps its combined
response under `data.universeData` and `data.userData`, including
`data.universeData.universe[].openInterestUsdc`.

```ts
type ClearinghouseStateData = AssetData | VenueData | MarketData | UserData

type SnapshotMessage = ClearinghouseStateData & { messageType: 'snapshot' }
type UpdateMessage = ClearinghouseStateData & { messageType: 'update' }

interface AssetData {
  type: 'assetData'
  universe: Array<AssetDefinition>
}

interface VenueData {
  type: 'venueData'
  meta: ClearinghouseMeta
}

interface MarketData {
  type: 'marketData'
  marketCtxs: Array<MarketContext>
  openInterestUsdc: Record<string, string | null>
}

interface UserData {
  type: 'userData'
  user: string
  positions: Array<Position>
  marginSummary: MarginSummary
  userCaps: UserCaps
}

interface MarginSummary {
  accountValue: number
  totalCashUsdc: number
  availableFunds: number
  excessLiquidity: number
  initialMarginUsed: number
  maintenanceMarginUsed: number
  grossNotional: number
  cushion: number
  leverage: number
}

interface UserCaps {
  tier: string
  openOrders: number
  maxOpenOrders: number
  maxSingleOrderNotionalUsdc: number
  openOrderNotionalUsdc: number
  maxOpenOrderNotionalUsdc: number
  grossNotionalUsdc: number
  maxGrossNotionalUsdc: number
  initialMarginUsageRate: number
  maxInitialMarginUsageRate: number
  maxAssetVenueNotionalUsdc: number
  assetVenueNotionals: Array<{ asset: string; venue: string; notionalUsdc: number }>
}

interface Position {
  coin: string
  venue: string
  entryPx: number
  szi: number
  positionValue: number
  unrealizedPnl: number
  unrealizedFunding: number
}

interface AssetDefinition {
  name: string
  szDecimals: number
  maxLeverage: number
  marginTableId: number
  onlyIsolated?: boolean
  isDelisted?: boolean
  constraints?: MarketConstraints
  venues?: Array<UniverseVenue>
}

interface UniverseVenue {
  venue: string
  venueSymbol: string
  instrumentType: 'perp'
  status: 'active' | 'post_only' | 'reduce_only' | 'prelisted' | 'delisted' | 'inactive'
  constraints: MarketConstraints
}

interface MarketContext {
  name: string
  venue: string
  funding?: string
  oraclePx?: string
}

interface MarketConstraints {
  sizeDecimals: number
  sizeIncrement?: string
  priceDecimals?: number
  tickSize?: string
  minBaseAmount?: string
  minNotional?: string
  maxBaseAmount?: string
  maxMarketNotional?: string
  maxLimitNotional?: string
  maxPositionNotional?: string
  openInterestCap?: string
  maxOpenOrders?: number
  supportsReduceOnly?: boolean
  supportsPostOnly?: boolean
  requiresIsolatedMargin?: boolean
}

interface ClearinghouseMeta {
  venues: Array<{ venue: string; status: 'unknown' | 'online' | 'offline'; lastCheckedAtMs?: number; lastOkAtMs?: number; message?: string }>
  recovery?: Array<{ venue: string; phase: 'recovering' | 'active' | 'halted'; message?: string }>
}
```

Field semantics are documented on
[`GET /v1/clearinghouseState`](/api/clearinghouse-state). `positions` are
venue-scoped: `(coin, venue)` identifies one leg.

## `orderUpdates`

Order lifecycle projections for the user. The first message after subscribe is
a `snapshot` of at most 20 rows, ordered by `updatedAtMs` ascending. Each later `update` carries
one projection. A later projection for an `orderId` supersedes the earlier one;
while the snapshot loads, queued projections for one order may be coalesced.

```ts
interface OrderUpdates {
  user: string
  type: 'snapshot' | 'update'
  orderUpdates: Array<OrderUpdate>
}

interface OrderUpdate {
  orderId: string
  cloid: string
  a: string
  venue: string
  b: 'buy' | 'sell'
  status:
    | 'accepted'
    | 'open'
    | 'partially_filled'
    | 'filled'
    | 'cancelled'
    | 'failed'
    | 'not_on_book'
    | 'uncertain'
  size: string
  price?: string
  slippage?: string
  filledSize: string
  avgFillPrice?: string
  reconciliationPending: boolean
  failure?: { code: OrderFailureCode; reason: string; rawCode?: string }
  createdAtMs: number
  updatedAtMs: number
}

type OrderFailureCode =
  | 'rate_limited'
  | 'transport_unavailable'
  | 'venue_unavailable'
  | 'duplicate_client_order_id'
  | 'invalid_order'
  | 'insufficient_margin_or_balance'
  | 'market_unavailable'
  | 'no_liquidity'
  | 'account_restricted'
  | 'preemptive_cancel'
  | 'internal_invariant'
  | 'other'
```

* `open` means venue evidence shows the order resting. `failed` is a definitive
  unsuccessful state; `failure` carries the reason. `not_on_book` means the
  venue answered that the order is no longer resting: stop sending cancels for
  it. `uncertain` means Mosaiq cannot assert the state yet; wait or reconcile
  through `openOrders` and `userFills`. A REST cancel ack does not become
  `cancelled` until the venue confirms.
* `size` is the current effective total size. A confirmed modify replaces it.
  Mosaiq can also lower it without a modify when a reduce-only order is trimmed
  by a position decrease or a newer reduce-only order; a trim is reverted only
  when the placement that caused it is rejected before venue send.
* `price` is present on limit orders and `slippage` on market orders.
* `filledSize` and `avgFillPrice` are cumulative at that projection. The
  stream does not promise a row for every intermediate projection.
* `reconciliationPending` is `true` while Mosaiq is still confirming the row
  against venue evidence.

## `userFills`

Confirmed venue fills. The first message is a `snapshot` of at most 20 rows,
ordered by `createdAtMs` ascending; later `update` messages carry one fill each. Nothing is
replayed after a disconnect: backfill through
[`GET /v1/userFills`](/api/user-fills) and deduplicate by `(venue, fillId)`.

```ts
interface UserFills {
  user: string
  type: 'snapshot' | 'update'
  userFills: Array<UserFill>
}

interface UserFill {
  fillId: string
  orderId: string
  cloid: string
  a: string
  venue: string
  b: 'buy' | 'sell'
  size: string
  price: string
  liquidityRole: 'maker' | 'taker'
  fees?: { exchange: string; mosaiq: string }
  createdAtMs: number
}
```

`fees.exchange` and `fees.mosaiq` are the USDC amounts charged for that fill
and can be negative when the maker rate is a rebate. In a `snapshot`, a fill
without a settlement row carries fees estimated from the published rates for
its venue market; `fees` is absent only when no rate is configured for that
market. In an `update`, `fees` is absent when the settlement row is not yet
available. Treat `venue` as an opaque string.
