Build close-position payload
curl --request POST \
--url https://demo-api.mobula.io/api/2/perp/payloads/close-position \
--header 'Content-Type: application/json' \
--data '
{
"timestamp": 123,
"signature": "<string>",
"chainId": "<string>",
"marketId": "<string>",
"positionId": "<string>",
"closePercentage": 123,
"amountRaw": 123,
"params": {}
}
'import requests
url = "https://demo-api.mobula.io/api/2/perp/payloads/close-position"
payload = {
"timestamp": 123,
"signature": "<string>",
"chainId": "<string>",
"marketId": "<string>",
"positionId": "<string>",
"closePercentage": 123,
"amountRaw": 123,
"params": {}
}
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>',
closePercentage: 123,
amountRaw: 123,
params: {}
})
};
fetch('https://demo-api.mobula.io/api/2/perp/payloads/close-position', 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/close-position",
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>',
'closePercentage' => 123,
'amountRaw' => 123,
'params' => [
]
]),
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/close-position"
payload := strings.NewReader("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"chainId\": \"<string>\",\n \"marketId\": \"<string>\",\n \"positionId\": \"<string>\",\n \"closePercentage\": 123,\n \"amountRaw\": 123,\n \"params\": {}\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/close-position")
.header("Content-Type", "application/json")
.body("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"chainId\": \"<string>\",\n \"marketId\": \"<string>\",\n \"positionId\": \"<string>\",\n \"closePercentage\": 123,\n \"amountRaw\": 123,\n \"params\": {}\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://demo-api.mobula.io/api/2/perp/payloads/close-position")
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 \"closePercentage\": 123,\n \"amountRaw\": 123,\n \"params\": {}\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 Close-Position Payload
Build a signed canonical payload to close (fully or partially) an open perpetual position on Gains Network or Lighter.
POST
/
2
/
perp
/
payloads
/
close-position
Build close-position payload
curl --request POST \
--url https://demo-api.mobula.io/api/2/perp/payloads/close-position \
--header 'Content-Type: application/json' \
--data '
{
"timestamp": 123,
"signature": "<string>",
"chainId": "<string>",
"marketId": "<string>",
"positionId": "<string>",
"closePercentage": 123,
"amountRaw": 123,
"params": {}
}
'import requests
url = "https://demo-api.mobula.io/api/2/perp/payloads/close-position"
payload = {
"timestamp": 123,
"signature": "<string>",
"chainId": "<string>",
"marketId": "<string>",
"positionId": "<string>",
"closePercentage": 123,
"amountRaw": 123,
"params": {}
}
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>',
closePercentage: 123,
amountRaw: 123,
params: {}
})
};
fetch('https://demo-api.mobula.io/api/2/perp/payloads/close-position', 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/close-position",
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>',
'closePercentage' => 123,
'amountRaw' => 123,
'params' => [
]
]),
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/close-position"
payload := strings.NewReader("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"chainId\": \"<string>\",\n \"marketId\": \"<string>\",\n \"positionId\": \"<string>\",\n \"closePercentage\": 123,\n \"amountRaw\": 123,\n \"params\": {}\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/close-position")
.header("Content-Type", "application/json")
.body("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"chainId\": \"<string>\",\n \"marketId\": \"<string>\",\n \"positionId\": \"<string>\",\n \"closePercentage\": 123,\n \"amountRaw\": 123,\n \"params\": {}\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://demo-api.mobula.io/api/2/perp/payloads/close-position")
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 \"closePercentage\": 123,\n \"amountRaw\": 123,\n \"params\": {}\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.amountRaw) or percentage-based (closePercentage).
Request Body
string
required
gains or lighter.string
required
Chain of the position (e.g.,
evm:42161, lighter:301).string
required
Mobula market identifier (e.g.,
lighter-btc-usd).string
Gains only — required. The Gains trade index, sent as a string that parses as a non-negative integer (e.g. Not used for Lighter.
"0", "919").The Gains positions endpoints (e.g.
GET /2/wallet/positions/perp/open, perp-positions-open WSS channel) expose a composite position id of the form:pos-gains-<base>-<quote>-<collateral>-<wallet>-<tradeIndex>
pos-gains-inj-usd-usdc-0xaa0055ef84ef93138c7c11be1d19dac5dcd08741-0.payloads/close-position does not accept this composite id — passing it returns 400 close-position payload generation failed: gains - positionId must be a non-negative integer, got "<composite>".Extract the trailing trade index segment (the final -<integer>) and send it as a string:// composite → trade index
const tradeIndex = compositeId.split('-').pop(); // e.g. "0"
// Send as string. A JS number is rejected by the zod layer
// ("expected string, received number"); a string with leading zeros
// or decimals fails the non-negative-integer business check.
number
Portion of the position to close, in percent (
0 < value ≤ 100). Use 100 for a full close. Mutually exclusive with amountRaw.number
Raw base-token amount to close. Mutually exclusive with
closePercentage.object
Additional DEX-specific parameters. For Gains partial closes, the API transparently injects
currentCollateralRaw from the position cache when available, so you do not need to supply it.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 | close-position payload generation failed — position not found, invalid close size, or DEX refusal |
Full flow — close 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 close-position payload
const endpoint = 'api/2/perp/payloads/close-position';
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,
dex: 'gains', // or 'lighter'
chainId: 'evm:42161', // or 'lighter:301'
marketId: 'gains-btc-usd',
positionId: '12345', // Gains only — Gains trade index
closePercentage: 50, // close 50%; or use amountRaw
}),
}).then(r => r.json());
// 2. Branch on transport
let signedTx;
const finalPayloadStr = data.payloadStr; // close-position never mutates the envelope
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,
gasLimit: txData.gas ? BigInt(txData.gas) : 1_500_000n, // Diamond proxy under-reports; floor at 1.5 M
maxFeePerGas: txData.maxFeePerGas
? BigInt(txData.maxFeePerGas)
: (feeData.maxFeePerGas ?? 0n) * 3n, // headroom across the roundtrip
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:
gains, lighter Gains trade index. Required for Gains.
Portion to close (0 < value ≤ 100). Mutually exclusive with amountRaw.
Raw base-token amount to close. Mutually exclusive with closePercentage.