> ## 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>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.