> 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), including blocks that carry no price round for your coins — `prices` is then `[]`. That lets you track the chain height and time continuously, and `seq` is gap-free per connection so a missed message is detectable. 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.

### Field definitions

| Field                  | Type      | Description                                                                                                                                          |
| ---------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `height`               | int       | Block height                                                                                                                                         |
| `timestamp`            | int       | Block timestamp (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 block that had a round for your coins — the empty tick messages are not replayed; take the chain height from your first live message. `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
