# 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. Cancellation starts after the last
one disconnects and its pending requests finish. Reconnecting with the option
inside the grace window prevents cancellation. A heartbeat timeout counts as
a disconnect. Unknown query parameters or values that cannot be parsed fail
the upgrade with HTTP `400 invalid_query`. A `reconnectGraceMs` above `30000`,
or supplied without `cancelAllOnDisconnect=true`, returns HTTP `400 bad_request`.

During a planned server shutdown, Mosaiq also attempts to cancel these orders
without waiting for `reconnectGraceMs`.

After the grace window, Mosaiq attempts to cancel every open order. Retries
are limited and can stop with orders still open. Waiting for a venue to
recover can extend cancellation beyond a few seconds. Each retry targets
every order open at that moment, including orders placed while it waited.

If you reconnect with `cancelAllOnDisconnect=true` before cancellation
completes, Mosaiq stops retrying and leaves your remaining orders open. Cancels
it already sent still complete, so an order can still disappear shortly after
you reconnect. On every reconnect, query
[`GET /v1/openOrders`](/api/open-orders), watch `orderUpdates` for cancels that
complete late, and cancel the orders you no longer want. A reconnect without
the option does not stop cancellation.

While a venue connection is halted, cancellation has no time limit: it waits
for that venue to accept cancels again. A reconnect without
`cancelAllOnDisconnect=true` does not stop it, so orders you place meanwhile,
on any venue, are cancelled when it finally runs.

:::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 128 requests are in flight per client IP, across all of that IP's
  connections. A request is in flight until its answer, which comes once Mosaiq
  launched its venue send or refused it. At the limit, Mosaiq returns
  `inflight_limit_exceeded`.
  Ping, pong, close, and maintenance control frames never
  count. Clients behind one NAT address share the budget, as they do at the
  venues. If Mosaiq cannot deliver the refusals in time, it attempts to close
  with code `1013` and reason `in-flight refusal queue full` or
  `in-flight refusal expired`. A blocked socket can prevent delivery of the
  close frame.
  After reconnecting, check orders without responses by `cloid` before retrying.
* Frames are limited to 1 MiB. A larger frame closes the connection.
* The outbound queue holds 256 messages, including responses, maintenance
  messages, and control frames. A message that cannot be queued closes the
  connection. The shared [outbound byte and age limits](/api/websocket#rate-limits)
  also apply.

## Disconnects and shutdown

Requests already owned by Mosaiq can continue after a client disconnect, even
if their responses are lost. A server crash before durable acceptance may
discard a place, modify, or cancel. REST and WebSocket share the
[acceptance and recovery contract](/api/exchange#disconnects-and-shutdown).
Reconnect and check each order by `cloid` before retrying a frame with no response.

## Ordering

Send a dependent `cancel` or `modify` after its `place` on the same connection,
using the same `(venue, cloid)`. Mosaiq orders these actions without requiring
you to wait for the placement's response.

Consecutive modify frames for the same `(venue, cloid)` execute in arrival
order. Each waits for the earlier modify's venue outcome before admission. A
modify's `accepted` answer does not confirm the changed order state; the order's
`orderUpdates` row does. If the earlier modify remains unresolved after the
two-second confirmation wait, the waiting item returns `modify_in_flight`
without being sent. A modify sent right after its place also waits up to two
seconds for the venue to answer the place. If the venue has not confirmed the
placement by then, the modify returns `modify_placement_not_confirmed` without
being sent. Unrelated orders continue independently.

A cancel following a pending placement on any supported venue can stop that
placement before it is sent. This applies whether the placement, the cancel,
or both arrive in batches. A later cancel supersedes only its targeted
placement; unrelated batch items continue. Earlier pending actions on the
same order, such as a modification, still retain their ordering barriers.

For example, after `place [A, B]`, a `cancel [A]` can prevent A while B
proceeds. The placement response still includes both items in their original
positions. A prevented placement answers `accepted`, like its cancel, and
`orderUpdates` reports it `cancelled` with no `failure`.

Prevention is best effort. If the placement has already been sent, the cancel
follows the normal venue path and a fill can still occur.

Batch duplicate rejection remains in effect when a placement is prevented.
For example, send `place [A, A]` with the same `cloid` and venue for both items,
then `cancel [A]` on the same connection. The second placement is rejected
with `duplicate_cloid`. If the cancel prevents the first placement before
dispatch, neither placement is sent to the venue. Preventing the first does
not make the second eligible for submission.

Unrelated items can run concurrently, including items within one batch.
Batch membership does not guarantee submission together in one venue request.
Do not rely on request order across separate connections. Responses, order
updates, and fills can 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 an answer. An item answers
when Mosaiq launched its venue send or refused it, so no answer waits for the
venue. 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": [
        { "accepted": { "cloid": "0x00000000000000000000000000000005" } },
        { "error": "duplicate_cloid: ..." }
      ]
    }
  }
}
```

* Place, modify, and cancel items answer `{ "accepted": { "cloid" } }` once
  Mosaiq launched their venue send. The item carries the `cloid` only, never an
  order id, and a cancel's item carries the `cloid` of its order. `accepted`
  says nothing about the venue's verdict. Read it on
  [`orderUpdates`](/api/websocket#orderupdates), as
  [Follow each write on orderUpdates](/api/exchange#follow-each-write-on-orderupdates)
  shows. The effective size is not returned either; read it from
  `orderUpdates`.
* A refused item is `{ "error": "<code>: <msg>" }` with the REST item-level
  codes. Mosaiq refused it before any venue effect, so no `orderUpdates` row
  follows, except after `request_uncertain` or a launch that failed after the
  action became durable (see the [REST response](/api/exchange#response)).
  Other items in the frame are still submitted.
* `{ "resting": { "cloid" } }`, the string `"success"`, and the item codes of
  venue outcomes, such as `place_rejected`, `cancel_not_on_book`, or
  `preemptive_cancel`, no longer appear. The
  [REST error codes](/api/exchange#error-codes) list them.

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 (including a decimal string
that is not [plain](/api/exchange#fields), with `bad_request`), unsupported
request types, empty or oversized batches (with the [REST batch codes](/api/exchange#batches)),
the exchange rate limit, duplicate `cloid`
values inside one `modify` batch. A missing `c` on a placement is an
item-level `bad_request`. If Mosaiq cannot prepare an item for submission
within 2 seconds, it returns `pre_venue_resource_timeout` for that item.
Check order state after uncertain or internal errors as described in the
[REST response contract](/api/exchange#response).

## 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. Repeated requests
while a subscription is loading can return `rate_limited`. Subscription
errors keep the connection open; continue reading order responses and
heartbeats while waiting or retrying.

This account-independent topic covers Hyperliquid mainnet and Lighter Core.
The first message has `data.message_type: "snapshot"`; subsequent messages use `update`.
Every message replaces the complete `data.sources` array, including source
freshness. Updates may repeat unchanged announcements. After reconnecting,
subscribe again; there is no replay of changes missed while disconnected.

### 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`.

Announcements come from [Hyperliquid Statuspage](https://hyperliquid.statuspage.io/api/v2/scheduled-maintenances.json)
and [Lighter Core status](https://status.lighter.xyz/index.json), checked every
60 seconds. There is no separate Robinhood source: Lighter announcements also
apply to `lighter:robinhood`, and Hyperliquid announcements apply to its HIP-3
venues. Coverage excludes Telegram-only announcements. Lighter's `scheduled`
and `in_progress` states follow its published
start time; they do not independently confirm venue downtime.

Snapshots include at most 128 events per venue and retain completed or
missing events for seven days after their scheduled window. They are not a
complete history.

An `in_progress` announcement can reject place and modify requests with
`maintenance_in_progress` while its source is `fresh` and was successfully
checked within five minutes. This refusal ends 15 seconds after the scheduled
end, if provided; without an end, it lasts while the fresh source reports
`in_progress`. A completed announcement or stale source also ends this
maintenance refusal. Other trading restrictions may still apply. Cancels
and cancel-on-disconnect are not blocked by this check.

### 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`. If the snapshot is unavailable, back off
before retrying the subscription.
