> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kuru.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Exchange WebSocket API

> Binary market and user streams with replayable sequence cursors.

Browsers connect directly to `wss://gateway.mainnet.kuru.io/ws` on mainnet or `wss://gateway.testnet.kuru.io/ws` on testnet. The examples below target mainnet. One
connection can multiplex market and user subscriptions. Control messages are
JSON text frames; data messages are binary frames with an eight-byte subscription ID followed by a payload beginning with the `KXMD` magic bytes.

Every server response carries a `feedEpoch`. The epoch is fixed for the server
process and namespaces every market and user sequence. A sequence number is
only meaningful together with the epoch in which it was issued.

```js theme={null}
const socket = new WebSocket("wss://gateway.mainnet.kuru.io/ws");
socket.binaryType = "arraybuffer";
```

The public frontend contract subscribes to finalized (`@f`) topics.

<CardGroup cols={2}>
  <Card title="L2 deltas" icon="wave-pulse" href="/api/exchange/websocket-reference/l2-delta">
    Snapshot, replay, and live order-book changes.
  </Card>

  <Card title="Trades" icon="chart-line" href="/api/exchange/websocket-reference/trades">
    Replayable market fills.
  </Card>

  <Card title="L2 book" icon="book-open" href="/api/exchange/websocket-reference/l2-book">
    Periodic complete order-book snapshots.
  </Card>

  <Card title="BBO" icon="arrow-right-arrow-left" href="/api/exchange/websocket-reference/bbo">
    Periodic best bid and offer snapshots.
  </Card>

  <Card title="All mids" icon="chart-no-axes-combined" href="/api/exchange/websocket-reference/all-mids">
    Mid prices for every active market.
  </Card>

  <Card title="User orders" icon="list" href="/api/exchange/websocket-reference/user-orders">
    Replayable order updates with periodic snapshots.
  </Card>

  <Card title="User balances" icon="wallet" href="/api/exchange/websocket-reference/user-balances">
    Replayable balance updates with periodic snapshots.
  </Card>

  <Card title="User trades" icon="receipt" href="/api/exchange/websocket-reference/user-trades">
    Replayable taker- and active-maker-side executions.
  </Card>
</CardGroup>

## Control requests

Clients send control requests as UTF-8 JSON text frames. Binary frames are
reserved for server data. The request object is strict: unknown keys are
rejected instead of ignored.

```ts theme={null}
type ExchangeWsControlRequest =
  | {
      op: "sub";
      id: number;
      t: string;
      m?: string;
      u?: number;
      feedEpoch?: number;
      seq?: number;
      n?: number;
      g?: "none" | "tick" | "sf";
      tk?: number;
      sf?: number;
      mt?: number;
      e?: boolean;
    }
  | { op: "unsub"; id: number }
  | { op: "ping"; ts?: number };
```

The Gateway's Rust parser accepts non-negative JSON integers across the full
unsigned 64-bit range unless a narrower bound is stated below. JavaScript
clients must restrict number-valued controls to `Number.MAX_SAFE_INTEGER`
(`9,007,199,254,740,991`) because larger JSON numbers cannot be represented
exactly. Binary-frame sequence values remain `bigint`; do not lossy-convert a
larger cursor to `number` when constructing a control request.

| Key | Required for | Meaning |
| - | - | - |
| `op` | Every request | `sub`, `unsub`, or `ping` |
| `id` | `sub`, `unsub` | Client-selected unsigned request and subscription ID |
| `t` | `sub` | Case-sensitive compact topic string |
| `m` | Market topics | Lowercase, `0x`-prefixed 20-byte market address |
| `u` | User topics | Numeric Kuru user ID |
| `feedEpoch` | `sub` with `seq` | Expected feed namespace; may also bind a fresh subscription without replay |
| `seq` | Replayable `sub` | Last sequence already applied by the client; replay starts strictly after it |
| `ts` | `ping` | Optional client timestamp echoed unchanged by `pong` |
| `n` | `l2Book` only | Depth per side, `1..200`; defaults to `20` |
| `g` | `l2Book` only | `none`, `tick`, or `sf`; defaults to `none` |
| `tk` | `l2Book` with `g=tick` | Positive native tick grouping size |
| `sf` | `l2Book` with `g=sf` | Significant-figure count from `2` through `5` |
| `mt` | `l2Book` with `g=sf` and `sf=5` | Optional mantissa bucket: `1`, `2`, or `5` |
| `e` | `l2Book` only | Extended L2 tuples when `true`; defaults to `false` |

### Subscribe

The documentation and examples default to the finalized view. Every topic
still requires an explicit view suffix: use `@f` for finalized, `@v` for
voted, or `@p` for proposed. Proposed and voted subscriptions are intended for
diagnostic clients; frontend applications should use finalized streams.

The topic determines its scope, cadence, and whether `seq` is accepted:

| Finalized topic | Required selector | Duration | Replay cursor | L2 options |
| - | - | - | - | - |
| `l2Delta@f` | `m` | Forbidden | `MarketSeq` | Forbidden |
| `trades@f` | `m` | Forbidden | `MarketSeq` | Forbidden |
| `l2Book@f@<ms>` | `m` | Required, positive | Unsupported | Allowed |
| `bbo@f[@<ms>]` | `m` | Optional; default `50` ms | Unsupported | Forbidden |
| `allMids@f[@<ms>]` | Neither `m` nor `u` | Optional; default `50` ms | Unsupported | Forbidden |
| `userOrders@f[@<ms>]` | `u` | Optional; default `300` ms | `GlobalUserSeq` | Forbidden |
| `userBalances@f[@<ms>]` | `u` | Optional; default `300` ms | `GlobalUserSeq` | Forbidden |
| `userTrades@f` | `u` | Forbidden | `GlobalUserSeq` | Forbidden |

Replace `@f` with `@v` or `@p` to select another supported view. All other
topic-specific field rules remain the same.

Market subscriptions require `m` and reject `u`. User subscriptions require
`u` and reject `m`. `allMids` rejects both selectors. The six L2 formatting
fields are accepted together only by `l2Book`; supplying any of them to another
topic returns `bad_options`.

For significant-figure grouping, `g=sf` requires `sf`. `mt` is permitted only
when `sf=5`. For native-tick grouping, `g=tick` requires a positive `tk` and
rejects `sf` and `mt`. The ungrouped mode rejects `tk`, `sf`, and `mt`.

`seq` must be greater than zero and is accepted only by the five replayable
streams in the table. Whenever `seq` is present, `feedEpoch` is required. A
`feedEpoch` may be supplied without `seq` to ensure a fresh subscription still
belongs to the REST response's namespace. Any asserted epoch is checked before
replay or subscription ownership; a mismatch returns `feed_epoch_mismatch`.

Subscribe to a market stream after applying sequence `1022`:

```json theme={null}
{
  "op": "sub",
  "id": 1,
  "t": "l2Delta@f",
  "m": "0x8121EE960066e4344A213F9aC7502395502aEe7a",
  "feedEpoch": 0,
  "seq": 1022
}
```

Subscribe to a user stream after applying sequence `2951`:

```json theme={null}
{
  "op": "sub",
  "id": 2,
  "t": "userOrders@f@300",
  "u": 2,
  "feedEpoch": 0,
  "seq": 2951
}
```

An accepted subscription receives an acknowledgement before bootstrap,
replay, or live binary frames:

```json theme={null}
{"op":"ack","feedEpoch":0,"id":1,"t":"l2Delta@f"}
```

The `id` must be unique among active subscriptions on that connection. A
duplicate ID or a connection subscription-cap rejection returns
`bad_options`. The ID becomes reusable after a successful unsubscribe.

Coalesced durations are positive milliseconds. Recommended frontend values are
`50`, `100`, `250`, `500`, and `1000`. A `userOrders` or `userBalances`
duration must not exceed the server's configured full-snapshot interval, which
defaults to five seconds.

### Unsubscribe

Unsubscribe by the same client-selected ID:

```json theme={null}
{"op":"unsub","id":1}
```

The operation is idempotent. The Gateway acknowledges it even when that ID is
not currently active:

```json theme={null}
{"op":"ack","feedEpoch":0,"id":1,"t":"unsub"}
```

### Application ping

An application-level ping may include a timestamp:

```json theme={null}
{"op":"ping","ts":1784794509021}
```

The Gateway echoes `ts` when present:

```json theme={null}
{"op":"pong","feedEpoch":0,"ts":1784794509021}
```

This JSON ping is separate from the native WebSocket Ping frame the Gateway
sends every 15 seconds by default. Browsers answer native Ping frames with Pong
automatically.

## Control responses

All `ack`, `pong`, `err`, `resync`, and `resub` responses carry `feedEpoch`.
Control responses are JSON text frames and are distinct from binary market and
user data frames.

### Acknowledgement and pong

`ack` confirms either a successful subscription or an idempotent unsubscribe.
For a subscription, the acknowledgement is queued before any bootstrap,
replay, or live binary data. `pong` echoes the optional timestamp from an
application-level `ping`.

```json theme={null}
{"op":"ack","feedEpoch":0,"id":1,"t":"l2Delta@f"}
```

```json theme={null}
{"op":"pong","feedEpoch":0,"ts":1784794509021}
```

### Errors

Invalid requests receive `err` with a stable code and a diagnostic message.
`id` is optional: it is absent when parsing or validation fails before the
Gateway can recover a request ID.

```json theme={null}
{
  "op": "err",
  "feedEpoch": 0,
  "id": 1,
  "c": "bad_options",
  "msg": "seq requires feedEpoch"
}
```

| Code | Meaning |
| - | - |
| `bad_json` | Malformed JSON, an unknown key, or an invalid `op` value |
| `bad_op` | Missing `id` for `sub` or `unsub` |
| `bad_topic` | Missing, malformed, unknown, or duration-incompatible topic |
| `bad_market` | Missing, malformed, or forbidden market selector |
| `bad_user` | Missing, malformed, or forbidden user selector |
| `bad_view` | Missing or invalid view suffix |
| `bad_duration` | Missing, malformed, zero, or over-limit coalescing duration |
| `bad_options` | Invalid option combination, replay cursor, or connection limit |
| `feed_epoch_mismatch` | Asserted epoch differs from the live server epoch |
| `unsupported` | Recognized feature is unavailable, or the control-message rate limit was exceeded |

### Market resynchronization

A market replay or live-join gap emits a market `resync` control. It has no
subscription `id`; the affected stream is identified by topic `t` and market
`m`. `seq` is the Gateway's suggested first available sequence boundary.

```json theme={null}
{
  "op": "resync",
  "feedEpoch": 0,
  "t": "l2Delta@f",
  "m": "0x8121EE960066e4344A213F9aC7502395502aEe7a",
  "seq": 992500
}
```

Stop applying that market stream, discard its local derived state, and create
a fresh subscription. For `l2Delta`, omit `seq` so the Gateway supplies a new
authoritative L2 snapshot before live deltas.

### User resynchronization

A user resync includes the affected subscription `id`, topic, a stable reason,
and optional cursor information:

```json theme={null}
{
  "op": "resync",
  "feedEpoch": 0,
  "id": 8,
  "t": "userTrades@f",
  "seq": 82000,
  "latest": 83000,
  "r": "history_lost"
}
```

`history_lost` applies to `userTrades`, which has no authoritative snapshot
with which to bridge an evicted history range. Refresh the corresponding REST
history, then recreate the subscription using the supplied `feedEpoch` and
`seq` as the last processed cursor when `seq` is present. `latest` is the
latest sequence indexed for that exact user stream and may be absent.

`userOrders` and `userBalances` first try to recover a retention gap
transparently: the Gateway emits an authoritative snapshot, replays from that
baseline, and continues the existing subscription without a control response.
Only when it cannot establish a safe baseline does it emit:

```json theme={null}
{
  "op": "resync",
  "feedEpoch": 0,
  "id": 6,
  "t": "userOrders@f@300",
  "seq": 82000,
  "latest": 83000,
  "r": "baseline_unavailable"
}
```

On `baseline_unavailable`, discard the affected user state and resubscribe
without `seq`; the Gateway will establish a new authoritative snapshot.

### Resubscription

`resub` means the publisher-side subscription has already been removed. The
WebSocket connection normally remains open and other subscription IDs are
unaffected.

```json theme={null}
{
  "op": "resub",
  "feedEpoch": 0,
  "id": 1,
  "t": "trades@f",
  "r": "publication_gap"
}
```

The reason is `upstream_closed` when the upstream publisher or forwarding task
ended unexpectedly, or `publication_gap` when lossless live delivery could no
longer prove continuity. Recreate only the identified subscription, normally
using a new client-selected ID and the last cursor your client durably applied.

## Sequence reconciliation

`l2Delta` and market `trades` use per-market `marketSeq` cursors. User orders,
balances, and trades use per-user `globalUserSeq` cursors. An exact
`(market, view, topic)` or `(user, view, topic)` stream can be sparse within its
sequence domain, so continuity follows the predecessor encoded in each frame,
not arithmetic adjacency. Treat the replay cursor as `(feedEpoch, marketSeq)`
or `(feedEpoch, globalUserSeq)`, and store it only after the corresponding
payload has been applied.

When `seq` is supplied in a subscription, `feedEpoch` is required. The server
rejects a stale epoch with `feed_epoch_mismatch` rather than interpreting the
sequence in a new namespace.

For a market replay requested after sequence `R`, accept the first returned
frame only when `marketSeq > R` and `previousMarketSeq` is `null` or less than
or equal to `R`. After accepting that bridge frame, require each subsequent
frame's `previousMarketSeq` to equal the last accepted `marketSeq` exactly.
User streams apply the same rule using `globalUserSeq` and
`previousGlobalUserSeq`. A predecessor may be much lower than the current
sequence; never derive it by subtracting one.

For market trades, use `feedEpoch` and `marketSeq` from the
[Data Source market-trades response](/api/data-source/reference/market-trades).
For user streams, use `feedEpoch` and `globalUserSeq` from the corresponding
Data Source user response. Gateway REST responses also carry `feedEpoch`, but
the supported L2 bootstrap is the snapshot emitted by `l2Delta` itself. Start
an L2 stream without `seq` when you need that initial snapshot.

When a sequence-linked stream reports an unrecoverable gap, stop applying
updates and follow the specific `resync` or `resub` instruction rather than
guessing over the missing range. User-order and user-balance retention gaps may
instead be repaired transparently with an authoritative snapshot. If a binary
frame carries a different `feedEpoch` from the installed client state, discard
it and bootstrap again. Never compare sequence numbers across epochs.

## Binary frame header

Every binary data frame starts with the same 16-byte header:

| Offset | Width | Field |
| - | - | - |
| `0` | `4` | ASCII magic `KXMD` |
| `4` | `1` | Wire version (`1`) |
| `5` | `1` | Message kind |
| `6` | `1` | View or finality code |
| `7` | `1` | Message flags |
| `8` | `8` | Unsigned `feedEpoch` |

Kind-specific fields begin at byte `16`.

Kinds `2` (`l2Delta`), `3` (market `trades`), and market-scoped kind `4`
(`lifecycle`) share this replayable market prefix after the common header:

| Offset | Width | Field |
| - | - | - |
| `16` | `20` | `marketAddress` |
| `36` | `8` | `marketSeq` |
| `44` | `8` | `previousMarketSeq`; zero means `null` |
| `52` | `8` | `globalSeq` |

For `l2Delta` and market `trades`, `sourceBlockNumber` occupies bytes `60..68`,
`sourceBlockId` occupies bytes `68..100`, and the record count occupies bytes
`100..104`. The market lifecycle body begins at byte `60`. `l2Book`, BBO,
and all-mids retain 16-byte signed price fields. Delta/trade/order prices use
8-byte signed fields. All prices are native pp integers. L2 delta tuples are
61 bytes; market trade tuples are 43 bytes; user snapshot orders are 104 bytes;
active/passive user trades are 159/153 bytes. Wire version remains 1 for the
coordinated testnet rollout. Update clients that assumed x18 snapshot units
or the intermediate widened event-price layout together with the server.

<Info>
  The reference pages describe the exact objects returned by the shared
  envelope decoder's `message` property. Use `decodeExchangeWsEnvelope` on socket
  bytes; `decodeExchangeWsFrame` accepts only the inner payload. The decoders
  preserve wire integers as `bigint`. Every
  price is in native market price precision (pp); market/order/trade quantities remain native size precision.
  Token balances retain native token decimals. Do not apply a second price conversion.
</Info>

## Lifecycle frames

Replayable streams may interleave lifecycle frames where the selected view
requires them. The decoder returns the following shape:

```ts theme={null}
interface ExchangeWsLifecycleEvent {
  action:
    | "blockProposed"
    | "blockVoted"
    | "blockFinalized"
    | "blockExecuted"
    | "branchDropped";
  blockNumber: bigint;
  blockId: string;
  parentBlockId: string | null;
  dropReason:
    | "competingBlock"
    | "sourceDroppedSignal"
    | "parentNotCanonical"
    | "sourceCorrection"
    | null;
  replacementBlockId: string | null;
}

interface ExchangeWsMarketLifecycleFrame {
  wireVersion: 1;
  feedEpoch: bigint;
  view: "proposed" | "voted" | "finalized";
  kind: "lifecycle";
  scope: "market";
  marketAddress: string;
  marketSeq: bigint;
  previousMarketSeq: bigint | null;
  globalSeq: bigint;
  event: ExchangeWsLifecycleEvent;
}

interface ExchangeWsUserLifecycleFrame {
  wireVersion: 1;
  feedEpoch: bigint;
  view: "proposed" | "voted" | "finalized";
  kind: "lifecycle";
  scope: "user";
  userId: bigint;
  globalUserSeq: bigint | null;
  globalSeq: bigint | null;
  previousGlobalUserSeq: bigint | null;
  event: ExchangeWsLifecycleEvent;
}
```

Finalized market streams contain only promoted market data and do not emit
lifecycle frames. Under the current user-retention semantics, finalized user
streams may still replay promoted lifecycle controls. Consumers should handle
`kind: "lifecycle"` rather than assuming every user frame is a business-data
frame.

See the [userTrades response changelog](/api/exchange/user-trades-changelog) for
fee/source fields and the coordinated version-1 testnet decoder update.

## Binary subscription envelope

Every binary socket message starts with an eight-byte unsigned big-endian `id`,
followed by the existing `KXMD` payload. The id is the client-selected subscription
id within that socket. This applies to all streams and to snapshots, replay and
live messages; one prefix identifies the entire message, including all its rows.
All existing layout offsets describe the inner payload; add eight for socket offsets.

Use `decodeExchangeWsEnvelope(data)` for synchronous binary decoding or
`await decodeExchangeWsMessage(data)` for binary/Blob input. Both return
`{ id: bigint, message }`; route `message` by `id` within the socket.
The existing `decodeExchangeWsFrame` and stream-specific frame decoders accept
only the inner payload after the prefix has been removed.

<Warning>
  See [decoder support](/sdk/installation#exchange-websocket-decoder-compatibility) before selecting an SDK decoder for these frame layouts.
</Warning>

```ts theme={null}
import { decodeExchangeWsMessage } from "@toxicflow-labs/ts-sdk/exchange-ws";

socket.binaryType = "arraybuffer";
socket.onmessage = async ({ data }) => {
  if (typeof data === "string") {
    // Handle JSON acknowledgement and recovery controls separately.
    return;
  }
  const { id, message } = await decodeExchangeWsMessage(data);
  // id is a bigint matching the client-supplied subscription id on this socket.
  console.log(id, message.kind, message);
};
```

Two subscriptions to the same topic can receive identical inner payloads with
different outer IDs. Route by the envelope `id`, not by topic or frame kind:
an `l2Delta` subscription also receives an `l2Book` bootstrap frame. The decoded
field is named `id`, not `subscriptionId`. The inner KXMD header starts at socket
byte 8; its payload starts at socket byte 24.

JSON control messages and WebSocket ping/pong are unchanged. Wire version stays
1 on testnet, but this is a breaking framing change: deploy the Gateway and
updated socket decoder together. Unprefixed messages are not auto-detected.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.