Skip to main content
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>",
  "dex": "gains",
  "chainId": "<string>",
  "marketId": "<string>",
  "positionId": "<string>",
  "closePercentage": 123,
  "amountRaw": 123,
  "params": {}
}
'
{
  "success": true,
  "data": {
    "action": "<string>",
    "dex": "<string>",
    "chainId": "<string>",
    "transport": "offchain-api",
    "payloadStr": "<string>",
    "marketId": "<string>"
  }
}

Documentation Index

Fetch the complete documentation index at: https://docs.mobula.io/llms.txt

Use this file to discover all available pages before exploring further.

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.
Builds the payload for closing an existing position. Close size is either explicit (amountRaw) or percentage-based (closePercentage).

Request Body

dex
string
required
gains or lighter.
chainId
string
required
Chain of the position (e.g., evm:42161, lighter:301).
marketId
string
required
Mobula market identifier (e.g., lighter-btc-usd).
positionId
string
Gains trade index. Required for Gains. Not used for Lighter.
closePercentage
number
Portion of the position to close, in percent (0 < value ≤ 100). Use 100 for a full close. Mutually exclusive with amountRaw.
amountRaw
number
Raw base-token amount to close. Mutually exclusive with closePercentage.
params
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:
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.
data
object

Endpoint-specific errors

Statusmessage
400close-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 on data.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
  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,           // Gains → calldata field is `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,
  });
}
// 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
dex
enum<string>
required
Available options:
gains,
lighter
chainId
string
required
marketId
string
required
positionId
string

Gains trade index. Required for Gains.

closePercentage
number

Portion to close (0 < value ≤ 100). Mutually exclusive with amountRaw.

amountRaw
number

Raw base-token amount to close. Mutually exclusive with closePercentage.

params
object

Response

200 - application/json

Canonical payload envelope

success
boolean
required
data
object
required