# Write WebSocket — `/v1/exchange/ws`

The authenticated low-latency execution transport supports order requests
(`place`, `modify`, or `cancel`) and [maintenance subscriptions](#maintenance-announcements).
Order frames carry one `POST /v1/exchange` request; subscription frames use
`method` and `subscription`.

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

```http
Authorization: Bearer <your-api-key>
```

Heartbeats and per-IP connection limits are shared with the
[user-data WebSocket](/api/websocket#heartbeats). For socket layout,
pipelining, and recovery, see [Optimizing latency](/api/optimizing-latency).

## Cancel on disconnect

```text
wss://production.mosaiq.0d.finance/v1/exchange/ws?cancelAllOnDisconnect=true&reconnectGraceMs=2000
```

| Query parameter | Default | Description |
| --- | --- | --- |
| `cancelAllOnDisconnect` | `false` | Cancel the user's open orders after the last opted-in write connection disconnects. |
| `reconnectGraceMs` | `2000` | Delay before cancelling so an opted-in reconnect can suppress it. Requires `cancelAllOnDisconnect=true`; maximum `30000`. |

Only opted-in connections participate. With several opted-in connections,
cancellation starts after the last one drops and its in-flight frames have
drained. A new opted-in connection inside the grace window clears the pending
trigger. A heartbeat timeout counts as a disconnect. Unknown query parameters
fail the upgrade with HTTP `400 invalid_query`; invalid values with HTTP
`400 bad_request`.

During graceful gateway shutdown, Mosaiq closes the opted-in connections and
drains their in-flight frames. It then starts one mass cancel per user after
the last opted-in connection closes. Shutdown skips `reconnectGraceMs` and
waits for each cancellation to finish or reach its venue timeout.

:::warning\[Best effort only]
A process crash, a venue failure, or a partial cancellation can leave orders
open. After reconnecting, reconcile with
[`GET /v1/openOrders`](/api/open-orders) and cancel explicitly.
:::

## Limits

* Frames share the user's exchange budget with REST, across API-key rotations
  and connections. An exhausted budget returns a `rate_limited` error frame;
  the socket stays open.
* Frames also count against the per-IP WebSocket message budget. Exceeding it
  closes the connection.
* At most 8 frames are in flight per connection. At the limit Mosaiq stops
  reading the socket until a slot frees; it does not reject the frame.
* Frames are limited to 1 MiB. A larger frame closes the connection, as does an
  outbound queue of more than 16 undelivered responses.

## Disconnects and shutdown

A connection close races with the first durable or ordering-visible action in
each frame. If the close wins, Mosaiq stops the frame before it records a fact,
updates in-memory order state, or sends to a venue. If the action wins, Mosaiq
continues the whole frame to completion, including every item in a batch. The
response can be lost because the socket is closed. Gateway shutdown uses the
same boundary.

After reconnecting, reconcile by `cloid` before you retry a frame with no
response.

## Ordering

Within one connection, frames are admitted to rate limiting in arrival order.
Stateful ordering is keyed by `(venue, cloid)`: a frame that targets the same
order as an earlier frame waits until that earlier frame reaches its venue
handoff, meaning Mosaiq has validated it, recorded it durably, updated
in-memory state, and committed it to the venue send lane. Handoff is not a
venue acknowledgement.

This is what keeps a dependent `cancel` or `modify` from overtaking its
`place`: send both on the same connection, in that order, with the same
public `cloid`. Frames for unrelated orders run concurrently. The order key is
global per user, so a frame for the same `(venue, cloid)` on another
connection also waits, but the order in which frames from different
connections register is not predictable. If the earlier frame fails before its
venue handoff, the waiting frame is rejected with `dependency_failed` and is not
executed. Venue acknowledgements, responses, lifecycle events, and fills can
still arrive out of order. Match responses by frame `id` and order state by
`cloid`.

## Frames

An order frame is a numeric `id` and one `request`. `id` is required, must be
an unsigned integer, and is echoed verbatim; a frame that fails to parse is
answered with `"id": null`. Placements use the REST fields; `c` is required
on every WebSocket placement.

```jsonc
{
  "id": 1,
  "request": {
    "type": "place",
    "orders": [
      {
        "a": "BTC",
        "b": "buy",
        "p": "64000",
        "s": "1",
        "venue": "hyperliquid",
        "t": "Alo",
        "c": "0x00000000000000000000000000000005"
      }
    ]
  }
}
```

Order responses use the Hyperliquid post envelope:

```jsonc
{
  "channel": "post",
  "data": {
    "id": 1,
    "response": { "type": "action" | "error", "payload": { /* see below */ } }
  }
}
```

An `action` response is sent once every item has a venue outcome. Its
`payload.response.type` is `order`, `modify`, or `cancel`, and `statuses[]` is
aligned with the request `orders[]`:

```jsonc
{
  "status": "ok",
  "response": {
    "type": "order",
    "data": {
      "statuses": [
        { "resting": { "cloid": "0x00000000000000000000000000000005" } },
        { "error": "duplicate_cloid: ..." }
      ]
    }
  }
}
```

* Place and modify items succeed as `{ "resting": { "cloid" } }`, including an
  `Ioc` that filled immediately. The effective size is not returned; read it
  from `orderUpdates`.
* Cancel items succeed as the string `"success"`.
* A failed item is `{ "error": "<code>: <msg>" }` with the REST item-level
  codes. Other items in the frame are still submitted.

An `error` response rejects the whole frame. Its `payload` is the REST error
envelope `{ "status": "error", "data": { "code", "msg" }, "server_time_ms" }`.
Frame-level rejections cover unparseable frames, unsupported request types,
empty or oversized batches, the exchange rate limit, duplicate `cloid`
values inside one `modify` batch, `dependency_failed` when an earlier frame for
the same order failed before its venue handoff, and
`pre_venue_resource_timeout` when the frame could not acquire risk admission
within 2 seconds. A missing `c` on a placement is an item-level `bad_request`.

## Supported actions

* `place`, `modify`, and `cancel` batches, same shape and venue codes as REST;
* limit and market placements, including `reduceOnly`;
* `modify` on Hyperliquid and Lighter, without `t`;
* no `mass_cancel`, `deposit`, or `withdraw`.

## Maintenance announcements

Subscribe on the same authenticated `/v1/exchange/ws` connection used for orders.
The API key must belong to a registered wallet, as for execution requests.
`/v1/market-data/ws` and `/v1/ws` reject this topic.
Maintenance messages use a `status` / `topic` envelope; order responses
keep their existing `channel: "post"` envelope.

```json
{ "method": "subscribe", "subscription": { "type": "maintenance" } }
```

This subscription has no `user`, venue filter, or `unsubscribe` method.
Do not mix `id` or `request` action fields into this subscription frame. Subscribe
requests share the connection's IP and user request limits. One snapshot read
and one pending resubscription are allowed per connection; further requests
receive a `rate_limited` error. Snapshot reads time out after two seconds
and do not block order dispatch or heartbeat handling. Subscription errors keep
the connection open; retry the subscription after resolving the error.
The existing cancel-on-disconnect option still applies when this connection closes.

This account-independent topic covers Hyperliquid mainnet and Lighter Core.
It uses the endpoint's existing API-key authentication. On subscription, Mosaiq reads the durable snapshot from PostgreSQL.
`data.message_type` is `snapshot`; subsequent messages use `update`.
Every message replaces the complete `data.sources` array, including source
freshness. Successful persistence after each 60-second poll publishes a new
snapshot even when announcements are unchanged. Repeated subscriptions return
a fresh snapshot. After reconnecting, subscribe again; subscriptions do not
survive a disconnect.

### Snapshot and update messages

Illustrative snapshot (not a live status report):

```json
{
  "status": "ok",
  "topic": "maintenance",
  "server_time_ms": 1788646149474,
  "data": {
    "message_type": "snapshot",
    "sources": [
      {
        "venue": "hyperliquid",
        "source_url": "https://hyperliquid.statuspage.io/api/v2/scheduled-maintenances.json",
        "status": "fresh",
        "checked_at_ms": 1788646149474,
        "last_success_ms": 1788646149474,
        "maintenances": []
      },
      {
        "venue": "lighter",
        "source_url": "https://status.lighter.xyz/index.json",
        "status": "fresh",
        "checked_at_ms": 1788646149474,
        "last_success_ms": 1788646149474,
        "maintenances": [
          {
            "id": "1051290",
            "title": "Network Upgrade",
            "status": "scheduled",
            "scheduled_start_ms": 1788692431000,
            "scheduled_end_ms": 1788700531000,
            "source_url": "https://status.lighter.xyz/maintenance/1051290"
          }
        ]
      }
    ]
  }
}
```

Updates have the same shape with `message_type: "update"`. Route these frames
by `topic: "maintenance"`, separately from order responses with `channel: "post"`.
Replace the whole source array on either message type; do not append events
on every update. A new subscription starts with a snapshot, not a replay of
all changes while disconnected.

Each source contains `venue`, `source_url`, `status`, `checked_at_ms`,
`last_success_ms`, and `maintenances`. Source status is `unknown` before the
first poll, `fresh` after success, or `stale` after a failed fetch or invalid
response. Failed polls retain the previous announcements. Clients should also
treat a source as stale when `last_success_ms` is more than 180 seconds old.
Both timestamp fields may be `null` before any observation. An empty
`maintenances` array with an unknown or stale source does not establish that
no maintenance is planned.

Each maintenance contains `id`, `title`, `source_url`, `status`,
`scheduled_start_ms`, and nullable `scheduled_end_ms`. Times are UTC Unix
milliseconds. The end is the source's scheduled estimate, not proof that
trading has resumed. Status is `scheduled`, `in_progress`, `verifying`,
`completed`, or `unknown`. Completion requires an explicit source state.
Missing previously active entries become `unknown`, never `completed`.

When the end is present, estimated duration is
`scheduled_end_ms - scheduled_start_ms`: 8,100,000 ms (2 hours 15 minutes)
in the example. A `null` end means the duration is unknown. Reaching the
scheduled end does not by itself mark the maintenance completed.

Mosaiq polls [Hyperliquid Statuspage](https://hyperliquid.statuspage.io/api/v2/scheduled-maintenances.json)
and [Lighter Core status](https://status.lighter.xyz/index.json) every 60 seconds,
with a 10-second timeout and a 1 MiB response limit per source. These public
feeds determine coverage; Telegram-only announcements are not included.
Lighter's `maintenance` source state becomes `scheduled` before `starts_at`
and `in_progress` afterward. This follows the published schedule and does not
independently verify venue downtime. Lighter Core is separate from Robinhood
Chain, whose announcements are not included.

WebSocket snapshots retain completed and missing events for seven days after the scheduled window
and at most 128 events per venue. Upstream feeds have limited history; this
stream sends current state while PostgreSQL retains event history. Updates can coalesce to the latest complete snapshot. Connections that cannot
keep up with the bounded outbound queue close under the existing execution-stream policy. A 200 response with invalid data retains the cache and marks the
source stale. None of these announcements changes Mosaiq's trading gates or
cancel-on-disconnect behavior.

Monitor `tessera_maintenance_source_fresh{venue}` and
`tessera_maintenance_source_last_success_timestamp_seconds{venue}` for source
failures or a stopped poller.

### Subscription errors

A subscription error uses the maintenance envelope, for example:

```json
{
  "status": "error",
  "topic": "maintenance",
  "data": { "code": "rate_limited", "msg": "rate limit exceeded" },
  "server_time_ms": 1788646149474
}
```

Handle the `data.code` and treat `data.msg` as explanatory text. Invalid
subscription fields or a snapshot timeout return
`invalid_websocket_subscription`; a failed database read returns an error
without a RAM snapshot fallback. Back off before retrying the subscription.
Keep processing order responses and heartbeats while waiting.

### Durable history

Mosaiq saves maintenance changes before publishing them. Initial subscriptions
read the saved snapshot from PostgreSQL, with no RAM fallback if that read
fails. Retry the subscription after a snapshot error. Pending updates cannot
replace a newer initial snapshot with older data.

Event history survives restarts and the seven-day WebSocket retention window.
This topic serves current snapshots; it does not expose a history query or
replay cursor.
Repeated identical observations add no version. A change from A to B and back
to A retains all three versions. This includes fetched older events and the
`unknown` state inferred when an event disappears from its source. Source
freshness polls alone do not create event versions.

Restart restores the saved announcements and marks sources stale until a new
fetch succeeds. If saving fails, Mosaiq keeps the last committed state and retries
the pending batch every 60 seconds. New source polls pause until that batch is
saved. Transitions never observed during an outage cannot be reconstructed.

Monitor `tessera_maintenance_persistence_healthy` separately from source HTTP
health.
