Skip to main content
POST
/
2
/
perp
/
payloads
/
create-account
Build create-account payload
curl --request POST \
  --url https://demo-api.mobula.io/api/2/perp/payloads/create-account \
  --header 'Content-Type: application/json' \
  --data '
{
  "timestamp": 123,
  "signature": "<string>",
  "dex": "lighter",
  "chainId": "lighter:304",
  "accountIndex": 1,
  "apiKeyIndex": 1
}
'
{
  "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-only. Gains has no provisioning step — accounts are implicit. Skip this endpoint for Gains.
Provisions the signer’s Lighter sub-account API key + auth token. Run once per (L1 address, accountIndex) after the user’s first deposit so subsequent trades, withdrawals, etc. can authenticate against Lighter.

Account lifecycle on Lighter

A wallet (EOA) is not a Lighter account by default. Lighter only registers an account on-chain after the L1 address makes its first USDC deposit (≥ 5 USDC, a Lighter-side requirement). Once the deposit settles, Lighter assigns an accountIndex to that L1 address. The integrator must then call this endpoint with the discovered accountIndex to provision an API key + auth token — without it, every other Lighter perp endpoint (create-order, close-position, withdraw, …) will fail. End-to-end first-time setup:
  1. Deposit ≥ 5 USDC via /2/perp/payloads/deposit → submit via /2/perp/execute-v2 → poll /2/perp/check-process until success.
  2. Discover the accountIndex by polling Lighter’s account-lookup endpoint (the bridge takes a few seconds to settle on L2):
    GET https://mainnet.zklighter.elliot.ai/api/v1/account?by=l1_address&value=<EOA address>
    Accept: application/json
    
    Response shape (success): { "code": 200, "accounts": [{ "account_index": <number>, ... }] }. Poll every ~1s until accounts[0].account_index is present.
    Coming soon. Mobula will expose a proxy endpoint so integrators don’t need to call Lighter directly. For now, hit Lighter’s URL above.
  3. Provision the API key by calling this endpoint with the discovered accountIndex and submitting via /2/perp/execute-v2. The response payload may carry payload.MessageToSign — sign it, set payload.L1Sig, delete payload.MessageToSign, re-stringify, and sign execute-v2 over the new string.
  4. The user’s wallet can now call all Lighter trade/withdraw endpoints.

Request Body

dex
string
required
Must be lighter.
chainId
string
required
Must be lighter:304 (the only chain that accepts Lighter create-account today).
accountIndex
number
required
Lighter sub-account index (non-negative integer). Discovered via the Lighter /api/v1/account?by=l1_address&value=<EOA> lookup after the first deposit settles.
apiKeyIndex
number
Lighter API key slot to provision (≥ 0). Pick any unused slot. Defaults server-side if omitted.

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
400accountIndex must be a non-negative integer for lighter create-account — missing or invalid accountIndex
400Invalid chainId "<value>" for lighter create-account — chainId other than lighter:304
400create-account payload generation failed — Lighter rejected provisioning (e.g., slot already in use)
501payload action "create-account" not implemented yetdex other than lighter

Full flow — provision a Lighter sub-account end-to-end

Assumes the wallet has already deposited ≥ 5 USDC and you have polled Lighter to discover its accountIndex. Snippet shows step 3 of the lifecycle above.
// 1. Auth-sign + fetch the create-account payload
const endpoint = 'api/2/perp/payloads/create-account';
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: 'lighter',
    chainId: 'lighter:304',
    accountIndex,         // discovered from Lighter's /api/v1/account lookup
    apiKeyIndex: 100,     // any unused slot
  }),
}).then(r => r.json());

// 2. Mutate the envelope only if Lighter returns an L1 challenge
const parsed = JSON.parse(data.payloadStr);
let finalPayloadStr = data.payloadStr;

if (parsed.payload.MessageToSign) {
  parsed.payload.L1Sig = await wallet.signMessage(parsed.payload.MessageToSign);
  delete parsed.payload.MessageToSign;
  finalPayloadStr = JSON.stringify(parsed);
}

// 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,
    transport: data.transport,
    payloadStr: finalPayloadStr,
    timestamp: execTs,
    signature: execSig,
  }),
}).then(r => r.json());

Helper — discover accountIndex after a deposit

async function pollLighterAccountIndex(eoaAddress, { intervalMs = 1000, maxRetries = 200 } = {}) {
  for (let i = 0; i < maxRetries; i++) {
    const res = await fetch(
      `https://mainnet.zklighter.elliot.ai/api/v1/account?by=l1_address&value=${eoaAddress}`,
      { headers: { Accept: 'application/json' } },
    ).then(r => r.json()).catch(() => null);

    if (res?.code === 200 && res.accounts?.[0]?.account_index != null) {
      return res.accounts[0].account_index;
    }
    await new Promise(r => setTimeout(r, intervalMs));
  }
  throw new Error(`Lighter accountIndex not found for ${eoaAddress} after ${maxRetries} polls`);
}

Body

application/json
timestamp
number
required

Unix ms timestamp; must be within 30s of server time.

signature
string
required

Hex signature of {endpoint}-{timestamp}. Recovered signer becomes the request user.

dex
enum<string>
required
Available options:
lighter
chainId
enum<string>
required

Must be lighter:304 (only chain that accepts Lighter create-account today).

Available options:
lighter:304
accountIndex
integer
required

Lighter sub-account index. Discover via Lighter's /api/v1/account?by=l1_address&value= after the first deposit settles.

Required range: x >= 0
apiKeyIndex
integer

Lighter API key slot to provision. Pick any unused slot. Defaults server-side if omitted.

Required range: x >= 0

Response

200 - application/json

Canonical payload envelope

success
boolean
required
data
object
required