# WebSockets

<figure className="page-header-image">
  <img src="/images/headers/websockets.png" alt="WebSockets" />
</figure>

The WebSocket gateway provides real-time streaming access to market data and trading updates on the Meridian exchange. Clients subscribe to channels over a persistent connection and receive plain JSON payloads with short-form keys, optimized for low latency, minimal overhead, and fast parsing.

| URL                                    | Status  |
| -------------------------------------- | ------- |
| `wss://ws2.ethereal.trade/v1/stream`   | Mainnet |
| `wss://ws2.etherealtest.net/v1/stream` | Testnet |

:::info
Meridian docs focus on the native WebSocket gateway. For historical Socket.IO documentation, reference [docs.ethereal.trade](https://docs.ethereal.trade).
:::

### Subscription

The subscription gateway offers multiple data streams for real-time and periodic updates. Some channels e.g. `OrderUpdate` push messages immediately as they occur, while others e.g. `L2Book` emit messages at fixed intervals. Payload formats and message shapes are documented below.

#### Connection Behavior

Connections have a maximum lifetime of approximately **4 hours**, after which they are closed with ***code 1000***. Clients are encouraged to implement automatic reconnection to handle this.

Idle connections with no message activity between both parties are closed after a period of inactivity with ***code 1006***. It is the *responsibility of the client to send WebSocket ping frames periodically* to prevent idle disconnects (we recommend once every 30s).

```javascript
// Example client-side heartbeat (javascript)
const WebSocket = require('ws')
const ws = new WebSocket('wss://ws2.ethereal.trade/v1/stream')

let pingInterval

ws.on('open', () => {
  pingInterval = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.ping()
    }
  }, 30_000)
})

ws.on('close', () => {
  clearInterval(pingInterval)
})
```

:::warning
There is a per-connection limit on subaccount subscriptions.

Each `(subaccountId, streamType)` pair counts as one subscription. Market data channels (`L2Book`, `Ticker`, `TradeFill`) are not subject to this limit.

Exceeding the limit returns `{ ok: false, code: "SUBSCRIPTION_LIMIT_EXCEEDED" }`
:::

#### `L2Book`

Provides L2 book depth updates for a specific product.

```json
// Subscription message payload
{
  "event": "subscribe",
  "data": {
    "type": "L2Book",
    "symbol": "<string>" // e.g. "BTCUSD", "ETHUSD"
  }
}

// Response message
{
  "e": "L2Book",
  "t": <epoch>,
  "data": {
    "s": "<string>",
    "t": <epoch>,
    "pt": Optional<epoch>,
    "a": [[price: string, quantity: string]],
    "b": [[price: string, quantity: string]]
  }
}
```

**`L2_BOOK`** events are emitted on a configurable fixed interval (as of writing, this is configured to be *once every 200ms*).

* `e` - event name
* `t` - server timestamp (epoch in milliseconds)
* `data` - L2 Book price levels details
  * `s` - symbol e.g. BTCUSD
  * `t` - calculated book timestamp (epoch in milliseconds)
  * `pt` - previous calculated book timestamp (epoch in milliseconds) - optional
    * Using both the `pt` and `data.t` you can infer whether or not any events were missed during connection or during consumption
  * `a` - asks, array of `[price, qty]` pairs
  * `b` - bids, array of `[price, qty]` pairs

:::warning
A `L2Book` message of the current book **(up to 100 price levels per side)** is emitted back as an initial snapshot on connection. Every subsequent message is a price level diff with absolute quantities. A zero quantity price diff indicates that this level has been removed.
:::

#### `TICKER`

Delivers real-time ticker data feeds for a specified product.

```json
// Subscription message payload
{
  "event": "subscribe",
  "data": {
    "type": "Ticker",
    "symbol": "<string>" // e.g. "BTCUSD", "ETHUSD"
  }
}

// Response message
{
  "e": "Ticker",
  "t": <epoch>,
  "data": {
    "s": "<string>",
    "t": <epoch>,
    "bidPx": "Optional<string>",
    "askPx": "Optional<string>",
    "bidAmt": "Optional<string>",
    "askAmt": "Optional<string>",
    "markPx": "Optional<string>",
    "markPx24h": "Optional<string>",
    "oi": "Optional<string>",
    "fr1h": "Optional<string>",
    "vol24h": "Optional<string>"
  }
}
```

**`TICKER`** events are emitted on a configurable fixed interval (currently configured to be *once every second*).

* `e` - event name `Ticker`
* `t` - server timestamp this message was emitted at (epoch in milliseconds)
* `data` - Real time ticker data
  * `s` - symbol e.g. BTCUSD
  * `t` - calculated best bid / ask book timestamp
  * `bidPx` - best bid price
  * `askPx` - best ask price
  * `bidAmt` - total quantity at the best bid
  * `askAmt` - total quantity at the best ask
  * `markPx` - current mark price
  * `markPx24h` - 24h mark price
  * `oi` - open interest
  * `fr1h` - projected funding rate at the end of the hour
  * `vol24h` - past 24 hours volume

:::warning
In extreme cases, **`Ticker`** will skip publishing if **both** **`bidPx`** & **`askPx`** are not available. All values are returned as decimals (9 precision).
:::

#### `TRADE_FILL`

Provides a stream of trades that have occurred filtered by product.

```json
// Subscription message payload
{
  "event": "subscribe",
  "data": {
    "type": "TradeFill",
    "symbol": "<string>" // e.g. "BTCUSD", "ETHUSD"
  }
}

// Response message
{
    "e": "TradeFill",
    "t": <epoch>,
    "data": {
        "s": "<symbol>",
        "t": <epoch>,
        "d":[{
            "id": "<uuid>",
            "px": "<string>",
            "sz": "<string>",
            "sd": 0|1,
            "sids": ["<uuid>", "<uuid>"]
        }]
    }
}
```

**`TRADE_FILL`** events are streamed in real-time as they occur and from the perspective of the taker (i.e. `sz`, `sd`).

* `e` - event name
* `t` - server timestamp this message was emitted at (epoch in milliseconds)
* `data`
  * `s` - symbol of product traded
  * `t` - timestamp trade fills happened (epoch in milliseconds)
  * `d` - array of fills that occurred on the product
    * `s` - symbol e.g. BTCUSD where the trade fill occurred on
    * `id` - trade fill identifier
    * `px` - execution price
    * `sz` - quantity traded
    * `sd` - side (`0=BUY` or `1=SELL`) from the perspective of the taker
    * `sids` - tuple of the taker subaccount id and the maker subaccount id

#### `SUBACCOUNT_LIQUIDATION`

Provides an update when a subaccount is liquidated.

```json
// Subscription message payload
{
  "event": "subscribe",
  "data": {
    "type": "SubaccountLiquidation",
    "subaccountId": "<uuid>"
  }
}

// Response message
{
  "e": "SubaccountLiquidation",
  "t": <epoch>,
  "data": {
    "sid": "<uuid>",
    "t": <epoch>,
    "d": [
      {
        "s": "<string>",
        "px": "<string>",
        "sz": "<string>"
      }
    ]
  }
}
```

When a subaccount is liquidated, all positions are transferred to the insurance fund and derisked at a later time. `SubaccountLiquidation` events are emitted in real-time.

* `e` - event name
* `t` - server timestamp this message was emitted at (epoch in milliseconds)
* `data` - Liquidation subaccount data
  * `sid` - `id` of liquidated sub-account
  * `t` - timestamp sub-account was liquidated (epoch in miliseconds)
  * `d` - An array of positions liquidated (subaccount may have one or many positions at liquidation):
    * `price` - mark price at the time of liquidation
    * `sz` - position size at liquidation (positive of long, negative if short)

#### **`POSITION_UPDATE`**

Provides real-time updates to open positions for a specific subaccount.

```json
// Subscription message payload
{
  "event": "subscribe",
  "data": {
    "type": "PositionUpdate",
    "subaccountId": "<uuid>"
  }
}

// Response message
{
  "e": "PositionUpdate",
  "t": <epoch>,
  "data": {
    "t": <epoch>,
    "d": [
      {
        "id": "<uuid>",
        "sid": "<uuid>",
        "s": "<string>",
        "sd": 0|1,
        "sz": "<string>",
        "cost": "<string>",
        "rpnl": "<string>",
        "fpnl": "<string>",
        "fee": "<string>",
        "lpx": "Optional<string>"
      }
    ]
  }
}
```

**`POSITION_UPDATE`** events are emitted in real-time, published per-subaccount whenever a position is opened, increased/reduced, or closed.

* `e` - event name
* `t` - server timestamp (epoch in milliseconds)
* `data` - position update details
  * `t` - update timestamp (epoch in milliseconds)
  * `d` - array of position updates
    * `id` - position ID (UUID)
    * `sid` - subaccount ID (UUID)
    * `s` - ticker symbol (e.g. ETHUSD or BTCUSD)
    * `sd` - position side (BUY or SELL)
    * `sz` - position size
    * `cost` - position value in USD (`quantity * average entry price`)
    * `rpnl` - realized PnL in USD
    * `fpnl` - funding in USD (charged and applied to position, negative if paid)
    * `fee` - fees accrued in USD
    * `lpx` - liquidation price (only set if liquidated)

:::info
Funding charges do not trigger `PositionUpdate` events.
:::

#### **`ORDER_UPDATE`**

Provides updates about order status changes for a specific subaccount.

```json
// Subscription message payload
{
  "event": "subscribe",
  "data": {
    "type": "OrderUpdate",
    "subaccountId": "<uuid>"
  }
}

// Response message
{
  "e": "OrderUpdate",
  "t": <epoch>,
  "data": {
    "t": <epoch>,
    "d": [
      {
        "id": "<uuid>",
        "cloid": "Optional<string>",
        "otyp": "LIMIT" | "MARKET",
        "qty": "<string>",
        "aqty": "<string>",
        "fill": "<string>",
        "px": "Optional<string>",
        "sd": 0|1,
        "s": "<string>",
        "sid": "<uuid>",
        "sn": "<string>",
        "st": "<string>",
        "t": <epoch>,
        "ro": boolean,
        "cl": boolean,
        "tif": "Optional<string>",
        "et": <epoch>,
        "po": Optional<boolean>,
        "spx": "Optional<string>",
        "styp": Optional<number>,
        "spxtyp": Optional<number>,
        "tr": "<string>",
        "gtyp": Optional<number>,
        "gid": "Optional<uuid>",
        "rr": "Optional<string>"
      }
    ]
  }
}
```

**`ORDER_UPDATE`** events are emitted in real-time, published per-subaccount whenever an order's state changes.

* `e` - event name
* `t` - server timestamp (epoch in milliseconds)
* `data` - order update details
  * `t` - update timestamp (epoch in milliseconds)
  * `d` - array of order updates
    * `id` - order ID (UUID)
    * `cloid` - client order ID
    * `otyp` - order type
    * `qty` - original quantity
    * `aqty` - available (remaining) quantity
    * `fill` - filled amount
    * `px` - limit price - optional, omitted for market orders
    * `sd` - side (`BUY=0`, `SELL=1`)
    * `s` - symbol e.g. BTCUSD
    * `sid` - subaccount ID (UUID)
    * `sn` - sender (signer EVM address)
      * Account or linked signer address that originally placed this order
    * `st` - order status (enum, same status as `OrderDto.status`)
      * One of: `NEW, PENDING, FILLED_PARTIAL, FILLED, REJECTED, CANCELED, EXPIRED`
    * `t` - order created timestamp (epoch in milliseconds)
    * `ro` - reduce only (boolean)
    * `cl` - close (boolean)
    * `tif` - time in force
    * `et` - expires at (epoch in seconds)
    * `po` - post only
    * `spx` - stop price
    * `styp` - stop type
    * `spxtyp` - stop price type
    * `tr` - triggered state
    * `gtyp` - group contingency type
    * `gid` - group ID (UUID)
    * `rr` - reason the order was rejected e.g. `CausesImmediateLiquidation`, `OrderIncreasesPosition`, `MarketOrderReachedMaxSlippage`, etc.
      * See: `OrderDto.rejectedReason` for the full list of possible values

#### `ORDER_FILL`

Notifies when orders are filled for a specific subaccount.

```json
// Subscription message payload
{
  "event": "subscribe",
  "data": {
    "type": "OrderFill",
    "subaccountId": "<uuid>"
  }
}

// Response message
{
  "e": "OrderFill",
  "t": <epoch>,
  "data": {
    "t": <epoch>,
    "d": [
      {
        "id": "<uuid>",
        "oid": "<uuid>",
        "cloid": "Optional<string>",
        "px": "<string>",
        "sz": "<string>",
        "typ": "LIMIT" | "MARKET",
        "sd": 0|1,
        "s": "<string>",
        "sid": "<uuid>",
        "ro": boolean,
        "fee": "<string>",
        "m": boolean,
        "t": <epoch>
      }
    ]
  }
}
```

**`ORDER_FILL`** events are emitted in real-time as they occur, published per-subaccount whenever an order is filled (both maker and taker sides receive their own event).

* `e` - event name
* `t` - server timestamp (epoch in milliseconds)
* `data` - order fill details
  * `t` - fill timestamp (epoch in milliseconds)
  * `d` - array of order fills
    * `id` - fill ID (UUID)
    * `oid` - order ID (UUID)
    * `cloid` - client order ID - optional
    * `px` - fill price
    * `sz` - filled quantity
    * `typ` - order type
    * `sd` - side
    * `s` - symbol e.g. BTCUSD
    * `sid` - subaccount ID (UUID)
    * `ro` - reduce only
    * `fee` - fee in USD
    * `m` - is maker
    * `t` - created at timestamp (epoch in milliseconds)

#### `TOKEN_TRANSFER`

Provides updates for deposits / withdrawals for a specific subaccount.

```json
// Subscription message payload
{
  "event": "subscribe",
  "data": {
    "type": "TokenTransfer",
    "subaccountId": "<uuid>"
  }
}

// Response message
{
  "e": "TokenTransfer",
  "t": <epoch>,
  "data": {
    "t": <epoch>,
    "id": "<uuid>",
    "sid": "<uuid>",
    "tName": "<string>",
    "tAddr": "<hex>",
    "typ": "<string>",
    "st": "<string>",
    "amt": "<string>",
    "fee": "<string>",
    "iniBk": "Optional<string>",
    "finBk": "Optional<string>",
    "iniTx": "Optional<hex>",
    "finTx": "Optional<hex>",
    "lzAddr": "Optional<hex>",
    "lzEid": "Optional<integer>"
  }
}
```

**`TOKEN_TRANSFER`** events are published per-subaccount when a deposit or withdrawal state changes.

* `e` - event name
* `t` - server timestamp this message was emitted at (epoch in milliseconds)
* `data` - token transfer details
  * `id` - unique identifier of the transfer
  * `t` - timestamp token transfer event (epoch in miliseconds)
  * `sid` - subaccount id that owns this transfer
  * `tName` - token name
  * `tAddr` - token contract address
  * `typ` - transfer type, one of: `"DEPOSIT"`, `"WITHDRAW"`
  * `st` - transfer status, one of: `"SUBMITTED"`, `"PENDING"`, `"COMPLETED"`, `"REJECTED"`
  * `amt` - transfer amount
  * `fee` - transaction fee
  * `iniBk` - block number when the transfer was initiated (optional)
  * `finBk` - block number when the transfer was finalized (optional)
  * `iniTx` - Transaction hash of the initiation transaction (optional)
  * `finTx` - Transaction hash of the finalization transaction (optional)
  * `lzAddr` - LayerZero destination address for cross-chain bridge transfers (optional)
  * `lzEid` - LayerZero endpoint id identifying the destination chain

#### Unsubscribing from Channels

To `unsubscribe` from a channel, send the same payload used to subscribe but with `unsubscribe` as the `event` type. Alternatively, disconnecting your entire connection will end all subscriptions. Note that reconnecting will consume rate limits. See [System Limits](/developer-guides/trading-api/system-limits) for more details.

```json
{
  "event": "unsubscribe",
  "data": {
    "type": "MarketPrice",
    "symbol": "<string>" // e.g. "BTCUSD", "ETHUSD"
  }
}
```

#### Ping / Pong

WebSocket connections include protocol-level ping/pong frames that are handled automatically and are not exposed at the application layer. However, some clients may require explicit liveness checks or latency measurement. In these scenarios, the gateway supports an application-level `ping` event:

```json
// Request message payload
{
  "event": "ping"
}

// Response message
{
  "e": "pong",
  "t": <epoch>
}
```

* `e` - `pong` response from a previous `ping`
* `t` - server timestamp this message was emitted at (epoch in milliseconds)

:::info
This mechanism is optional and is not required to maintain the WebSocket connection. ***Note that is not yet available on mainnet.***
:::

#### Exception Handling

Error responses are returned directly as a reply to the originating event (e.g. `subscribe`, `unsubscribe`). They follow a unified shape:

```json
{
  "ok": false,
  "code": "UNKNOWN_PRODUCT"
}
```

* `ok` indicates whether the request succeeded
* `code` a machine-readable error code present when `ok` is `false`

| Error Code                    | Description                                                                   |
| ----------------------------- | ----------------------------------------------------------------------------- |
| `UNKNOWN_PRODUCT`             | The provided symbol does not match any known product.                         |
| `VALIDATION_ERROR`            | The request payload failed validation, for example missing or invalid fields. |
| `SUBSCRIPTION_FAILED`         | The server was unable to subscribe to the requested topic.                    |
| `UNSUBSCRIBE_FAILED`          | The server was unable to unsubscribe from the requested topic.                |
| `RATE_LIMIT`                  | Too many requests, the client has been rate-limited.                          |
| `INTERNAL_ERROR`              | An unexpected server-side error occurred.                                     |
| `SUBSCRIPTION_LIMIT_EXCEEDED` | Too many subaccount subscriptions on this connection.                         |

:::success
A successful response would simply just return `{ "ok": true, ... }`
:::
