> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mobula.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Perp Events Stream

> Real-time WebSocket stream of trade-lifecycle events for perpetual trading on Lighter and Gains — fills, liquidations, TP/SL triggers, TP/SL edits, cancels, and position-size / leverage updates.

<Tip>
  Use this stream to learn that a submitted order **filled**, was **liquidated**, hit **TP/SL**, was **canceled**, or had its margin / leverage / TP / SL updated. The [Perp Positions WSS](/streams/wss-perp-positions) gives you state (open positions + pending orders); this stream gives you the events that change that state.
</Tip>

## Endpoint

* **URL:** `wss://stream-perps-prod-eu.mobula.io/`
* **Event type:** `stream` (in the subscribe envelope)

<Warning>
  This stream is hosted on a **different domain** from the perp REST API and the [Perp Positions WSS](/streams/wss-perp-positions) (which both live on `hawk-api-prod-eu.mobula.io`). Subscribing to perp events on `hawk-api-prod-eu.mobula.io` silently fails with `CloseEvent { code: 4001, reason: "Idle timeout - no subscription received" }` \~10 s later.
</Warning>

## Subscribe

```json theme={null}
{
  "type": "stream",
  "authorization": "<MOBULA_SERVER_SIDE_KEY>",
  "payload": {
    "name": "perp-events-<your-uuid>",
    "chainIds": ["evm:42161", "evm:421614", "evm:8453", "lighter:304"],
    "events": ["order"],
    "subscriptionTracking": "true"
  }
}
```

### Parameters

* **`type`** (required) — must be `"stream"`.
* **`authorization`** (required) — your Mobula server-side API key.
* **`payload.name`** (required) — client-provided subscription name. Echoed back on the ack frame and useful for log correlation.
* **`payload.chainIds`** (required) — array of chain ids to subscribe to. See [Chain coverage](#chain-coverage) below.
* **`payload.events`** (required) — array of event categories. `"order"` covers all perp trade-lifecycle events listed in [Event types](#event-types) below.
* **`payload.subscriptionTracking`** (optional) — **string** `"true"` (not a boolean). When set, the ack frame includes a `subscriptionId` you can use to demux multiple subscriptions on a single socket.

<Warning>
  `subscriptionTracking` on this stream is the **string** `"true"`. The sibling [Perp Positions WSS](/streams/wss-perp-positions) uses a **boolean** `true` instead. The two envelope shapes are different — see the comparison table below.
</Warning>

### Subscribe acknowledgement

```json theme={null}
{ "event": "subscribed", "subscriptionId": "sub_5cbd27158c9f0b7fb9bc66584d55d76a" }
```

## Server push

Each event is one frame. The top-level envelope wraps the event-specific `data`:

```json theme={null}
{
  "data": { /* event-data — schema varies by event type, see below */ },
  "chainId": "evm:42161",
  "duplicateCount": 1,
  "subscriptionId": "sub_5cbd27158c9f0b7fb9bc66584d55d76a"
}
```

* **`data.traderAddress`** is lowercased on the wire — match accordingly when filtering by wallet.
* **`duplicateCount`** is informational; identical frames may still be delivered on reconnect or for Gains' `TRADE_STORED` + `MARKET_BUY` pair on the same transaction. **Deduplicate on the client** with a key like `(transactionHash, type, traderAddress, tradeId)`.

## Event types

| Type                                                                  | Emitted by     | Description                                                                                                                                                            |
| --------------------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MARKET_BUY` / `MARKET_SELL`                                          | Gains, Lighter | Market order fill                                                                                                                                                      |
| `LIMIT_BUY` / `LIMIT_SELL`                                            | Gains          | Limit order fill                                                                                                                                                       |
| `LIQUIDATION_LONG` / `LIQUIDATION_SHORT`                              | Gains, Lighter | Position liquidated                                                                                                                                                    |
| `TAKE_PROFIT` / `STOP_LOSS`                                           | Gains, Lighter | TP / SL trigger fired                                                                                                                                                  |
| `TRADE_STORED`                                                        | Gains          | Emitted **in addition to** `MARKET_*` when a Gains trade is committed to the Diamond — clients see two frames per tx                                                   |
| `UPDATE_TP` / `UPDATE_SL`                                             | Gains          | TP/SL edited. Carries `newPriceQuote` and `quantityPercentage`.                                                                                                        |
| `POSITION_SIZE_INCREASE_EXECUTED` / `POSITION_SIZE_DECREASE_EXECUTED` | Gains          | Margin added / removed on an open position                                                                                                                             |
| `LEVERAGE_UPDATE_EXECUTED`                                            | Gains          | Leverage changed on an open position                                                                                                                                   |
| `MARKET_OPEN_CANCELED` / `MARKET_CLOSE_CANCELED`                      | Gains          | Market order rejected by the Diamond. `extra.cancelReason` is a numeric Gains enum — see the [Gains Trading docs](https://docs.gains.trade) for the canonical mapping. |

For the field reference of each event category (`PerpOrderExecuted`, `PerpTpSlUpdate`, `PerpUncategorizedEvent`) including extra-field shapes, see the [Perpetuals data model](/indexing-stream/stream/data-model/perps-data-model).

### Gains "two frames per trade" caveat

For every Gains market action, you will receive **two** events back-to-back with overlapping but **different** field sets — these are not duplicates:

* `TRADE_STORED` — `{ market, exchange, chainId, traderAddress, type, tradeId, transactionHash, extra: { tradeType, leverage, long, collateralAmount, index, openPrice, tp, sl }, date, ... }`
* `MARKET_BUY` (or `MARKET_SELL`) — `{ market, exchange, chainId, type, priceQuote, priceUSD, blockNumber, baseAmountRaw, tradeId, collateralAmountRaw, collateralAsset, leverage, traderAddress, takeProfitUSD, stopLossUSD, extra: { open, amountSentToTrader, trade: { long, index, tradeType, openPrice, collateralIndex } }, transactionHash, date, ... }`

Common dedupe key: `(transactionHash, type, traderAddress, tradeId)` — distinguishes the two without dropping one of them.

## Chain coverage

| Chain id      | Used for                            |
| ------------- | ----------------------------------- |
| `evm:42161`   | Gains on Arbitrum (mainnet)         |
| `evm:421614`  | Gains on Arbitrum Sepolia (testnet) |
| `evm:8453`    | Gains on Base                       |
| `lighter:304` | Lighter — used by this stream       |

<Note>
  Lighter on this stream is `lighter:304`. The sibling [Perp Positions WSS](/streams/wss-perp-positions) uses `lighter:301`. Subscribing to events with `lighter:301` returns no Lighter frames at all — it is silently dropped.
</Note>

## Envelope shape — events vs positions

The perp events stream and the perp positions WSS use **different envelope shapes**:

|                             | Perp Events (this page)                      | [Perp Positions](/streams/wss-perp-positions)                             |
| --------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| Host                        | `stream-perps-prod-eu.mobula.io`             | `hawk-api-prod-eu.mobula.io`                                              |
| Subscribe top-level         | `{ type: "stream", authorization, payload }` | `{ event: "perp-positions-open" \| "...-unfilled", data, authorization }` |
| `subscriptionTracking` type | string `"true"`                              | boolean `true`                                                            |
| Lighter chain id            | `lighter:304`                                | `lighter:301`                                                             |
| Push semantics              | one event per frame                          | full snapshot per frame                                                   |

## Heartbeat & idle timeout

The server expects `ping` frames to keep the socket alive:

```json theme={null}
{ "event": "ping" }
```

The server replies with `{ "event": "pong" }`. The idle timeout is short (\~10 s) **before** a subscription is sent — make sure your first frame after `open` is a subscribe, not silence. Once subscribed, 30 s+ between pings has been observed to work.

## Lighter "hawk" subscribers

Lighter `MARKET_*` / liquidation / TP / SL frames may include an `activePosition` / `historicalPosition` block embedded on the event with the full position state at the time of the event. Raw (non-hawk) subscribers receive a smaller payload without this block.

## Implementation example

```typescript theme={null}
const ws = new WebSocket('wss://stream-perps-prod-eu.mobula.io/');

ws.addEventListener('open', () => {
  ws.send(JSON.stringify({
    type: 'stream',
    authorization: process.env.MOBULA_API_KEY,
    payload: {
      name: `perp-events-${crypto.randomUUID()}`,
      chainIds: ['evm:42161', 'evm:8453', 'lighter:304'],
      events: ['order'],
      subscriptionTracking: 'true',     // STRING, not boolean
    },
  }));

  setInterval(() => ws.send(JSON.stringify({ event: 'ping' })), 30_000);
});

const seen = new Set<string>();

ws.addEventListener('message', (msg) => {
  const frame = JSON.parse(msg.data.toString());
  if (frame.event === 'pong') return;
  if (frame.event === 'subscribed') return;

  const e = frame.data;
  if (!e?.type) return;

  const dedupeKey = `${e.transactionHash}-${e.type}-${e.traderAddress}-${e.tradeId}`;
  if (seen.has(dedupeKey)) return;
  seen.add(dedupeKey);

  // Filter by your wallet — events are lowercased on the wire
  if (e.traderAddress !== WALLET_ADDRESS.toLowerCase()) return;

  handleEvent(e);
});
```

## Related

<CardGroup cols={2}>
  <Card title="Perp Positions Stream" icon="signal-stream" href="/streams/wss-perp-positions">Live snapshot of open positions + unfilled orders.</Card>
  <Card title="Perpetuals Data Model" icon="diagram-project" href="/indexing-stream/stream/data-model/perps-data-model">Per-event field reference (executed / TP-SL / uncategorized).</Card>
  <Card title="Execution Cookbook" icon="bolt" href="/cookbooks/perp-execution-flow">Pair with this stream to learn that your order filled.</Card>
  <Card title="Funding Stream" icon="signal-stream" href="/streams/wss-funding">Real-time funding rates across CeFi + DeFi exchanges.</Card>
</CardGroup>
