> 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/prices-data-and-asset-context/perpprices.md).

# perpPrices

Stream the accepted oracle and mark prices of perps, one message per block.

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

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

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 %}

The streaming counterpart of [perpPriceHistoryByTime](/readme/rest-api/asset-data/perppricehistorybytime.md): the prices the exchange actually uses for margining and liquidation, on every dex including HIP-3. A row here is identical to the row the REST endpoint returns for the same block.

### Subscribe

```json
{
    "method": "subscribe",
    "subscription": {
        "type": "perpPrices",
        "coins": ["BTC", "xyz:GOLD"]
    }
}
```

**Parameters:**

| Parameter | Type      | Required | Description                                                                                                   |
| --------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------- |
| `coins`   | string\[] | Yes      | Coins to subscribe to, 1 to 200 per request. Use the dex-prefixed symbol for HIP-3 markets (e.g. `xyz:GOLD`). |

{% hint style="info" %}
**Coins always merge.** A connection holds one `perpPrices` subscription. Subscribing again unions the new `coins` into it — the next block is one frame covering the merged set, with no unsubscribe/resubscribe gap. The ack echoes the request; re-sending coins you already hold is a no-op. The merged set is bounded by your tier's coin cap.

**The ack is the boundary.** The ack is delivered after every frame that was already in flight when the change committed, so every frame after the ack covers the new set. Apply a coin-set change when the ack arrives, not when the request is sent.
{% endhint %}

### Unsubscribe

Name coins to remove; the rest keep streaming without a gap. Omit `coins` to drop the whole subscription.

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

### Update data format

**A message is sent for every block** (\~70 ms), in block order, including blocks that carry no price round for your coins — `prices` is then `[]`. That lets you track the chain height and time continuously. Price rounds arrive roughly every 3 seconds per dex, so expect about one message in forty to carry rows.

```json
{
    "type": "perpPrices",
    "channel": "perpPrices",
    "seq": 42,
    "cursor": "1131737186:1788273972577",
    "data": {
        "height": 1131737186,
        "timestamp": 1788273972577,
        "prices": [
            {
                "time": 1788273972577,
                "blockNumber": 1131737186,
                "dex": "hyperliquid",
                "coin": "BTC",
                "oraclePx": "78356.00",
                "markPx": "78340.00",
                "extPerpPx": "78314.65"
            },
            {
                "time": 1788273972577,
                "blockNumber": 1131737186,
                "dex": "xyz",
                "coin": "xyz:GOLD",
                "oraclePx": "4365.80",
                "markPx": "4368.00",
                "extPerpPx": "4365.80",
                "updateClass": "Normal"
            }
        ]
    }
}
```

A block carries at most one row per coin: when a dex publishes more than one round in a block, the last one is the accepted price, exactly as the REST endpoint reports it.

### Knowing a time window is complete

You get one live message per block, in order. Each message shows how far we have processed the chain: price rounds for a coin, from the moment you subscribed to it up to that block, have normally all been sent. So you don't need to wait for the next price round to see that a window has passed.

**Example.** Your window ends at 16:30:00. The last BTC round was at 16:29:58. At 16:30:00.1 a message arrives with `timestamp` 16:30:00.1 and `prices: []`. We had processed the chain past 16:30:00 and saw no BTC round after 16:29:58, so you don't have to wait for the next round to see that.

Rules:

* **Fetch the start of the window if you subscribed during it.** Live messages only cover a coin from the subscribe ack that added it. Fetch the part of the window before that from [perpPriceHistoryByTime](/readme/rest-api/asset-data/perppricehistorybytime.md).
* **Look for `timestamp` past the window end.** Wait for a message with `timestamp` greater than the end, not equal to it. Several blocks can share the same millisecond.
* **Check that `height` goes up by exactly 1 between live messages.** If it jumps (for example from 100 to 103), blocks 101 and 102 were not delivered. Fetch that range from [perpPriceHistoryByTime](/readme/rest-api/asset-data/perppricehistorybytime.md). `seq` does not always show this gap.
* **After a reconnect**, resume with your last `cursor`. Replay carries every missed block (`prices: []` included), so the height check continues through the replay items and on into the live messages. Only if the replay reports `hasGap: true`, fetch the hole from [perpPriceHistoryByTime](/readme/rest-api/asset-data/perppricehistorybytime.md).

Good to know:

* `height` is the same for all coins and dexes, since a block is processed as a whole.
* No rows for a coin means no price round for it. For HIP-3 this also covers a deployer that stopped posting prices.
* A quiet market still sends messages, with `prices: []` and a rising `height`. If messages stop arriving, the feed has stalled.

### Field definitions

| Field                  | Type      | Description                                                                                                                                                      |
| ---------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `height`               | int       | Block height. Price rounds since you subscribed up to this block have normally all been sent, as long as `height` has gone up by 1 each live message (see above) |
| `timestamp`            | int       | Block timestamp of `height` (ms since epoch)                                                                                                                     |
| `prices`               | object\[] | The block's accepted rounds for your coins; empty when there were none                                                                                           |
| `prices[].time`        | int       | Block timestamp of the oracle round (ms) — equals `timestamp`                                                                                                    |
| `prices[].blockNumber` | int       | Height of the block that committed the round — equals `height`                                                                                                   |
| `prices[].dex`         | string    | DEX identifier (`"hyperliquid"` for the native dex)                                                                                                              |
| `prices[].coin`        | string    | Trading pair                                                                                                                                                     |
| `prices[].oraclePx`    | string?   | Accepted oracle price. Omitted when the round carried no oracle slot for the asset (the natively-priced HYPE/PURR miss the slot in \~2 rounds a day)             |
| `prices[].markPx`      | string    | Accepted mark price — the value used for margining, liquidations and funding. Always present                                                                     |
| `prices[].extPerpPx`   | string?   | External perp reference price; omitted for assets without one                                                                                                    |
| `prices[].updateClass` | string?   | HIP-3 only: how the network sourced the round as reported by the node (`"Deployer"`, `"Fallback"`, `"Normal"`). Omitted for the native dex                       |

### Reconnect replay

Subscribe with the `cursor` of the last message you received (`block:timestamp`) and the server first sends a `replay` message covering up to **30 seconds** of missed data, then the subscription ack, then live messages. Replay carries one item per missed block, including blocks with no round for your coins (`prices: []`), so `height` goes up by exactly 1 from your cursor through the replay and into the live messages. `hasGap: true` on the replay means the cursor is older than the cache: fill the hole with [perpPriceHistoryByTime](/readme/rest-api/asset-data/perppricehistorybytime.md), whose rows are identical to these. The replay message format is described in [Session management and reconnection](/readme/websocket/session-management-and-reconnection.md).

### Limits

`perpPrices` is included in every tier. Your tier's coin cap bounds the coins per API key (10/100/200 for starter/growth/scale; higher bespoke limits are available — contact us), and each subscribed coin counts as one subscription toward your tier's total. A single request may name at most 200 coins.

### 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: 'perpPrices',
            coins: ['BTC', 'xyz:GOLD']
        }
    }));
});

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

    if (msg.type === 'ping') {
        ws.send(JSON.stringify({ method: 'pong' }));
    } else if (msg.type === 'perpPrices') {
        const { height, prices } = msg.data;
        // Every block arrives; prices is empty unless the block carried a round.
        for (const row of prices) {
            console.log(`Height ${height}: ${row.coin} mark ${row.markPx} oracle ${row.oraclePx ?? 'n/a'}`);
        }
    }
});
```

{% 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') == 'perpPrices':
        data = msg['data']
        # Every block arrives; prices is empty unless the block carried a round.
        for row in data['prices']:
            print(f"Height {data['height']}: {row['coin']} mark {row['markPx']} oracle {row.get('oraclePx')}")

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

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). A well-formed but unknown or inactive coin succeeds with an `inactive_coins` warning and simply never produces rows.

1. `coins required for perpPrices subscription` - Send at least one coin; there is no all-markets form yet
2. `Too many coins` - More than 200 coins in one request, or the merge would push the set past your tier's coin cap; the existing subscription is left unchanged
3. `Rate limit exceeded` - Reduce subscription frequency
4. `Authentication failed` - Check API key
