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

# Scoped WebSocket protocol

> Decode binary frames, maintain exact-link snapshots and recover from state gaps.

Connect to `wss://api.liftx.io/v1/stream` using a client that supports an HTTP `Authorization: Bearer …` upgrade header. `read` permission and current entitlement are required. Do not put a key in the URL or a WebSocket subprotocol.

This is a state stream. Trading commands remain durable HTTP requests. The connection reuses Liftx publications and does not create a bot-specific exchange socket.

## Authorization and connection scope

All channel controls belong to one exact `exchange_link_id`. A connection cannot switch to another link by sending another control. Use a separate authorized connection when another link is required, rather than multiplexing account authority implicitly.

Instrument-restricted credentials cannot subscribe to link-wide balances, positions pages or private indexes. An exact-instrument ticker or authorized exact-position history remains subject to scope checks. Expiry has an exact connection deadline. Local revocation cancels registered streams, and an authority sweep detects out-of-process changes within its 30-second interval.

## Client controls: JSON text frames

```json theme={null}
{
  "op":"subscribe",
  "channels":[
    {"type":"positions","exchange_link_id":17,"status":"active","offset":0,"limit":50,"include_total":true,"request_id":1},
    {"type":"positions_index","exchange_link_id":17},
    {"type":"orders_index","exchange_link_id":17}
  ]
}
```

Each message has 1–32 channels and at most 16 KiB. Fields are exact, unique and case-sensitive; unknown fields invalidate control. Send only fields listed for the operation. Client controls must be text frames; binary, malformed or empty controls can terminate the stream generation.

| Channel | Subscribe fields beyond `type` and link | Unsubscribe fields beyond `type` and link | Resync |
| - | - | - | - |
| `balances` | None | None | No; rebuild through a fresh subscription/snapshot |
| `positions` | Required `status:active` or `inactive`, `offset`, `limit`; optional `include_total`, positive `request_id` | None | No; request a fresh page |
| `positions_index` | None | None | Type and link |
| `orders_index` | None | None | Type and link |
| `tickers` | Exact `instrument_id` | Same instrument | No |
| `position_pnl_history` | Canonical `position_id`, positive safe-integer `request_id` | Exact same position and request ID | No |

Position page offset is 0–10,000,000 and limit is 1–200. Each control can include at most one ticker channel and at most one position-history channel. History selection uses a monotonically increasing request ID within that connection; an identical current tuple can be repeated. Do not reuse an older request ID for a different history demand.

```json theme={null}
{"op":"resync","channels":[{"type":"positions_index","exchange_link_id":17}]}
```

```json theme={null}
{"op":"unsubscribe","channels":[{"type":"tickers","exchange_link_id":17,"instrument_id":"COPY_EXACT_CATALOG_ID"}]}
```

## Server data frame header

Each binary WebSocket **message** contains one seven-byte header followed by its payload. WebSocket fragmentation is resolved by the WebSocket library before this parser runs.

| Byte offset | Length | Meaning |
| - | - | - |
| 0 | 1 | Message type: `1` update, `2` event, `3` response, `4` batch |
| 1 | 4 | Unsigned little-endian payload byte length |
| 5 | 1 | Protocol version |
| 6 | 1 | Channel |
| 7 | Header length field | Payload |

Require `message_length == 7 + payload_length`. Set a bounded receive limit appropriate to the documented pages; reject unknown versions and malformed/trailing payloads. Control ping/pong/close frames are WebSocket protocol operations, not this application envelope.

| Channel | Version | Payload |
| - | - | - |
| 1 balances | 1 | Binary balance batch; a response readiness marker is special |
| 2 positions/history notices | 1 | UTF-8 JSON; discriminate message type and JSON shape |
| 3 tickers | 2 | Binary topic mapping or price-update batch |
| 4 positions index | 1 | UTF-8 JSON snapshot/delta |
| 5 orders index | 1 | UTF-8 JSON snapshot/delta |
| 6 private control | 1 | UTF-8 JSON revision/snapshot-required metadata |

Do not assume all payloads are JSON or that the same version applies to every channel.

## Balances: channel 1, version 1

For an update frame, the payload is:

1. `count`: uint32 little-endian.
2. For each row, an asset string ending in NUL (`0x00`).
3. A signed int64 little-endian free balance with fixed scale **12**.

An empty batch contains four zero bytes. A separate response frame (`message_type:3`) with the single payload byte `0x01` marks readiness for the empty complete snapshot path. Completion metadata also arrives on channel 6. The connection's exact link supplies scope; the balance row itself has no link field.

<Warning>
  The existing balance stream codec is a compact presentation format. It truncates precision beyond 12 decimal places toward zero and is bounded by signed int64. Its current encoder can emit zero when conversion is unrepresentable. Therefore a zero stream value alone cannot establish an exact available balance. Use the fixed-point decimal HTTP `/v1/balances` snapshot, including `snapshot_complete`, for sizing and reconciliation. This codec limitation remains a release-qualification concern.
</Warning>

## Positions: channel 2, version 1

| Message type/shape | Interpretation |
| - | - |
| Update (`1`), JSON object | A complete canonical Position publication |
| Batch (`4`), JSON array | Position publications; recovery completeness is conveyed separately |
| Batch (`4`), JSON object with `positions` | PositionsPage publication with pagination/request correlation and possible incompleteness/error |
| Event (`2`), JSON object | `type`, `id`, optional `exchange_link_id`, and event-specific `data` |

Position publications contain revision, product/account identity, OPEN/close/SL/TP rows, canonical allocation/execution amounts, FSM projections and exact fixed-point fields. The full schema is included under `Position` and `PositionOrder` in the [OpenAPI contract](/api/openapi.json).

Do not overwrite a complete position with a partial event as if it were a full model. Apply only understood event fields for the correct link/position/generation. If an event requires state that is absent or cannot be safely interpreted, mark the local view incomplete and reconcile the affected position. Coalesce that read; do not poll every position on every event.

History notifications use event type `position_pnl_history`. Its `data` includes `position_id`, `request_id`, `ready` and `reset`. Verify the exact requested tuple before reading history. A reset invalidates the retained history baseline; a ready/reset notification is not the history samples themselves.

## Tickers: channel 3, version 2

Every ticker payload begins with:

* One-byte kind: `1` topic mapping or `2` price batch.
* A uint16 little-endian row count.

A **mapping** frame has message type `3` and kind `1`. Each row contains:

| Field | Encoding |
| - | - |
| `topic_id` | uint32 little-endian |
| Registered exchange identity | uint32 little-endian |
| Source identity | uint16 little-endian |
| Canonical `instrument_id` | NUL-terminated string |

An **update** frame has message type `1` and kind `2`. Each row contains:

| Field | Encoding |
| - | - |
| `topic_id` | uint32 little-endian |
| `price_scale` | uint16 little-endian |
| `price_atomic` | NUL-terminated signed integer string |

Retain the mapping for the current connection/generation and resolve updates only against it. An unknown topic is not permission to infer a market from the numeric ID. Rebuild the mapping after reconnect. Exchange/source values describe the mapped publication; they do not override execution authority from your authenticated link/catalog.

## Position and order indexes

Channel 4 snapshots use `kind:"positions_index_snapshot"`; deltas use `"positions_index_delta"`. Channel 5 uses `"orders_index_snapshot"` and `"orders_index_delta"`.

Common fields are `exchange_link_id`, `kind`, `session_gen` (an exact decimal **string**) and `rev`. A snapshot adds `complete` and `items`; each item contains `exchange_link_id` and `instrument_id`. A delta has optional `add`/`remove` arrays with the same item shape.

Positions index messages also include `server_epoch`, `active_total` and `inactive_total`. These count positions, while the `items` array is an instrument index, not one row per position. The orders index is likewise an instrument membership index, not a full executable-order inventory.

```json theme={null}
{
  "exchange_link_id":17,
  "kind":"positions_index_snapshot",
  "server_epoch":"SYNTHETIC_SERVER_EPOCH",
  "session_gen":"1",
  "rev":1,
  "active_total":0,
  "inactive_total":0,
  "complete":true,
  "items":[]
}
```

Require a complete snapshot before accepting deltas. Preserve full-width revisions and generation strings. On a changed generation/epoch, revision gap or an incomplete baseline, invalidate dependent state and request the appropriate index resync. An empty incomplete index does not mean no positions or orders exist.

## Private control: channel 6, version 1

```json theme={null}
{
  "exchange_link_id":17,
  "topic":"positions",
  "revision":12,
  "is_snapshot":false,
  "snapshot_required":true,
  "server_ts":1893456000
}
```

When `snapshot_required` is true, do not continue making trading decisions from an assumed complete cache. Rebuild the appropriate canonical snapshot. `is_snapshot` and revisions describe state publication; neither is a command receipt or fill confirmation.

## Minimal decoder example

Download [decode\_stream.py](https://raw.githubusercontent.com/liftx-hq/docs/main/examples/api/decode_stream.py) for a bounded standard-library parser of the documented framing, balance and ticker wire formats. It does not open a connection, store credentials, reconstruct position FSMs or submit commands. JSON channels are returned as decoded values for a separate versioned application model.

The decoder retains fractional JSON numbers as Python `decimal.Decimal`, integers as exact Python integers and fixed-point coefficient strings as strings. It does not convert money through binary floating-point or turn a large finite exponent into floating-point infinity. Application schema validation must still reject values outside each field's documented bounds.

A transport decoder alone is not a production SDK. A real client must implement schema validation, exact-link checks, snapshot/revision rules, bounded queues, credential lifecycle, websocket ping/pong and reconciliation before trading from observed state.

## Reconnect and backpressure

Slow consumers are disconnected or instructed to rebuild snapshots under bounded backpressure. Reconnect with bounded backoff and jitter, discard obsolete session mappings, obtain fresh complete state and only then resume dependent decisions. A stream gap is not evidence that orders were canceled or that exposure is zero.

Use a single consumer state owner per connection. Coalesce related state updates; avoid spawning one exchange socket, polling loop or unconstrained worker for every position or strategy.


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