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>"
}
'import requests
url = "https://demo-api.mobula.io/api/2/perp/payloads/create-order"
payload = {
"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>"
}
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
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>'
})
};
fetch('https://demo-api.mobula.io/api/2/perp/payloads/create-order', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://demo-api.mobula.io/api/2/perp/payloads/create-order",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'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>'
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://demo-api.mobula.io/api/2/perp/payloads/create-order"
payload := strings.NewReader("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"baseToken\": \"<string>\",\n \"quote\": \"<string>\",\n \"leverage\": 123,\n \"long\": true,\n \"reduceOnly\": true,\n \"collateralAmount\": 123,\n \"openPrice\": 123,\n \"tp\": 123,\n \"sl\": 123,\n \"amountRaw\": 123,\n \"maxSlippageP\": 123,\n \"chainIds\": [\n \"<string>\"\n ],\n \"dexes\": [],\n \"marginMode\": 123,\n \"referrer\": \"<string>\",\n \"marketId\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://demo-api.mobula.io/api/2/perp/payloads/create-order")
.header("Content-Type", "application/json")
.body("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"baseToken\": \"<string>\",\n \"quote\": \"<string>\",\n \"leverage\": 123,\n \"long\": true,\n \"reduceOnly\": true,\n \"collateralAmount\": 123,\n \"openPrice\": 123,\n \"tp\": 123,\n \"sl\": 123,\n \"amountRaw\": 123,\n \"maxSlippageP\": 123,\n \"chainIds\": [\n \"<string>\"\n ],\n \"dexes\": [],\n \"marginMode\": 123,\n \"referrer\": \"<string>\",\n \"marketId\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://demo-api.mobula.io/api/2/perp/payloads/create-order")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Content-Type"] = 'application/json'
request.body = "{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"baseToken\": \"<string>\",\n \"quote\": \"<string>\",\n \"leverage\": 123,\n \"long\": true,\n \"reduceOnly\": true,\n \"collateralAmount\": 123,\n \"openPrice\": 123,\n \"tp\": 123,\n \"sl\": 123,\n \"amountRaw\": 123,\n \"maxSlippageP\": 123,\n \"chainIds\": [\n \"<string>\"\n ],\n \"dexes\": [],\n \"marginMode\": 123,\n \"referrer\": \"<string>\",\n \"marketId\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"success": true,
"data": {
"action": "<string>",
"dex": "<string>",
"chainId": "<string>",
"transport": "offchain-api",
"payloadStr": "<string>",
"marketId": "<string>"
}
}Execution
Build Create-Order Payload
Build a signed canonical payload to open a new perpetual position on Gains Network or Lighter. The response is submitted as-is to /2/perp/execute-v2.
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>"
}
'import requests
url = "https://demo-api.mobula.io/api/2/perp/payloads/create-order"
payload = {
"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>"
}
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
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>'
})
};
fetch('https://demo-api.mobula.io/api/2/perp/payloads/create-order', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://demo-api.mobula.io/api/2/perp/payloads/create-order",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'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>'
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://demo-api.mobula.io/api/2/perp/payloads/create-order"
payload := strings.NewReader("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"baseToken\": \"<string>\",\n \"quote\": \"<string>\",\n \"leverage\": 123,\n \"long\": true,\n \"reduceOnly\": true,\n \"collateralAmount\": 123,\n \"openPrice\": 123,\n \"tp\": 123,\n \"sl\": 123,\n \"amountRaw\": 123,\n \"maxSlippageP\": 123,\n \"chainIds\": [\n \"<string>\"\n ],\n \"dexes\": [],\n \"marginMode\": 123,\n \"referrer\": \"<string>\",\n \"marketId\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://demo-api.mobula.io/api/2/perp/payloads/create-order")
.header("Content-Type", "application/json")
.body("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"baseToken\": \"<string>\",\n \"quote\": \"<string>\",\n \"leverage\": 123,\n \"long\": true,\n \"reduceOnly\": true,\n \"collateralAmount\": 123,\n \"openPrice\": 123,\n \"tp\": 123,\n \"sl\": 123,\n \"amountRaw\": 123,\n \"maxSlippageP\": 123,\n \"chainIds\": [\n \"<string>\"\n ],\n \"dexes\": [],\n \"marginMode\": 123,\n \"referrer\": \"<string>\",\n \"marketId\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://demo-api.mobula.io/api/2/perp/payloads/create-order")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Content-Type"] = 'application/json'
request.body = "{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"baseToken\": \"<string>\",\n \"quote\": \"<string>\",\n \"leverage\": 123,\n \"long\": true,\n \"reduceOnly\": true,\n \"collateralAmount\": 123,\n \"openPrice\": 123,\n \"tp\": 123,\n \"sl\": 123,\n \"amountRaw\": 123,\n \"maxSlippageP\": 123,\n \"chainIds\": [\n \"<string>\"\n ],\n \"dexes\": [],\n \"marginMode\": 123,\n \"referrer\": \"<string>\",\n \"marketId\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"success": true,
"data": {
"action": "<string>",
"dex": "<string>",
"chainId": "<string>",
"transport": "offchain-api",
"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:
- Deposit ≥ 5 USDC via
/2/perp/payloads/deposit→ Lighter creates anaccountIndexon-chain once the bridge settles. (5 USDC is a Lighter requirement, not a Mobula limit.) - Provision API key + auth token via
/2/perp/payloads/create-accountusing thataccountIndex.
accountIndex after a deposit.Gains requires a one-time USDC approval per wallet. Gains’ Diamond proxy executes Before the first Gains order on a wallet, send
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
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./2/perp/execute-v2. The signer recovered from the signature field becomes the order’s user.
Request Body
string
required
Base token address, symbol, or Mobula asset id.
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). SendingUSDCreturns"No market matching base/quote".
marketId (e.g. gains-btc-usd, lighter-btc-usd) and the field is derived server-side.number
required
Leverage multiplier (e.g.,
10).boolean
required
true for long, false for short.boolean
required
true if the order must only reduce an existing position.number
required
Collateral in
quote units (e.g., USDC).string
One of
market, limit, stop_limit. Default market.number
Trigger/limit price. Required for
limit and stop_limit.number
Take-profit price.
number
Stop-loss price.
number
Raw position size in base-token units. If omitted, derived from
collateralAmount * leverage.number
Max slippage in percent.
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.string[]
Restrict routing to
gains and/or lighter.number
0 = cross, 1 = isolated (DEX-specific).string
Referrer wallet address for fee sharing.
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:
number
required
Unix timestamp in milliseconds. Must be within 30 seconds of server time. Older timestamps are rejected to prevent replay.
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
| Status | message |
|---|---|
| 403 | timestamp expired — timestamp older than 30s |
| 403 | signature already used — replay attempt |
| 400 | zod validation failed — timestamp/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).object
Show data
Show data
string
Canonical action name — one of
withdraw, create-account, deposit, create-order, close-position, cancel-order, update-margin, edit-order.string
gains or lighter.string
Chain where the action lands (e.g.,
evm:42161, lighter:301).string
Mobula market identifier. Present when the action targets a specific market.
string
offchain-api — server submits to the DEX off-chain API on the user’s behalf (Lighter trades, Lighter withdraw, Lighter create-account).evm-tx — server broadcasts a user-signed EVM transaction (Lighter deposit bridge route, Gains trade actions). The Gains case requires a top-level signedTx on execute-v2; the Lighter deposit case injects signed txs inside payloadStr.string
JSON-stringified canonical envelope. For most actions you forward this byte-for-byte into
/2/perp/execute-v2. Mutations are required for: Lighter deposit (inject payload.signedTxs), Lighter withdraw (sign payload.MessageToSign → payload.L1Sig, delete MessageToSign), Lighter create-account (same as withdraw only if payload.MessageToSign is present). After mutation, re-stringify and sign execute-v2 over the new string. Never alter the envelope metadata (action, dex, chainId, transport, marketId) — execute-v2 cross-checks it.Endpoint-specific errors
| Status | message |
|---|---|
| 500 | could 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 ondata.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
Available options:
market, limit, stop_limit Available options:
gains, lighter