# 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. Each connection also closes if pending outgoing payloads exceed
4 MiB or a message remains pending for 2 seconds, including time waiting in
the queue. These limits include queued messages and the message being sent.
On `/v1/ws`, incoming messages and frames are limited to 1 MiB. Oversized input
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 unrecoverable topic failure closes the connection and may send a
`websocket_error` message first.

## 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 guaranteed ordering between them. `orderUpdates` follows the order
in which Mosaiq processes changes. `userFills` can report an execution before
its account state changes when early notifications are enabled. Fills can
arrive out of execution-time order. 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.

By default, Mosaiq checks for changes to `venueData`, `marketData`, and
`userData` every 100 ms, and `assetData` every second. These are not guaranteed
delivery intervals. The underlying data may refresh less often, and there is
no minimum gap between messages. In `marketData`, oracle prices and predicted
funding rates are read live on each check; the market list and fallback
settlement prices refresh about once a second.

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 are accepted and begin matching when
they appear in `marketData`. Because the chunks refresh independently,
`marketData` can mention a newly added asset before `assetData` publishes its
definition. 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.

REST returns the same four payloads together under `data` and omits `type`
and `messageType`.

```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. `entryPx` is rounded to the
venue's price decimals for display only; `positionValue` and `unrealizedPnl` are
computed from the unrounded average entry price and need not match it exactly.
`marketCtxs[*].funding` is the venue's current predicted hourly funding rate,
not a settled rate. It is absent when Mosaiq had no fresh rate when it built
the `marketData` chunk. A Hyperliquid rate stays fresh for 60 seconds after its
last update. A Lighter rate stays fresh while the Lighter feed of Mosaiq is
connected, because Lighter sends a market only when its stats change. The rate
expires 60 seconds after that feed disconnects, or 60 seconds after Lighter
sends the market without a rate. A message can reach you after that check, so
the rate it carries can be slightly older.

## `orderUpdates`

Order status changes for the user. The first message after subscribe is a
`snapshot` of at most 20 rows, ordered by `updatedAtMs` ascending. A change
made after you subscribe arrives as an `update`, also when the snapshot already
shows it. Each later `update` carries one order's current state. Replace the
earlier row for that `orderId`; intermediate changes may be skipped while the
snapshot loads.

```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
    retryAfterMs?: number
  }
  createdAtMs: number
  updatedAtMs: number
}

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

* This stream carries every outcome of a place, modify, or cancel. The write's
  answer only says that Mosaiq launched the venue send; see
  [Follow each write on orderUpdates](/api/exchange#follow-each-write-on-orderupdates).
* `accepted` means Mosaiq has the order and its venue send is under way or
  acknowledged. It does not mean the order rests. `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`. An order whose venue gives no answer within 2 seconds of its send
  becomes `uncertain` with `failure.code` `transport_unavailable`.
* A cancel has no row of its own. Its order's rows tell how the order ended:
  `cancelled`, `filled`, or `failed`. The order becomes `cancelled` only once
  the venue confirms, and no cancel outcome shows as `not_on_book`.
* A modify's outcome shows on its order's row, with the new price and size, or
  with the previous price and size and the modify's `failure`.
* A step that changes no field of the order's row other than `updatedAtMs`
  sends no `update`, for example the venue's acknowledgement of an order that
  is already `accepted`. A modify that ends on an order that did not end gets
  an `update` even when it leaves the row as it was, for example a second
  refusal like the first. An order that stays live while 1,024 newer orders
  arrive on the same subscription can get one `update` that repeats its last
  row.
* A refusal that Hyperliquid documents is one `failed` row, whichever of
  Hyperliquid's two reports of it arrives first. Its `reason` and `rawCode` are
  the Hyperliquid status name, for example `badAloPxRejected`.
* A recovery that finds a fill late sends another `update` for the same
  `orderId`, even after a terminal status, for example `cancelled` with a larger
  `filledSize`.
* After an internal error, Mosaiq can rebuild its order state from the
  database. The stream cannot carry those changes, so Mosaiq closes the
  connection. Reconnect and subscribe again: the new snapshot has the current
  rows.
* `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. On Lighter,
  it also falls without a fill when another of your orders crosses the order:
  Lighter's self-trade prevention removes the crossed size from both orders.
* `recovery_fenced` means recovery confirmed that the order was never sent
  to the venue.
* `venue_busy` means Mosaiq's send capacity for the venue was full, so the
  order was never sent. It is not your rate limit. Retry after a short delay.
* A placement that your own cancel stopped before it reached the order book is
  `cancelled` with no `failure`. It never rested.
* `broker_capacity` means the venue refused or cancelled the order because the
  Mosaiq pooled account on that venue did not have enough margin. Your own
  margin is not the cause. The `reason` is
  `broker capacity on <venue> is temporarily unavailable; retry shortly`, and
  `rawCode` keeps the venue code. `insufficient_margin_or_balance` remains for
  balance refusals that are not caused by the pooled account. An `update` for
  that refusal carries `retryAfterMs` when Mosaiq knows when margin will reach
  the account: wait that many milliseconds, then retry. It is absent when
  Mosaiq does not know, and a `snapshot` never carries it. An order that
  carried `priorityFeeBps` and that Hyperliquid refused because the Mosaiq HYPE
  stake cannot pay the fee also shows `broker_capacity`, with the reason
  `priority fee capacity on <venue> is temporarily unavailable; retry shortly,
  or send the order without priorityFeeBps` and never `retryAfterMs`.
* `price` is present on limit orders and `slippage` on market orders.
* `filledSize` and `avgFillPrice` are cumulative as of that update. The
  stream does not promise a message for every intermediate change.
* A fill that the venue timed before the order's last change keeps the
  `updatedAtMs` of that change, so a `filled` row is never older than the row
  before it.
* `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). Within the subscribed user, upsert each
row by `(venue, fillId)`. A later update can add fees to an earlier execution;
counting both messages as trades double-counts the fill.

```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; priority?: 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. `fees.priority` is the
Hyperliquid priority fee paid on that fill, in USDC, for an order sent with
`priorityFeeBps`; it is absent when the fill paid none. Missing fees mean unknown,
not zero. A later update with the same `(venue, fillId)` supplies settled fees.
Treat `venue` as an opaque string.

Early notifications are disabled by default. When enabled, a fill can arrive
before Mosaiq updates the account or adds it to REST history. Its `size`,
`price`, order identity, and execution time describe the exchange execution;
`fees` is absent. Reduce-only fills wait until Mosaiq confirms the quantity
allocated to your account. Other fills may also wait for settlement, including
an initial IOC fill whose order has not yet been identified.

An early fill does not mean that balances, positions, or funds reserved for
orders have changed. A follow-up order can still see older account state and
be rejected. Check account updates before relying on the new balance or position;
there is no ordering guarantee between topics.

Snapshots can contain both early and settled fills. The settled version wins
when both exist. If the snapshot includes an early fill, a later update can
supply its settled fees. Historical fills whose actual fees are unavailable
may instead show estimated fees from the published venue-market rates.

A later update can correct an early fill's execution details. Replace the row
with the same `(venue, fillId)`. If Mosaiq detects conflicting fill identities,
it closes affected subscriptions and disables early notifications. Reconnect
and reload account state and [fill history](/api/user-fills). The new snapshot
may remain unavailable while the conflict persists.

Reconnects and delayed recovery can cause duplicate notifications or temporary
gaps. Always deduplicate fills and backfill missed history. A settled version
already received by a subscription is never replaced by an early version
without fees. Updates cannot undo trading decisions your client has already made.
