Skip to main content
Every perpetual action on Mobula follows the same two-step pattern:
  1. BuildPOST /2/perp/payloads/<action> with action parameters + an auth signature. You get back a canonical envelope { action, dex, chainId, marketId?, transport, payloadStr }.
  2. ExecutePOST /2/perp/execute-v2 with that envelope + a second signature that binds the timestamp to the exact payloadStr.
Use /2/perp/quote only for previewing fills/fees or picking a DEX. It does not return an executable payload — always call the matching payloads/<action> endpoint before executing.

Signatures at a glance

Two distinct signatures are used across the flow:
StepEndpointSigned message
BuildPOST /2/perp/payloads/<action>`${endpoint}-${timestamp}` (e.g., api/2/perp/payloads/create-order-1735686300000)
ExecutePOST /2/perp/execute-v2`api/2/perp/execute-v2-${timestamp}-${payloadStr}`
Rules for both:
  • timestamp must be within 30s of server time.
  • Each signature is single-use (30s replay window).
  • For actions that carry payload.data.from (create-order, close-position, …), the execute-v2 signer must equal from.

Shared helpers

import { Wallet } from 'ethers';

const BASE = 'https://api.mobula.io';

async function buildPayload(
  wallet: Wallet,
  action: string,
  body: Record<string, unknown>,
) {
  const endpoint = `api/2/perp/payloads/${action}`;
  const timestamp = Date.now();
  const signature = await wallet.signMessage(`${endpoint}-${timestamp}`);
  const res = await fetch(`${BASE}/${endpoint}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...body, timestamp, signature }),
  });
  if (!res.ok) throw new Error(`build ${action} failed: ${await res.text()}`);
  return (await res.json()).data as {
    action: string;
    dex: 'gains' | 'lighter';
    chainId: string;
    marketId?: string;
    transport: 'offchain-api' | 'evm-tx';
    payloadStr: string;
  };
}

async function executeV2(
  wallet: Wallet,
  envelope: Awaited<ReturnType<typeof buildPayload>>,
  extras: { signedTx?: `0x${string}` } = {},
) {
  const timestamp = Date.now();
  const signature = await wallet.signMessage(
    `api/2/perp/execute-v2-${timestamp}-${envelope.payloadStr}`,
  );
  const res = await fetch(`${BASE}/api/2/perp/execute-v2`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...envelope, ...extras, timestamp, signature }),
  });
  if (!res.ok) throw new Error(`execute-v2 failed: ${await res.text()}`);
  return (await res.json()).data as {
    success: true;
    executionDetails?: Array<{ txHash: string; type: string; status: string }>;
    processId?: string;
  };
}

1. Open a position (Lighter)

const envelope = await buildPayload(wallet, 'create-order', {
  baseToken: 'BTC',
  quote: 'USDC',
  long: true,
  reduceOnly: false,
  leverage: 10,
  collateralAmount: 100,
  orderType: 'market',
  maxSlippageP: 0.5,
  dexes: ['lighter'],
});

const result = await executeV2(wallet, envelope);
console.log('filled:', result.executionDetails);

2. Open a position (Gains, EVM tx)

Gains builds an EVM transaction. The server returns transport: "evm-tx"; parse payloadStr, extract payload.data, sign locally, and feed the raw signed tx back via signedTx.
const envelope = await buildPayload(wallet, 'create-order', {
  baseToken: 'BTC',
  quote: 'USDC',
  long: true,
  reduceOnly: false,
  leverage: 20,
  collateralAmount: 200,
  orderType: 'limit',
  openPrice: 60000,
  tp: 72000,
  sl: 55000,
  dexes: ['gains'],
});

const parsed = JSON.parse(envelope.payloadStr);
const txReq = parsed.payload.data; // { to, callData, value?, ... }

const signedTx = await wallet.signTransaction({
  to: txReq.to,
  data: txReq.callData,
  value: txReq.value ?? 0n,
  chainId: 42161,
  // populate gas / nonce from the client
});

const result = await executeV2(wallet, envelope, { signedTx });
console.log('broadcast:', result.executionDetails?.[0]?.txHash);

3. Close a position

Full close on Lighter:
const envelope = await buildPayload(wallet, 'close-position', {
  dex: 'lighter',
  chainId: 'lighter:301',
  marketId: 'lighter-btc-usd',
  closePercentage: 100,
});
await executeV2(wallet, envelope);
Partial close (25%) on Gains — positionId = Gains trade index from /2/wallet/perp/positions:
const envelope = await buildPayload(wallet, 'close-position', {
  dex: 'gains',
  chainId: 'evm:42161',
  marketId: 'gains-btc-usd',
  positionId: '12345',
  closePercentage: 25,
});
// gains → transport === 'evm-tx', sign + forward signedTx (see section 2)

4. Cancel an unfilled order

const envelope = await buildPayload(wallet, 'cancel-order', {
  dex: 'lighter',
  chainId: 'lighter:301',
  marketId: 'lighter-btc-usd',
  orderIndex: '42',
});
await executeV2(wallet, envelope);

5. Edit TP / SL

Clear the SL and set a new TP on a Gains trade (0 removes the leg):
const envelope = await buildPayload(wallet, 'edit-order', {
  dex: 'gains',
  chainId: 'evm:42161',
  marketId: 'gains-btc-usd',
  positionId: '12345',
  newTp: 75000,
  newSl: 0,
});

6. Update margin on an open position (Lighter)

Add 50 USDC of margin to the BTC-USD position:
const envelope = await buildPayload(wallet, 'update-margin', {
  dex: 'lighter',
  chainId: 'lighter:301',
  marketId: 'lighter-btc-usd',
  usdcAmount: 50,
  increase: true,
});
await executeV2(wallet, envelope);

7. Change leverage for future orders

const envelope = await buildPayload(wallet, 'update-leverage', {
  dex: 'lighter',
  chainId: 'lighter:301',
  marketId: 'lighter-btc-usd',
  leverage: 5,
  marginMode: 0, // 0=cross, 1=isolated
});
await executeV2(wallet, envelope);

8. Deposit USDC (Lighter multi-tx bridge)

Lighter deposits use a Relay-style multi-step bridge. The envelope’s payload.steps[] lists EVM transactions the user must sign; the client injects the resulting hex strings back as payload.signedTxs[], re-stringifies, and signs execute-v2 over the new string. No top-level signedTx field is used.
import { ethers } from 'ethers';

const envelope = await buildPayload(wallet, 'deposit', {
  dex: 'lighter',
  chainId: 'lighter:301',
  originChainId: 'evm:42161',
  amountUsdc: '250',
});

const parsed = JSON.parse(envelope.payloadStr) as {
  payload: {
    steps: Array<{
      kind: string;
      items: Array<{
        status: string;
        data: {
          to: string; data: string; value?: string; chainId: number;
          gas?: string; maxFeePerGas?: string; maxPriorityFeePerGas?: string;
        };
      }>;
    }>;
    signedTxs?: string[];
  };
};

const provider = new ethers.JsonRpcProvider(ARBITRUM_RPC);
let nonce = await provider.getTransactionCount(wallet.address);
const feeData = await provider.getFeeData();
const signedTxs: string[] = [];

for (const step of parsed.payload.steps) {
  if (step.kind !== 'transaction') continue;
  for (const item of step.items) {
    if (item.status === 'complete') continue;
    const t = item.data;
    const signed = await wallet.signTransaction({
      to: t.to,
      data: t.data,
      value: t.value ? BigInt(t.value) : 0n,
      chainId: t.chainId,
      nonce: nonce++,
      gasLimit: t.gas ? BigInt(t.gas) : 1_500_000n,
      maxFeePerGas: t.maxFeePerGas ? BigInt(t.maxFeePerGas) : feeData.maxFeePerGas,
      maxPriorityFeePerGas: t.maxPriorityFeePerGas ? BigInt(t.maxPriorityFeePerGas) : feeData.maxPriorityFeePerGas,
      type: 2,
    });
    signedTxs.push(signed);
  }
}

parsed.payload.signedTxs = signedTxs;
const finalPayloadStr = JSON.stringify(parsed);

// sign execute-v2 over the UPDATED string
const execTs = Date.now();
const execSig = await wallet.signMessage(`api/2/perp/execute-v2-${execTs}-${finalPayloadStr}`);

const res = await fetch(`${BASE}/api/2/perp/execute-v2`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    action: envelope.action,
    dex: envelope.dex,
    chainId: envelope.chainId,
    transport: envelope.transport,
    payloadStr: finalPayloadStr,
    timestamp: execTs,
    signature: execSig,
  }),
}).then(r => r.json());

const { processId } = res.data;
if (!processId) throw new Error('deposit did not return processId');

async function pollProcess(id: string) {
  for (let i = 0; i < 60; i++) {
    const r = await fetch(`${BASE}/api/2/perp/check-process?processId=${id}`).then(r => r.json());
    if (r.status && r.status !== 'pending') return r;
    await new Promise(res => setTimeout(res, 2000));
  }
  throw new Error('deposit timeout');
}

console.log(await pollProcess(processId));

9. Withdraw (Lighter L1 sig flow)

Lighter withdraw responses embed an L1 MessageToSign. Sign it, swap it in as L1Sig, re-stringify, sign execute-v2 over the new string.
const envelope = await buildPayload(wallet, 'withdraw', {
  dex: 'lighter',
  chainId: 'lighter:301',
  amountUsdc: '100',
});

const parsed = JSON.parse(envelope.payloadStr) as {
  payload: { MessageToSign: string; L1Sig?: string };
};

const l1Sig = await wallet.signMessage(parsed.payload.MessageToSign);
parsed.payload.L1Sig = l1Sig;
delete (parsed.payload as Partial<typeof parsed.payload>).MessageToSign;

const finalPayloadStr = JSON.stringify(parsed);

const execTs = Date.now();
const execSig = await wallet.signMessage(`api/2/perp/execute-v2-${execTs}-${finalPayloadStr}`);

await fetch(`${BASE}/api/2/perp/execute-v2`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    action: envelope.action,
    dex: envelope.dex,
    chainId: envelope.chainId,
    transport: envelope.transport,
    payloadStr: finalPayloadStr,
    timestamp: execTs,
    signature: execSig,
  }),
});

10. Provision an account (first-time Lighter users)

const envelope = await buildPayload(wallet, 'create-account', {
  dex: 'lighter',
  chainId: 'lighter:301',
});
await executeV2(wallet, envelope);

Reference implementation

A hackathon project built on top of the Mobula perp endpoints — useful as a working end-to-end example covering client-side signing/execution and server-side position reads (REST + WebSocket). Client (Next.js) — Wakushi/defi-client Server (NestJS) — Wakushi/defi-api

Common pitfalls

  • Signing execute-v2 over the wrong string. For deposit and Lighter withdraw you must mutate payload.* (inject signedTxs or swap MessageToSignL1Sig) and sign over the re-stringified envelope. For every other action the envelope is forwarded byte-for-byte. Either way, the string in payloadStr must equal the string inside the execute-v2 signed message.
  • Mutating envelope metadata. Never change action, dex, chainId, transport, or marketId inside payloadStr — execute-v2 cross-checks them against the request fields and rejects mismatches.
  • Signing with the wrong account. For actions whose envelope carries payload.data.from (Gains EVM txs, Lighter orders), the execute-v2 signer must equal from. Deposit/withdraw flows that don’t expose from skip that check.
  • Reusing signatures. Build + execute signatures are each single-use within 30s of their timestamp.
  • Wrong signed-tx channel. Gains single-tx actions go in the top-level signedTx field. Lighter deposit multi-tx goes inside payloadStr as payload.signedTxs[]. Never swap the two.
  • Mixing margin vs leverage on Lighter. update-margin mutates collateral on an existing position (usdcAmount + increase). update-leverage sets leverage/margin-mode for future orders. On Gains, per-trade leverage change is carried by update-margin via newLeverage.

Quote

Preview fills and pick a DEX before building the payload.

Execute

Full execute-v2 reference including signature and error table.

Check Process

Poll status of async deposits.

Retrieve Perp Markets

List all tradable perp markets via Pulse.