Skip to main content

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.

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.

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

{
  "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.
subscriptionTracking on this channel is the boolean true. The sibling Perp Events Stream takes the string "true" instead — the two channels currently use different envelope shapes.

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.
{
  "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 (for perp-positions-open) and GET /2/wallet/positions/perp/unfilled (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, 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:
{ "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 idNotes
evm:42161Gains on Arbitrum
lighter:301Lighter — used by this channel
Lighter is exposed as lighter:301 on this channel. The sibling Perp Events Stream emits Lighter events under lighter:304. Subscribe with the right chain id depending on which stream you are wiring.

Implementation example

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
  }
});

Perp Events Stream

Trade-lifecycle events (fills, liquidations, TP/SL, cancels).

Get Perp Positions

REST endpoint — useful as a synchronous seed.

Get Perp Unfilled Orders

REST endpoint for pending orders.

Execution Cookbook

Full build → execute flow.