Build update-margin payload
curl --request POST \
--url https://demo-api.mobula.io/api/2/perp/payloads/update-margin \
--header 'Content-Type: application/json' \
--data '
{
"timestamp": 123,
"signature": "<string>",
"chainId": "<string>",
"marketId": "<string>",
"positionId": "<string>",
"usdcAmount": 123,
"increase": true,
"newLeverage": 123
}
'import requests
url = "https://demo-api.mobula.io/api/2/perp/payloads/update-margin"
payload = {
"timestamp": 123,
"signature": "<string>",
"chainId": "<string>",
"marketId": "<string>",
"positionId": "<string>",
"usdcAmount": 123,
"increase": True,
"newLeverage": 123
}
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>',
chainId: '<string>',
marketId: '<string>',
positionId: '<string>',
usdcAmount: 123,
increase: true,
newLeverage: 123
})
};
fetch('https://demo-api.mobula.io/api/2/perp/payloads/update-margin', 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/update-margin",
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>',
'chainId' => '<string>',
'marketId' => '<string>',
'positionId' => '<string>',
'usdcAmount' => 123,
'increase' => true,
'newLeverage' => 123
]),
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/update-margin"
payload := strings.NewReader("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"chainId\": \"<string>\",\n \"marketId\": \"<string>\",\n \"positionId\": \"<string>\",\n \"usdcAmount\": 123,\n \"increase\": true,\n \"newLeverage\": 123\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/update-margin")
.header("Content-Type", "application/json")
.body("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"chainId\": \"<string>\",\n \"marketId\": \"<string>\",\n \"positionId\": \"<string>\",\n \"usdcAmount\": 123,\n \"increase\": true,\n \"newLeverage\": 123\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://demo-api.mobula.io/api/2/perp/payloads/update-margin")
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 \"chainId\": \"<string>\",\n \"marketId\": \"<string>\",\n \"positionId\": \"<string>\",\n \"usdcAmount\": 123,\n \"increase\": true,\n \"newLeverage\": 123\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 Update-Margin Payload
Build a signed canonical payload to add or remove collateral from an open perpetual position (Lighter) or change its per-trade leverage (Gains).
POST
/
2
/
perp
/
payloads
/
update-margin
Build update-margin payload
curl --request POST \
--url https://demo-api.mobula.io/api/2/perp/payloads/update-margin \
--header 'Content-Type: application/json' \
--data '
{
"timestamp": 123,
"signature": "<string>",
"chainId": "<string>",
"marketId": "<string>",
"positionId": "<string>",
"usdcAmount": 123,
"increase": true,
"newLeverage": 123
}
'import requests
url = "https://demo-api.mobula.io/api/2/perp/payloads/update-margin"
payload = {
"timestamp": 123,
"signature": "<string>",
"chainId": "<string>",
"marketId": "<string>",
"positionId": "<string>",
"usdcAmount": 123,
"increase": True,
"newLeverage": 123
}
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>',
chainId: '<string>',
marketId: '<string>',
positionId: '<string>',
usdcAmount: 123,
increase: true,
newLeverage: 123
})
};
fetch('https://demo-api.mobula.io/api/2/perp/payloads/update-margin', 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/update-margin",
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>',
'chainId' => '<string>',
'marketId' => '<string>',
'positionId' => '<string>',
'usdcAmount' => 123,
'increase' => true,
'newLeverage' => 123
]),
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/update-margin"
payload := strings.NewReader("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"chainId\": \"<string>\",\n \"marketId\": \"<string>\",\n \"positionId\": \"<string>\",\n \"usdcAmount\": 123,\n \"increase\": true,\n \"newLeverage\": 123\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/update-margin")
.header("Content-Type", "application/json")
.body("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"chainId\": \"<string>\",\n \"marketId\": \"<string>\",\n \"positionId\": \"<string>\",\n \"usdcAmount\": 123,\n \"increase\": true,\n \"newLeverage\": 123\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://demo-api.mobula.io/api/2/perp/payloads/update-margin")
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 \"chainId\": \"<string>\",\n \"marketId\": \"<string>\",\n \"positionId\": \"<string>\",\n \"usdcAmount\": 123,\n \"increase\": true,\n \"newLeverage\": 123\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.- Lighter — supply
usdcAmountandincreaseto add or remove USDC collateral on the market’s position. - Gains — supply
newLeverageto change the trade’s leverage, which effectively adjusts its collateral.
Request Body
string
required
gains or lighter.string
required
Chain of the position.
string
Mobula market identifier.
string
Gains trade index. Required for Gains.
number
Lighter only. USDC amount to add or remove (> 0).
boolean
Lighter only.
true to add margin, false to remove it.number
Gains only. New per-trade leverage (> 0).
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 |
|---|---|
| 400 | update-margin payload action failed — missing position, wrong leg supplied for the DEX, or DEX refusal |
Full flow — update margin end-to-end
Single example covering both DEXes (Lighter offchain-api withusdcAmount+increase, Gains evm-tx with newLeverage). The flow branches on data.transport.
import { ethers } from 'ethers';
// 1. Auth-sign + fetch the update-margin payload
const endpoint = 'api/2/perp/payloads/update-margin';
const ts = Date.now();
const authSig = await wallet.signMessage(`${endpoint}-${ts}`);
// Lighter: add 50 USDC of margin to BTC-USD
const lighterBody = {
timestamp: ts, signature: authSig,
dex: 'lighter', chainId: 'lighter:301',
marketId: 'lighter-btc-usd',
usdcAmount: 50, increase: true,
};
// Gains alternative: lower leverage to 5x on Gains trade #12345
// const gainsBody = {
// timestamp: ts, signature: authSig,
// dex: 'gains', chainId: 'evm:42161',
// marketId: 'gains-btc-usd',
// positionId: '12345', newLeverage: 5,
// };
const { data } = await fetch(`https://api.mobula.io/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(lighterBody),
}).then(r => r.json());
// 2. Branch on transport
let signedTx;
const finalPayloadStr = data.payloadStr;
if (data.transport === 'evm-tx') {
// Gains
const txData = JSON.parse(data.payloadStr).payload.data;
const provider = new ethers.JsonRpcProvider(rpcUrlFor(txData.chainId));
const feeData = await provider.getFeeData();
const baseTx = {
to: txData.to,
data: txData.callData,
value: txData.value ? BigInt(txData.value) : 0n,
from: wallet.address,
chainId: txData.chainId,
nonce: txData.nonce ?? await provider.getTransactionCount(wallet.address),
};
const gasLimit = txData.gas ? BigInt(txData.gas) : await provider.estimateGas(baseTx);
signedTx = await wallet.signTransaction({
...baseTx, gasLimit,
maxFeePerGas: txData.maxFeePerGas ? BigInt(txData.maxFeePerGas) : feeData.maxFeePerGas,
maxPriorityFeePerGas: txData.maxPriorityFeePerGas ? BigInt(txData.maxPriorityFeePerGas) : feeData.maxPriorityFeePerGas,
type: 2,
});
}
// 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:
gains, lighter Gains trade index. Required for Gains.
Lighter only. USDC amount to add or remove (> 0).
Lighter only. true = add margin, false = remove.
Gains only. New per-trade leverage (> 0).