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
}
'import requests
url = "https://demo-api.mobula.io/api/2/perp/payloads/create-account"
payload = {
"timestamp": 123,
"signature": "<string>",
"dex": "lighter",
"chainId": "lighter:304",
"accountIndex": 1,
"apiKeyIndex": 1
}
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>',
dex: 'lighter',
chainId: 'lighter:304',
accountIndex: 1,
apiKeyIndex: 1
})
};
fetch('https://demo-api.mobula.io/api/2/perp/payloads/create-account', 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-account",
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>',
'dex' => 'lighter',
'chainId' => 'lighter:304',
'accountIndex' => 1,
'apiKeyIndex' => 1
]),
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-account"
payload := strings.NewReader("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"dex\": \"lighter\",\n \"chainId\": \"lighter:304\",\n \"accountIndex\": 1,\n \"apiKeyIndex\": 1\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-account")
.header("Content-Type", "application/json")
.body("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"dex\": \"lighter\",\n \"chainId\": \"lighter:304\",\n \"accountIndex\": 1,\n \"apiKeyIndex\": 1\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://demo-api.mobula.io/api/2/perp/payloads/create-account")
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 \"dex\": \"lighter\",\n \"chainId\": \"lighter:304\",\n \"accountIndex\": 1,\n \"apiKeyIndex\": 1\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>"
}
}Build Create-Account Payload
Build a signed canonical payload to create/provision the user’s account on a perpetual DEX (e.g., register a Lighter sub-account or API key).
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
}
'import requests
url = "https://demo-api.mobula.io/api/2/perp/payloads/create-account"
payload = {
"timestamp": 123,
"signature": "<string>",
"dex": "lighter",
"chainId": "lighter:304",
"accountIndex": 1,
"apiKeyIndex": 1
}
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>',
dex: 'lighter',
chainId: 'lighter:304',
accountIndex: 1,
apiKeyIndex: 1
})
};
fetch('https://demo-api.mobula.io/api/2/perp/payloads/create-account', 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-account",
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>',
'dex' => 'lighter',
'chainId' => 'lighter:304',
'accountIndex' => 1,
'apiKeyIndex' => 1
]),
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-account"
payload := strings.NewReader("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"dex\": \"lighter\",\n \"chainId\": \"lighter:304\",\n \"accountIndex\": 1,\n \"apiKeyIndex\": 1\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-account")
.header("Content-Type", "application/json")
.body("{\n \"timestamp\": 123,\n \"signature\": \"<string>\",\n \"dex\": \"lighter\",\n \"chainId\": \"lighter:304\",\n \"accountIndex\": 1,\n \"apiKeyIndex\": 1\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://demo-api.mobula.io/api/2/perp/payloads/create-account")
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 \"dex\": \"lighter\",\n \"chainId\": \"lighter:304\",\n \"accountIndex\": 1,\n \"apiKeyIndex\": 1\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-only. Gains has no provisioning step — accounts are implicit. Skip this endpoint for Gains.
(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 anaccountIndex 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:
- Deposit ≥ 5 USDC via
/2/perp/payloads/deposit→ submit via/2/perp/execute-v2→ poll/2/perp/check-processuntil success. - Discover the
accountIndexby polling Lighter’s account-lookup endpoint (the bridge takes a few seconds to settle on L2):Response shape (success):GET https://mainnet.zklighter.elliot.ai/api/v1/account?by=l1_address&value=<EOA address> Accept: application/json{ "code": 200, "accounts": [{ "account_index": <number>, ... }] }. Poll every ~1s untilaccounts[0].account_indexis 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. - Provision the API key by calling this endpoint with the discovered
accountIndexand submitting via/2/perp/execute-v2. The response payload may carrypayload.MessageToSign— sign it, setpayload.L1Sig, deletepayload.MessageToSign, re-stringify, and sign execute-v2 over the new string. - The user’s wallet can now call all Lighter trade/withdraw endpoints.
Request Body
string
required
Must be
lighter.string
required
Must be
lighter:304 (the only chain that accepts Lighter create-account today).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.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:
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 | accountIndex must be a non-negative integer for lighter create-account — missing or invalid accountIndex |
| 400 | Invalid chainId "<value>" for lighter create-account — chainId other than lighter:304 |
| 400 | create-account payload generation failed — Lighter rejected provisioning (e.g., slot already in use) |
| 501 | payload action "create-account" not implemented yet — dex 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 itsaccountIndex. 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
Unix ms timestamp; must be within 30s of server time.
Hex signature of {endpoint}-{timestamp}. Recovered signer becomes the request user.
Available options:
lighter Must be lighter:304 (only chain that accepts Lighter create-account today).
Available options:
lighter:304 Lighter sub-account index. Discover via Lighter's /api/v1/account?by=l1_address&value= after the first deposit settles.
Required range:
x >= 0Lighter API key slot to provision. Pick any unused slot. Defaults server-side if omitted.
Required range:
x >= 0