> 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/orderbook-streaming/l4bookupdates.md).

# l4BookUpdates

Stream real-time L4 orderbook updates per block.

Every live data message includes both routing identifiers with the same value:

```json
{"type": "l4BookUpdates", "channel": "l4BookUpdates"}
```

Other payload fields are omitted above. Existing clients may continue routing on either field.

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

### Subscribe

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

**Parameters:**

| Parameter     | Type      | Required | Description                                                                                                                                                                                                                                                    |
| ------------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `coins`       | string\[] | No       | Coins to subscribe to. Omit for all markets (needs **l4BookUpdatesAll** add-on).                                                                                                                                                                               |
| `marketTypes` | string\[] | No       | All-markets only. Filters delivery by market type — see [All-markets filter](#all-markets-filter). Rejected if combined with `coins`.                                                                                                                          |
| `merge`       | bool      | No       | Default `false`. When `true`, union these `coins` into the connection's existing coin-list `l4BookUpdates` slots instead of opening a new stream. Subscribe-only — rejected on an `unsubscribe`, on the all-markets firehose, and on other subscription types. |

{% hint style="info" %}
**Adding a coin without a gap is opt-in.** Omit `merge` (or send `false`) and each subscribe is its own stream: `coins:["ETH"]` then `coins:["BTC"]` is two frames per block. Set `"merge": true` to union the new coins into the stored coin-list set. The next block is one frame covering the merged set — no unsubscribe/resubscribe cycle, and no missed or split block. The subscribe ack echoes the **merged** coin list. Re-sending coins you already hold is a no-op.

**The ack is the boundary.** Each frame carries the coin set its block was fanned out with, and the ack is delivered after every frame that was already in flight when the subscribe committed. So a frame arriving *before* the ack may or may not cover a coin that ack names — that block was already being built — while every frame *after* it does. Apply a coin-set change when the ack arrives, not when the request is sent.

The server holds the ack for a bounded interval only. If fan-out stalls past it (server-side contention; rare, and alerted on), the ack is released early — and **every** block still being fanned out at that moment can deliver a pre-change frame after it, not just one. Blocks are fanned out concurrently, so treat the boundary as the rule and a run of frames missing a just-added coin as possible-but-exceptional. It resolves once those blocks drain; the coin appears from the first block whose fan-out began after the change.

Two separate limits apply. A single subscribe request may name at most **200** coins, and your **tier's coin cap** bounds the merged set when `merge` is true. A subscribe that would push the merged set past your tier cap is rejected and the set is left unchanged.

When a subscribe carries a `cursor`, replay covers only the coins named in **that request**. After a merge the ack still echoes the full merged list, so it will name more coins than the replay covers.

If the connection holds **one** coin-list subscription (a single batched subscribe, or the result of `merge: true`), `unsubscribe` with `coins` **subtracts** those coins; the rest keep streaming. Coins you do not hold are ignored. If the connection holds **several** coin-list subscriptions, unsubscribe is exact-match — it removes the slot whose coin set matches, and does not collapse the others.

The all-markets firehose (`coins` omitted) still **coexists** with a coin-list subscription on the same connection — overlapping coins are delivered twice. That is unchanged. A coin-scoped unsubscribe against *only* the firehose is rejected (`all minus ETH` is not representable). A bare `{type:"l4BookUpdates"}` unsubscribe removes the firehose only; it does **not** clear the coin list.
{% endhint %}

{% hint style="warning" %}
**Planned change: `merge` will become the default.** Today a coin-list subscribe defaults to `merge: false`, so a second subscribe on the same connection opens a second stream. In a future release that default flips to `merge: true`: repeated coin-list subscribes will union into one stream delivering a single frame per block, and `unsubscribe` with `coins` will subtract from it.

**What to do now — set `merge` explicitly on every coin-list subscribe.** `"merge": true` opts into the future behaviour today. `"merge": false` pins the current behaviour and keeps working after the flip. Only clients that send neither will change behaviour.

The flag is not being removed; it stays accepted after the default changes.

You are unaffected either way if you already batch every coin into a single subscribe, or open one connection per coin list. The change only alters what happens when **one connection subscribes to coin lists more than once**.
{% endhint %}

### 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": "l4BookUpdates",
        "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

Name coins to remove. With one coin-list slot those coins are subtracted (remaining coins keep streaming, gap-free). With several coin-list slots the request must match one slot's coin set exactly:

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

Omit `coins` to drop the all-markets firehose only. That does **not** remove a coin-list subscription. To clear the coin-list, unsubscribe the coins you hold (the last coin drops the subscription).

The same boundary rule applies in reverse: a block whose fan-out began before the unsubscribe committed still carries the coin set it started with, and that frame is delivered **before** the unsubscribe ack. No frame after the ack contains the dropped coins.

### Update data format

Updates are streamed per-block with order changes for subscribed markets:

```json
{
    "type": "l4BookUpdates",
    "channel": "l4BookUpdates",
    "seq": 1,
    "cursor": "500:1704067200000",
    "data": {
        "height": 782007304,
        "timestamp": 1704067200000,
        "diffs": [
            {
                "type": "new",
                "coin": "ETH",
                "oid": 217148811876,
                "user": "0x742d35cc6634c0532925a3b844bc9e7595f7f2e2",
                "side": "B",
                "px": "3245.50",
                "sz": "1.5"
            },
            {
                "type": "new",
                "coin": "ETH",
                "oid": 217148811877,
                "user": "0x8f3c55aa1122c0532925a3b844bc9e7595f7f9a1",
                "side": "B",
                "px": "3245.50",
                "sz": "0.8",
                "insertBefore": 217148811876
            },
            {
                "type": "update",
                "coin": "ETH",
                "oid": 217148811800,
                "sz": "2.0"
            },
            {
                "type": "remove",
                "coin": "ETH",
                "oid": 217148811750
            }
        ]
    }
}
```

### Diff types

| Type     | Description                       | Fields                                                               |
| -------- | --------------------------------- | -------------------------------------------------------------------- |
| `new`    | New order placed                  | `coin`, `oid`, `user`, `side`, `px`, `sz`, `insertBefore` (optional) |
| `update` | Order size changed (partial fill) | `coin`, `oid`, `sz`                                                  |
| `remove` | Order removed (filled/cancelled)  | `coin`, `oid`                                                        |

### Field definitions

| Field          | Type                | Description                                                                                                                                                                                                                                                                 |
| -------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `height`       | int                 | Block height                                                                                                                                                                                                                                                                |
| `timestamp`    | int                 | Block timestamp (ms since epoch)                                                                                                                                                                                                                                            |
| `coin`         | string              | Market symbol (e.g., "ETH", "BTC")                                                                                                                                                                                                                                          |
| `oid`          | int                 | Unique order ID                                                                                                                                                                                                                                                             |
| `user`         | string              | User address (0x...)                                                                                                                                                                                                                                                        |
| `side`         | string              | "B" = bid, "A" = ask                                                                                                                                                                                                                                                        |
| `px`           | string              | Price as decimal string                                                                                                                                                                                                                                                     |
| `sz`           | string              | Size as decimal string                                                                                                                                                                                                                                                      |
| `insertBefore` | int (oid), optional | On `new` diffs only: insert this order into its price level's queue immediately **before** the resting order with this oid (Hyperliquid ALO priority placement). Absent = append to the tail of the queue. If the target oid is no longer at the level, append to the tail. |

### Examples

{% tabs %}
{% tab title="JavaScript" %}

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

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

ws.on('open', () => {
    ws.send(JSON.stringify({
        method: 'subscribe',
        subscription: {
            type: 'l4BookUpdates',
            coins: ['ETH', 'BTC']
        }
    }));
});

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

    if (msg.type === 'ping') {
        ws.send(JSON.stringify({ method: 'pong' }));
    } else if (msg.type === 'l4BookUpdates') {
        const { height, diffs } = msg.data;
        console.log(`Height ${height}: ${diffs.length} order changes`);
    }
});
```

{% endtab %}

{% tab title="Python" %}

```python
import websocket
import json
import os

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

    if msg.get('type') == 'ping':
        ws.send(json.dumps({'method': 'pong'}))
    elif msg.get('type') == 'l4BookUpdates':
        data = msg['data']
        print(f"Height {data['height']}: {len(data['diffs'])} order changes")

def on_open(ws):
    ws.send(json.dumps({
        "method": "subscribe",
        "subscription": {
            "type": "l4BookUpdates",
            "coins": ["ETH", "BTC"]
        }
    }))

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

{% endtab %}
{% endtabs %}

### Common errors

The subscription feedback and warning shapes are documented in the [WebSocket overview](/readme/websocket.md#correlating-subscription-feedback). Invalid names are rejected together, for example `invalid coin name(s): ["BTC-USD","ETH USD"]`. More than 200 names in one request returns `too_many_coins` with `requested` and `maximum` details. A well-formed inactive name succeeds with an `inactive_coins` warning instead of silently producing no data.

1. `Too many coins` - Either one request named more than 200 coins, or a `merge: true` subscribe would push the stored set past your tier's coin cap. Both leave the existing subscription unchanged.
2. `Subscribing to all markets requires permission` - Needs add-on for all books
3. `Rate limit exceeded` - Reduce subscription frequency
4. `Authentication failed` - Check API key
