Skip to main content
POST
/
2
/
perp
/
payloads
/
create-order
Build create-order payload
curl --request POST \
  --url https://demo-api.mobula.io/api/2/perp/payloads/create-order \
  --header 'Content-Type: application/json' \
  --data '
{
  "timestamp": 123,
  "signature": "<string>",
  "baseToken": "<string>",
  "quote": "<string>",
  "leverage": 123,
  "long": true,
  "reduceOnly": true,
  "collateralAmount": 123,
  "openPrice": 123,
  "tp": 123,
  "sl": 123,
  "amountRaw": 123,
  "maxSlippageP": 123,
  "chainIds": [
    "<string>"
  ],
  "dexes": [],
  "marginMode": 123,
  "referrer": "<string>",
  "marketId": "<string>"
}
'
{
  "success": true,
  "data": {
    "action": "<string>",
    "dex": "<string>",
    "chainId": "<string>",
    "payloadStr": "<string>",
    "marketId": "<string>"
  }
}
Lighter requires the wallet to be a registered account before any trade/withdraw action.If the EOA has never deposited on Lighter, this endpoint will fail. First-time setup is a two-step prerequisite:
  1. Deposit ≥ 5 USDC via /2/perp/payloads/deposit → Lighter creates an accountIndex on-chain once the bridge settles. (5 USDC is a Lighter requirement, not a Mobula limit.)
  2. Provision API key + auth token via /2/perp/payloads/create-account using that accountIndex.
Read the Build Create-Account Payload page for the full setup flow including how to discover the accountIndex after a deposit.
Gains requires a one-time USDC approval per wallet. Gains’ Diamond proxy executes USDC.transferFrom(user, gainsVault, collateral) mid-trade. Without an approve(spender, …) granting it allowance, /2/perp/execute-v2 returns 400 with the on-chain revert:
ERC20: transfer amount exceeds allowance
Before the first Gains order on a wallet, send USDC.approve(payload.data.to, MaxUint256) (the spender is the to field from the build response). See Approval prerequisite in the cookbook for a copy-paste helper. Native USDC per chain — Arbitrum 0xaf88d065e77c8cC2239327C5EDb3A432268e5831, Base 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.
Builds the canonical order payload used by /2/perp/execute-v2. The signer recovered from the signature field becomes the order’s user.

Request Body

baseToken
string
required
Base token address, symbol, or Mobula asset id.
quote
string
required
Quote token of the market. Semantics differ per DEX:
  • Lighter — pass the ERC-20 collateral symbol (USDC).
  • Gains — pass the synthetic quote (USD). Sending USDC returns "No market matching base/quote".
When unsure, pass marketId (e.g. gains-btc-usd, lighter-btc-usd) and the field is derived server-side.
leverage
number
required
Leverage multiplier (e.g., 10).
long
boolean
required
true for long, false for short.
reduceOnly
boolean
required
true if the order must only reduce an existing position.
collateralAmount
number
required
Collateral in quote units (e.g., USDC).
orderType
string
One of market, limit, stop_limit. Default market.
openPrice
number
Trigger/limit price. Required for limit and stop_limit.
tp
number
Take-profit price.
sl
number
Stop-loss price.
amountRaw
number
Raw position size in base-token units. If omitted, derived from collateralAmount * leverage.
maxSlippageP
number
Max slippage in percent.
chainIds
string[]
Routing hint, not a strict filter. The router prefers the requested chains but may fall back to another supported chain if the requested market is not deployed on any of them. Example: passing chainIds: ['evm:42161'] for a market that only exists on Base will silently return chainId: 'evm:8453'. Always trust the response chainId when broadcasting.Pass an explicit marketId (or rely on the response) for unambiguous routing.
dexes
string[]
Restrict routing to gains and/or lighter.
marginMode
number
0 = cross, 1 = isolated (DEX-specific).
referrer
string
Referrer wallet address for fee sharing.
marketId
string
Force a specific Mobula market instead of routing.

Authentication

Every /2/perp/payloads/<action> endpoint verifies the caller by requiring two extra fields in the request body alongside the action parameters:
timestamp
number
required
Unix timestamp in milliseconds. Must be within 30 seconds of server time. Older timestamps are rejected to prevent replay.
signature
string
required
Hex signature (EIP-191 personal_sign) of the message `${endpoint}-${timestamp}`, where endpoint is the path of this endpoint without the leading slash (e.g., for this page: api/2/perp/payloads/<this-action>). The recovered signer address becomes the user for the request. Single-use — replay returns 403 signature already used.
// Replace `<action>` with the action of THIS page (e.g. create-account, deposit, …)
const endpoint = 'api/2/perp/payloads/<action>';
const timestamp = Date.now();
const signature = await wallet.signMessage(`${endpoint}-${timestamp}`);

await fetch(`https://api.mobula.io/${endpoint}`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    timestamp,
    signature,
    // ...action-specific fields below
  }),
});

Authentication errors

Statusmessage
403timestamp expired — timestamp older than 30s
403signature already used — replay attempt
400zod validation failedtimestamp/signature shape invalid

Response envelope

Every /2/perp/payloads/<action> endpoint returns the same envelope shape. You pass these fields verbatim into POST /2/perp/execute-v2 to execute the action.
Top-level shape. Successful (2xx) responses return { data: { ... } }. A success: true flag is only present inside the body of execute-v2’s response, not on the payload-build endpoints. Parse defensively: read body.data, then check for the action-specific fields you need (e.g. data.payloadStr).
data
object

Endpoint-specific errors

Statusmessage
500could not build create-order payload — no DEX could build a payload for the given parameters

Full flow — open a position end-to-end

Single example covering both DEXes (Lighter offchain-api, Gains evm-tx). The flow branches on data.transport.
import { ethers } from 'ethers';

// 1. Auth-sign + fetch the create-order payload
const endpoint = 'api/2/perp/payloads/create-order';
const ts = Date.now();
const authSig = await wallet.signMessage(`${endpoint}-${ts}`);

const { data } = await fetch(`https://api.mobula.io/${endpoint}`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    timestamp: ts,
    signature: authSig,
    baseToken: 'BTC',
    quote: 'USDC',                   // Lighter quote. For Gains: 'USD' (synthetic) — or omit and pass `marketId`.
    long: true,
    reduceOnly: false,
    leverage: 10,
    collateralAmount: 100,           // 100 USDC of collateral
    orderType: 'market',
    maxSlippageP: 0.5,
    dexes: ['lighter'],              // or ['gains'], or omit to let routing pick
  }),
}).then(r => r.json());

// 2. Branch on transport
let signedTx;
const finalPayloadStr = data.payloadStr;

if (data.transport === 'evm-tx') {
  // Gains: sign the single EVM tx and pass as top-level signedTx.
  // Read nonce / feeData from a real chain RPC — NOT an embedded-wallet
  // provider, which can return stale or default-to-0 nonce.
  const txData = JSON.parse(data.payloadStr).payload.data;
  const provider = new ethers.JsonRpcProvider(rpcUrlFor(txData.chainId));
  const [nonce, feeData] = await Promise.all([
    provider.getTransactionCount(wallet.address, 'pending'),
    provider.getFeeData(),
  ]);

  signedTx = await wallet.signTransaction({
    to: txData.to,
    data: txData.callData,                            // calldata field is `callData`, not `data`
    value: txData.value ? BigInt(txData.value) : 0n,
    from: wallet.address,
    chainId: txData.chainId,
    nonce: txData.nonce ?? nonce,
    // Gains Diamond proxy under-reports estimateGas (~28 k vs ~270 k real burn);
    // use a floor of 1.5 M or take max(estimate * 2, 1_500_000n).
    gasLimit: txData.gas ? BigInt(txData.gas) : 1_500_000n,
    // Bump 3x to absorb base-fee drift across the build → execute roundtrip.
    maxFeePerGas: txData.maxFeePerGas
      ? BigInt(txData.maxFeePerGas)
      : (feeData.maxFeePerGas ?? 0n) * 3n,
    maxPriorityFeePerGas: txData.maxPriorityFeePerGas
      ? BigInt(txData.maxPriorityFeePerGas)
      : (feeData.maxPriorityFeePerGas ?? 0n),
    type: 2,
  });
}
// Lighter offchain-api: nothing to sign here

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

const execRes = await fetch('https://api.mobula.io/api/2/perp/execute-v2', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    action: data.action,
    dex: data.dex,
    chainId: data.chainId,
    marketId: data.marketId,
    transport: data.transport,
    payloadStr: finalPayloadStr,
    timestamp: execTs,
    signature: execSig,
    ...(signedTx && { signedTx }),
  }),
}).then(r => r.json());

Body

application/json
timestamp
number
required
signature
string
required
baseToken
string
required
quote
string
required
leverage
number
required
long
boolean
required
reduceOnly
boolean
required
collateralAmount
number
required
orderType
enum<string>
Available options:
market,
limit,
stop_limit
openPrice
number
tp
number
sl
number
amountRaw
number
maxSlippageP
number
chainIds
string[]
dexes
enum<string>[]
Available options:
gains,
lighter
marginMode
number
referrer
string
marketId
string

Response

200 - application/json

Canonical payload envelope

success
boolean
required
data
object
required