> For the complete documentation index, see [llms.txt](https://docs.hydromancer.xyz/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.hydromancer.xyz/readme/websocket/l2bookdiff.md).

# l2BookDiff

{% hint style="info" %}
New endpoint - this endpoint is not a part of original Hyperliquid API and is added by us for builder convenience.
{% endhint %}

Stream incremental L2 orderbook changes as they happen. Each message contains all changed price levels across all subscribed coins for a single block, making this far more bandwidth-efficient than full snapshots.

For full snapshots, use [`l2Book`](/readme/websocket/l2book.md). For just top-of-book, use [`bbo`](/readme/websocket/bbo.md).

### Subscribe

```json
{
    "method": "subscribe",
    "subscription": {
        "type": "l2BookDiff",
        "coins": ["ETH", "BTC"]
    }
}
```

**Parameters:**

| Parameter     | Type      | Required | Description                                                                                                                           |
| ------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `coins`       | string\[] | No       | Coins to subscribe to. Omit for all markets (requires `ws:l2BookDiffAll` permission).                                                 |
| `marketTypes` | string\[] | No       | All-markets only. Filters delivery by market type — see [All-markets filter](#all-markets-filter). Rejected if combined with `coins`. |

### All-markets filter

When `coins` is omitted, the optional `marketTypes` field restricts the firehose to specific market types. Each entry is `"perp"`, `"spot"`, `"outcome"`, or the wildcard `"*"` (alone) for "every type the server currently tracks":

```json
{
    "method": "subscribe",
    "subscription": {
        "type": "l2BookDiff",
        "marketTypes": ["perp", "outcome"]
    }
}
```

Omitting `marketTypes` defaults to `["perp"]` — outcome and spot markets do **not** appear unless you opt in. The default never grows; new market types must be added to your `marketTypes` array explicitly. Pass `["*"]` to auto-opt-in to future types.

A second subscribe with a different `marketTypes` value **replaces** the previous filter rather than coexisting with it.

### Unsubscribe

```json
{
    "method": "unsubscribe",
    "subscription": {
        "type": "l2BookDiff",
        "coins": ["ETH", "BTC"]
    }
}
```

### Update data format

Each message contains all changed levels for your subscribed coins in a single block. A message is sent **only for blocks that change something you're subscribed to** — quiet blocks are skipped, so `height` is sparse (not contiguous). See [Sequencing and gap detection](#sequencing-and-gap-detection). Levels with `sz: "0"` indicate that price level has been removed from the book.

```json
{
    "type": "l2BookDiff",
    "channel": "l2BookDiff",
    "seq": 42,
    "cursor": "782007304:1704067200000",
    "data": {
        "height": 782007304,
        "time": 1704067200000,
        "diffs": [
            {
                "coin": "ETH",
                "epoch": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
                "seq": 108,
                "prev_seq": 107,
                "levels": [
                    [
                        {"px": "3245.5", "sz": "12.4", "n": 3},
                        {"px": "3244.0", "sz": "0", "n": 0}
                    ],
                    [
                        {"px": "3246.0", "sz": "8.1", "n": 2}
                    ]
                ]
            },
            {
                "coin": "BTC",
                "epoch": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
                "seq": 55,
                "prev_seq": 54,
                "levels": [
                    [{"px": "68605", "sz": "96.9", "n": 5}],
                    []
                ]
            }
        ]
    }
}
```

### Resync signal

When the server detects a block-height gap (e.g., after a service restart, snapshot resync, or stream interruption), it emits a resync message. Discard the affected local book(s) and re-bootstrap from a REST snapshot. A height gap sends **one resync per coin for every coin you're subscribed to** (not just the coin that gapped) — a gap usually coincides with an `epoch` change, which invalidates every book.

```json
{
    "type": "l2BookDiff",
    "channel": "l2BookDiff",
    "data": {
        "type": "resync",
        "coin": "ETH",
        "reason": "height_gap",
        "new_epoch": "f9e8d7c6-b5a4-3210-fedc-ba9876543210"
    }
}
```

{% hint style="warning" %}
**Also watch `epoch` yourself — don't rely on the resync message alone.** The resync is driven by height-gap detection, and there is a narrow window (right after the server (re)connects to its diff stream) where a new `epoch` can appear on diffs **without** a preceding resync. A robust client treats *any* change in a coin's `epoch` — with or without a resync message — as "re-bootstrap that coin from a REST snapshot." See [Sequencing and gap detection](#sequencing-and-gap-detection).
{% endhint %}

### Field reference

**Envelope fields** (on every message):

| Field    | Type   | Description                                                                                                                                                                                                                                                    |
| -------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `seq`    | number | **Per-connection** message counter — +1 on every message, across all coins. A jump > 1 means you missed a message. Not per-coin, and **not** related to the per-coin `seq` inside `diffs` (see [Sequencing and gap detection](#sequencing-and-gap-detection)). |
| `cursor` | string | Cursor for session reconnection replay (format: `height:timestamp`)                                                                                                                                                                                            |

**Batch data fields** (`data`):

| Field    | Type   | Description                                                     |
| -------- | ------ | --------------------------------------------------------------- |
| `height` | number | Block height                                                    |
| `time`   | number | Block timestamp (milliseconds since epoch)                      |
| `diffs`  | array  | Array of per-coin diffs (only coins with changes in this block) |

**Per-coin diff fields** (each item in `diffs`):

| Field           | Type   | Description                                                            |
| --------------- | ------ | ---------------------------------------------------------------------- |
| `coin`          | string | Market symbol (e.g., "ETH", "BTC")                                     |
| `epoch`         | string | Server epoch (UUID) — changes on service restart                       |
| `seq`           | number | Per-coin sequence number — increments by 1 for each diff for this coin |
| `prev_seq`      | number | Previous per-coin sequence number (for gap detection)                  |
| `levels`        | array  | Tuple of `[bids, asks]` — only changed levels are included             |
| `levels[][].px` | string | Price as decimal string                                                |
| `levels[][].sz` | string | Size as decimal string (`"0"` means level removed)                     |
| `levels[][].n`  | number | Number of orders at this level (`0` when level removed)                |

**Resync data fields** (`data` when `type` is `"resync"`):

| Field       | Type   | Description                                |
| ----------- | ------ | ------------------------------------------ |
| `type`      | string | `"resync"`                                 |
| `coin`      | string | Market symbol                              |
| `reason`    | string | Why resync occurred (e.g., `"height_gap"`) |
| `new_epoch` | string | The new server epoch after restart         |

### Sequencing and gap detection

Every `l2BookDiff` message carries **two independent sequence numbers** plus a block `height`. They do different jobs — a robust client tracks both seqs:

**Per message — envelope `seq` (connection health).** The top-level `seq` increments by 1 on every message your subscription receives, across all coins. If it jumps by more than 1, you missed a message on this connection. It is local to your connection (two clients see different values for the same block) and says nothing about any single coin's book.

**Per coin — `data.diffs[].seq` / `prev_seq` (book integrity).** Each coin carries its own `seq` and `prev_seq`. For each coin, the next diff's `prev_seq` must equal the last `seq` you applied for that coin; a mismatch means you missed a diff for that coin — re-bootstrap it from [`l2BookDiffSnapshot`](/readme/rest-api/market-data/l2bookdiffsnapshot.md). This value is global (identical for every client).

The two are **not relatable** — one envelope step bumps each *included* coin by 1, but which coins appear varies per block, so you cannot derive one from the other. Track them separately.

**`height` (block position — sparse).** `data.height` is the block height. Messages are sent only for blocks that change something you're subscribed to, so **`height` skips blocks; gaps are normal, not a loss.** Never use `height + 1` as a completeness check — use the envelope `seq` for that. `height` is for aligning a REST snapshot only (discard diffs where `height <= snapshot.height`).

| Field              | Location             | Scope             | Gapless?             | Use it to detect                            |
| ------------------ | -------------------- | ----------------- | -------------------- | ------------------------------------------- |
| `seq`              | top-level (envelope) | per connection    | yes — +1 per message | a dropped message                           |
| `seq` / `prev_seq` | `data.diffs[]`       | per coin (global) | yes — per coin       | a missed diff for that coin                 |
| `height`           | `data`               | block             | no — sparse          | (snapshot alignment only, not completeness) |

### Building a local orderbook

Align the snapshot to the stream on **`height`** (always present and shared by both), then use the per-coin `seq`/`prev_seq` for ongoing gap detection — see [Sequencing and gap detection](#sequencing-and-gap-detection).

1. **Subscribe** to `l2BookDiff` for your coin(s) — start buffering messages.
2. **Fetch snapshot** via the REST [`l2BookDiffSnapshot`](/readme/rest-api/market-data/l2bookdiffsnapshot.md) endpoint. Note `height`, `epoch`, and the per-coin `seq`.
3. **Discard already-applied diffs**: drop any buffered batch whose `data.height <= snapshot.height`.
4. **Verify `epoch`**: every remaining diff's `epoch` must equal the snapshot's `epoch`. A different `epoch` means the server restarted after the snapshot — re-fetch and restart from step 1.
5. **Apply remaining diffs** in `height` order. The first diff for each coin should have `prev_seq == snapshot.seq` (the per-coin `seq`, not the envelope `seq`):
   * For each level in `bids` and `asks`:
     * If `sz` is `"0"`, remove that price level.
     * Otherwise, upsert the price level with the new `sz` and `n`.
6. **Ongoing gap detection**: per coin, check `prev_seq == your last applied seq for that coin` on each diff; on a mismatch, re-bootstrap that coin. Optionally watch the envelope `seq` for dropped messages.
7. **Resync**: if you receive a resync message, discard local state and re-bootstrap from step 1.

### Examples

{% tabs %}
{% tab title="Python (SDK)" %}

```python
import asyncio
from hydromancer_sdk import L2BookClient

def on_update(client, height):
    for coin in client.get_coins():
        book = client.get_book(coin)
        bid = book.best_bid()
        ask = book.best_ask()
        print(f"{coin} @ {height}: {bid.px if bid else 'n/a'} / {ask.px if ask else 'n/a'}")

async def main():
    client = L2BookClient(coins=["ETH", "BTC"], on_update=on_update)
    await client.run()

asyncio.run(main())
```

{% endtab %}

{% tab title="JavaScript" %}

```javascript
const WebSocket = require('ws');

const ws = new WebSocket(`wss://api.hydromancer.xyz/ws?token=${process.env.HYDROMANCER_API_KEY}`);

// Per-coin state: { epoch, seq }
const coinState = {};

ws.on('message', (raw) => {
    const msg = JSON.parse(raw);

    if (msg.type === 'connected') {
        ws.send(JSON.stringify({
            method: 'subscribe',
            subscription: { type: 'l2BookDiff', coins: ['ETH', 'BTC'] }
        }));
    } else if (msg.type === 'ping') {
        ws.send(JSON.stringify({ method: 'pong' }));
    } else if (msg.type === 'l2BookDiff') {
        const batch = msg.data;

        // Handle resync
        if (batch.type === 'resync') {
            console.log(`Resync for ${batch.coin}: ${batch.reason}`);
            delete coinState[batch.coin];
            return;
        }

        for (const diff of batch.diffs) {
            // Check for gaps
            const state = coinState[diff.coin];
            if (state && (diff.epoch !== state.epoch || diff.prev_seq !== state.seq)) {
                console.log(`Gap for ${diff.coin}, re-bootstrapping`);
                delete coinState[diff.coin];
                continue;
            }

            coinState[diff.coin] = { epoch: diff.epoch, seq: diff.seq };

            const [bidChanges, askChanges] = diff.levels;
            console.log(`${diff.coin} @ ${batch.height}: ${bidChanges.length} bid, ${askChanges.length} ask changes`);
        }
    }
});
```

{% endtab %}

{% tab title="Python (raw)" %}

```python
import websocket
import json
import os

coin_state = {}

def on_message(ws, message):
    msg = json.loads(message)

    if msg['type'] == 'connected':
        ws.send(json.dumps({
            "method": "subscribe",
            "subscription": { "type": "l2BookDiff", "coins": ["ETH", "BTC"] }
        }))
    elif msg['type'] == 'ping':
        ws.send(json.dumps({'type': 'pong'}))
    elif msg['type'] == 'l2BookDiff':
        batch = msg['data']

        if batch.get('type') == 'resync':
            print(f"Resync for {batch['coin']}: {batch['reason']}")
            coin_state.pop(batch['coin'], None)
            return

        for diff in batch['diffs']:
            coin = diff['coin']
            state = coin_state.get(coin)
            if state and (diff['epoch'] != state['epoch'] or diff['prev_seq'] != state['seq']):
                print(f"Gap for {coin}, re-bootstrapping")
                del coin_state[coin]
                continue

            coin_state[coin] = {'epoch': diff['epoch'], 'seq': diff['seq']}
            bid_changes, ask_changes = diff['levels']
            print(f"{coin} @ {batch['height']}: {len(bid_changes)} bid, {len(ask_changes)} ask changes")

ws = websocket.WebSocketApp(
    f"wss://api.hydromancer.xyz/ws?token={os.environ.get('HYDROMANCER_API_KEY')}",
    on_message=on_message
)
ws.run_forever()
```

{% endtab %}
{% endtabs %}

### Common errors

1. `Too many coins` - Reduce number of coins or upgrade tier
2. `Subscribing to all markets requires permission` - Needs `ws:l2BookDiffAll` add-on
3. `Rate limit exceeded` - Reduce subscription frequency
4. `Authentication failed` - Check API key
