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

> Real-time WebSocket stream of a wallet's open perpetual positions and pending (unfilled) orders. Snapshot-per-push protocol — every frame is the full state of the channel.

<Tip>
  Use this stream instead of polling `/2/wallet/positions/perp/open` and `/2/wallet/positions/perp/unfilled`. REST is still useful as a synchronous seed before the first WSS frame arrives.
</Tip>

## Endpoint

* **URL:** `wss://hawk-api-prod-eu.mobula.io/`
* **Channels** (subscribe once per channel):
  * `perp-positions-open` — currently filled / open perp positions
  * `perp-positions-unfilled` — pending limit / stop / TP / SL orders that haven't filled yet

Each channel is scoped to a single wallet. To track multiple wallets, open one subscription per `(wallet, channel)`.

## Subscribe

```json theme={null}
{
  "event": "perp-positions-open",
  "data": {
    "wallet": "0xaa00…",
    "subscriptionTracking": true,
    "subscriptionId": "<client-generated id>"
  },
  "authorization": "<MOBULA_SERVER_SIDE_KEY>"
}
```

### Parameters

* **`event`** (required) — `"perp-positions-open"` or `"perp-positions-unfilled"`.
* **`data.wallet`** (required) — wallet address. Lowercased on the wire — match accordingly.
* **`data.subscriptionTracking`** (optional, boolean) — when `true`, the server tags pushed frames with the `subscriptionId` so multiple subscriptions can be muxed on one socket. Default: `false`.
* **`data.subscriptionId`** (optional) — client-generated id echoed back on every push for that subscription. Auto-generated if omitted.
* **`authorization`** (required) — your Mobula server-side API key.

<Warning>
  `subscriptionTracking` on this channel is the **boolean** `true`. The sibling [Perp Events Stream](/streams/wss-perp-events) takes the **string** `"true"` instead — the two channels currently use different envelope shapes.
</Warning>

## Server push

Each frame contains the **full** position set for the channel — there is no diff/patch protocol. Replace your local state with `data` on every push.

```json theme={null}
{
  "event": "...",
  "type": "perp-positions-open",
  "data": [
    {
      "id": "pos-gains-inj-usd-usdc-0xaa0055ef84ef93138c7c11be1d19dac5dcd08741-0",
      "entryPriceQuote": 23.41,
      "currentLeverage": 10,
      "amountUSD": 250,
      "amountRaw": "10678342000000000000",
      "side": "BUY",
      "liquidationPriceQuote": 21.08,
      "currentPriceQuote": 24.15,
      "realizedPnlUSD": 0,
      "unrealizedPnlUSD": 7.91,
      "realizedPnlPercent": 0,
      "unrealizedPnlPercent": 3.16,
      "tp": [{ "size": 1.0, "price": 28.0, "id": 0 }],
      "sl": [{ "size": 1.0, "price": 20.5, "id": 0 }],
      "marketId": "gains-inj-usd",
      "exchange": "gains",
      "feesOpeningUSD": 0.12,
      "feesClosingUSD": 0,
      "feesFundingUSD": -0.03,
      "openDate": "2026-05-12T08:42:11.000Z",
      "lastUpdate": "2026-05-18T09:01:22.000Z",
      "address": "0xff162c694eaa571f685030649814282ea457f169",
      "chainId": "evm:42161",
      "collateralAsset": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
      "collateral": 25
    }
  ],
  "subscriptionId": "<your-subscription-id>"
}
```

### Field reference

The `data[]` items mirror the REST response of [`GET /2/wallet/positions/perp/open`](/rest-api-reference/endpoint/wallet-perp-positions) (for `perp-positions-open`) and [`GET /2/wallet/positions/perp/unfilled`](/rest-api-reference/endpoint/wallet-perp-orders) (for `perp-positions-unfilled`). The unfilled channel additionally carries `type: "STOP" | "LIMIT"`.

The `collateral` field (numeric, the USD value of the margin actually posted on the position) is present on WSS frames; it is also documented on the REST response.

#### Composite `id` format

```
pos-<dex>-<base>-<quote>-<collateral>-<wallet>-<tradeIndex>
```

e.g. `pos-gains-inj-usd-usdc-0xaa0055ef84ef93138c7c11be1d19dac5dcd08741-0`.

When closing a Gains position via [`/2/perp/payloads/close-position`](/exec/perps/perp-payload-close-position), do **not** pass the full composite id — extract the trailing trade-index segment and send it as a string (e.g. `"0"`, `"919"`).

## Snapshot semantics

* The first frame after a successful subscribe is the **current snapshot** of the channel. You can subscribe without a REST seed.
* Every subsequent frame is also a **full snapshot** (not a diff). Replace state wholesale on receive.
* An empty `data: []` push means the channel is currently empty (no open positions / no pending orders).

## Heartbeat & idle timeout

The server expects a periodic `ping` to keep the socket alive:

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

The server replies with `{ "event": "pong" }`. A 30-second interval is safe in practice. The idle timeout is short (\~10 s) **before** a subscription is sent — make sure your first frame after `open` is a subscribe, not silence.

## Chain coverage

| Chain id      | Notes                          |
| ------------- | ------------------------------ |
| `evm:42161`   | Gains on Arbitrum              |
| `lighter:301` | Lighter — used by this channel |

<Note>
  Lighter is exposed as `lighter:301` on this channel. The sibling [Perp Events Stream](/streams/wss-perp-events) emits Lighter events under `lighter:304`. Subscribe with the right chain id depending on which stream you are wiring.
</Note>

## Implementation example

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

ws.addEventListener('open', () => {
  for (const event of ['perp-positions-open', 'perp-positions-unfilled']) {
    ws.send(JSON.stringify({
      event,
      data: {
        wallet: WALLET_ADDRESS.toLowerCase(),
        subscriptionTracking: true,
        subscriptionId: `${event}-${WALLET_ADDRESS}`,
      },
      authorization: process.env.MOBULA_API_KEY,
    }));
  }

  // Keep-alive every 30s
  setInterval(() => ws.send(JSON.stringify({ event: 'ping' })), 30_000);
});

ws.addEventListener('message', (msg) => {
  const frame = JSON.parse(msg.data.toString());
  if (frame.event === 'pong') return;
  if (frame.type === 'perp-positions-open') {
    setOpenPositions(frame.data);     // full snapshot
  } else if (frame.type === 'perp-positions-unfilled') {
    setUnfilledOrders(frame.data);    // full snapshot
  }
});
```

## Related

<CardGroup cols={2}>
  <Card title="Perp Events Stream" icon="signal-stream" href="/streams/wss-perp-events">Trade-lifecycle events (fills, liquidations, TP/SL, cancels).</Card>
  <Card title="Get Perp Positions" icon="database" href="/rest-api-reference/endpoint/wallet-perp-positions">REST endpoint — useful as a synchronous seed.</Card>
  <Card title="Get Perp Unfilled Orders" icon="database" href="/rest-api-reference/endpoint/wallet-perp-orders">REST endpoint for pending orders.</Card>
  <Card title="Execution Cookbook" icon="bolt" href="/cookbooks/perp-execution-flow">Full build → execute flow.</Card>
</CardGroup>
