LeziFx Developer API

Build your own branded prediction-trading platform using LeziFx infrastructure. Your users never know it's LeziFx under the hood — they see your brand, your domain, your colors.

REST + WebSocket Multi-tenant M-Pesa Payments Spring Boot 4 STOMP / SockJS JWT Auth
Base URL All REST calls go to: https://lezifx-backend-java-spring-boot-production.up.railway.app/api/v1
WebSocket endpoint: https://lezifx-backend-java-spring-boot-production.up.railway.app/ws

What you get as a tenant

How Tenancy Works

The LeziFx backend is domain-aware. When your frontend makes its first request, it sends your domain as a header. The backend resolves which tenant you are, and returns your API key, branding, and feature flags. Everything after that is scoped to your tenant automatically.

1. Browser opens app.yourdomain.com │ ├── GET /api/v1/public/config │ Headers: X-Domain: app.yourdomain.com │ │ ← { apiKey, tenantId, brandName, primaryColor, features, ... }2. Store apiKey. All subsequent requests include:X-API-Key: {apiKey}3. User logs in: ├── POST /api/v1/auth/login { email, password } │ Headers: X-API-Key: {apiKey} │ │ ← { accessToken, refreshToken, user }4. Authenticated requests add:Authorization: Bearer {accessToken}X-API-Key: {apiKey}5. WebSocket connects: └── STOMP CONNECT to /ws Headers: Authorization: Bearer {accessToken} X-API-Key: {apiKey} Subscribes: /topic/{tenantId}/prices/BTC_KES /topic/{tenantId}/platform /user/queue/trade-result
Tenant isolation Every user, trade, balance, and transaction is scoped to your tenant ID. You cannot see or affect another tenant's data, and they cannot affect yours. The X-API-Key header is what tells the server which tenant context to use.

Quickstart

1
Contact LeziFx to register your tenant

Email [email protected] with your intended brand name and domain. You will receive your tenant credentials — the domain gets registered in our system and your API key becomes available via /public/config.

Tell us: your brand name, app.yourdomain.com (where your trading app will live), and optionally your logo URL and primary color hex.

2
Set up your flash landing page on Cloudflare

Deploy a single HTML file to Cloudflare Pages at yourdomain.com. This is the 5–8 second branded intro that redirects users to your trading app. See Flash Landing Page for the copy-paste template.

3
Build and deploy your trading app

Use our starter template or build from scratch. Set two environment variables and deploy to any host (Vercel, Netlify, Railway, Cloudflare Pages). Your app auto-discovers its tenant by domain — no hardcoded API keys in your code.

VITE_API_BASE_URL=https://lezifx-backend-java-spring-boot-production.up.railway.app/api/v1
VITE_WS_HTTP_URL=https://lezifx-backend-java-spring-boot-production.up.railway.app/ws
4
Configure your Daraja M-Pesa credentials via the Admin Console

Log in to your Admin Console at app.yourdomain.com/admin using the admin credentials provided. Under Settings → Payments, enter your Safaricom Daraja Consumer Key, Consumer Secret, Shortcode, Passkey, and B2C credentials. Use the built-in Test button to verify before going live.

5
Adjust limits and go live

Set min/max deposit and withdrawal limits, demo balance amount, max concurrent trades per user, and whether registration is open. Toggle platform mode to NORMAL and disable the kill switch. Your platform is live.

Flash Landing Page

This is a single static HTML file you deploy to Cloudflare Pages. It acts as a branded intro — shows for ~7 seconds while the trading app loads in the background, then redirects automatically. Replace every YOUR_* placeholder with your brand values.

Cloudflare Pages deployment is free, automatic from a GitHub repo, and serves globally from edge nodes — ideal for a redirect page that needs zero backend.

How to deploy on Cloudflare Pages

  1. Create a GitHub repo and commit the index.html below as the only file
  2. Go to pages.cloudflare.com → Create project → Connect GitHub
  3. Select the repo, leave build command blank, set output directory to /
  4. Add your custom domain yourdomain.com in the Pages project settings
  5. Point your domain's DNS to Cloudflare nameservers (or use the provided CNAME/A records)
index.html — deploy as-is to Cloudflare Pages
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>YOUR_BRAND_NAME — Predict Markets & Earn</title>
  <meta name="description" content="YOUR_TAGLINE" />
  <!-- Redirect to trading app after 7s -->
  <meta http-equiv="refresh" content="7;url=https://app.yourdomain.com" />
  <link rel="canonical" href="https://yourdomain.com/" />
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    :root {
      --accent: YOUR_PRIMARY_COLOR; /* e.g. #00e5a0 */
      --bg:     #0a0a0a;
    }
    body {
      background: var(--bg);
      color: #fff;
      font-family: system-ui, -apple-system, sans-serif;
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      text-align: center;
      padding: 24px;
    }
    .logo {
      width: 72px;
      height: 72px;
      border-radius: 20px;
      background: linear-gradient(135deg, var(--accent), #00b87a);
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 32px;
      font-weight: 900;
      color: #000;
      margin: 0 auto 28px;
    }
    h1 { font-size: 36px; font-weight: 900; letter-spacing: -0.03em; margin-bottom: 12px; }
    p  { font-size: 16px; color: rgba(255,255,255,0.55); max-width: 360px; line-height: 1.6; }
    .bar-track {
      width: 220px;
      height: 3px;
      background: rgba(255,255,255,0.1);
      border-radius: 3px;
      margin: 40px auto 0;
      overflow: hidden;
    }
    .bar-fill {
      height: 100%;
      width: 0%;
      background: var(--accent);
      border-radius: 3px;
      animation: fill 7s linear forwards;
    }
    @keyframes fill { to { width: 100%; } }
    .redirect-note {
      margin-top: 16px;
      font-size: 12px;
      color: rgba(255,255,255,0.25);
    }
    .redirect-note a { color: var(--accent); text-decoration: none; }
  </style>
</head>
<body>
  <div class="logo">Y</div> <!-- Replace Y with first letter of brand -->
  <h1>YOUR_BRAND_NAME</h1>
  <p>YOUR_TAGLINE — e.g. "Predict forex, crypto & stocks. Win real money."</p>
  <div class="bar-track"><div class="bar-fill"></div></div>
  <p class="redirect-note">
    Opening your dashboard…
    <a href="https://app.yourdomain.com">Click here if not redirected</a>
  </p>
  <script>
    setTimeout(() => { window.location.href = 'https://app.yourdomain.com'; }, 7000);
  </script>
</body>
</html>
SEO tip The <meta http-equiv="refresh"> tag is understood by Googlebot. The 7-second delay gives crawlers time to index your brand name and description before following the redirect. Add your brand's Open Graph image (og:image) and JSON-LD structured data for richer search results.

Trading App Setup

Your trading dashboard lives at app.yourdomain.com. It can be built with any framework (React, Vue, Svelte, vanilla JS) and deployed anywhere. The only requirements are:

Environment variables

# .env — set these on your hosting platform (Vercel/Netlify/Railway/etc)
VITE_API_BASE_URL=https://lezifx-backend-java-spring-boot-production.up.railway.app/api/v1
VITE_WS_HTTP_URL=https://lezifx-backend-java-spring-boot-production.up.railway.app/ws

If you use Next.js replace VITE_ with NEXT_PUBLIC_. For plain JS builds, inject them via your bundler's define/env mechanism.

Bootstrap sequence (implement once on app load)

// Step 1 — fetch tenant config (no auth needed, no API key needed yet)
const getConfig = async () => {
  const res = await fetch(`${API_BASE_URL}/public/config`, {
    headers: { 'X-Domain': window.location.hostname }
  });
  return res.json(); // TenantConfig
};

// Step 2 — store the API key returned from /public/config
const config = await getConfig();
apiKey = config.apiKey;        // store in memory
tenantId = config.tenantId;    // store for WebSocket topics

// Step 3 — restore session if user was previously logged in
const refreshToken = localStorage.getItem('lzfx_rt');
if (refreshToken) {
  const tokens = await refreshSession(refreshToken);
  accessToken = tokens.accessToken;
  localStorage.setItem('lzfx_rt', tokens.refreshToken);
}

Admin Console access

Your admin dashboard is accessible at app.yourdomain.com/admin. Log in with the ADMIN credentials provided when your tenant was created. From there you can:

Request Headers

Every request to the API must include the appropriate headers listed below.

HeaderRequiredDescription
X-Domain Only on /public/config Your app's domain (e.g. app.yourdomain.com). Used to resolve which tenant you are. Send window.location.hostname on the client.
X-API-Key All requests except /public/config The API key returned by /public/config. Identifies your tenant on every subsequent call. Store in memory, not localStorage.
Authorization All authenticated endpoints Bearer {accessToken} — obtained from /auth/login or /auth/refresh. Store in memory only (never localStorage).
Content-Type POST / PUT with a body application/json
Token storage security Store the access token in memory only (a JS variable / React state / Zustand store). Store the refresh token in localStorage — it persists across page reloads. Never put the access token in localStorage or a cookie without HttpOnly. The refresh token rotates on every use, so a leaked token becomes invalid after the next refresh.

Public — Bootstrap

One public endpoint exists, called before any auth. It returns your tenant's configuration including the API key that all other requests require.

GET /public/config No auth required

Returns tenant branding, features, and API key. Identified by X-Domain header.

Response

FieldTypeDescription
tenantIdstring (UUID)Your tenant's unique identifier — used in WebSocket topic paths
brandNamestringYour brand name as configured by SuperAdmin
logoUrlstring | nullCloudinary URL for your logo
faviconUrlstring | nullCloudinary URL for your favicon
primaryColorstringHex color for primary actions and accents
accentColorstringHex color for secondary accents
supportEmailstring | nullYour support contact email
apiKeystringStore this. Required as X-API-Key on all subsequent requests
killSwitchActivebooleanIf true, show a maintenance screen — no trading allowed
platformModeWIN | NORMAL | LOSECurrent house mode
features.registrationOpenbooleanWhether new user registration is currently allowed
features.kycRequiredbooleanWhether users must complete KYC before trading
features.demoEnabledbooleanWhether demo/paper trading is available
features.defaultDemoBalancenumberStarting demo balance for new users (KES)
features.minDepositnumberMinimum deposit amount (KES)
features.maxDepositnumberMaximum deposit amount (KES)
features.minWithdrawalnumberMinimum withdrawal amount (KES)
features.maxWithdrawalnumberMaximum withdrawal amount (KES)
features.autoWithdrawalLimitnumberWithdrawals below this are processed automatically via M-Pesa B2C
features.maxConcurrentTradesnumberMax open positions per user at once

Auth

POST /auth/register X-API-Key only

Create a new player account. Does not auto-login — user must call /auth/login after.

Request body

FieldTypeRequiredDescription
emailstringYesUser email address (unique per tenant)
passwordstringYesMin 6 characters
fullNamestringYesDisplay name
phoneNumberstringNoKenyan phone (07XXXXXXXX or 2547XXXXXXXX)

Response — 201

{ "userId": "uuid", "email": "[email protected]", "message": "Registration successful" }
POST /auth/login X-API-Key only

Authenticate a user. Returns a short-lived access token (15 min) and a long-lived refresh token (30 days).

Request body

{ "email": "[email protected]", "password": "secret123" }

Response — 200

FieldTypeDescription
accessTokenstring (JWT)Store in memory. Expires in accessTokenExpiresIn seconds
refreshTokenstringStore in localStorage. Use to get a new access token before it expires
accessTokenExpiresInnumberAccess token TTL in seconds (typically 900)
user.idstring (UUID)User's unique ID
user.tenantIdstring (UUID)Your tenant ID (same as from /public/config)
user.emailstring
user.fullNamestring
user.rolePLAYER | ADMIN | SUPER_ADMINDetermines which routes/features are accessible
user.statusstringACTIVE | SUSPENDED | FLAGGED | PENDING_VERIFICATION
user.isMarketerbooleanWhether user has marketer earning status
POST /auth/refresh X-API-Key only

Exchange a refresh token for a new access token and rotated refresh token. Call this when your access token is about to expire (before making any authenticated request that gets a 401).

Request body

{ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5..." }

Response — 200

Same shape as /auth/login response. Store the new refreshToken — the old one is now revoked.

Concurrent refresh race If multiple tabs fire a refresh simultaneously, deduplicate them into a single in-flight promise. See the starter template for an example refreshOnce() implementation.
POST /auth/logout Bearer + X-API-Key

Revokes the user's refresh token server-side. Always call this on sign-out so the token cannot be reused. No request body needed. Returns 204.

GET /auth/me Bearer + X-API-Key

Returns the currently authenticated user's profile. Use this to verify a session is still valid on app load.

POST /auth/forgot-password X-API-Key only

Sends a one-time OTP to the user's email. Always returns 200 regardless of whether the email exists (no user-existence leak).

{ "email": "[email protected]" }
POST /auth/reset-password X-API-Key only

Verify the OTP from the forgot-password email and set a new password.

{ "email": "[email protected]", "otp": "123456", "newPassword": "newsecret123" }

Trading

GET /trading/pairs X-API-Key only

List all trading pairs enabled for your tenant. Call this once on load and cache it.

Response — array of

FieldTypeDescription
idstringPair UUID
symbolstringe.g. BTC_KES, USD_KES, SAFARICOM_KES
namestringDisplay name e.g. "Bitcoin / Kenyan Shilling"
categorykenyan | crypto | fiatAsset category for grouping in UI
basePricenumberCurrent reference price in KES
minStakenumberMinimum trade amount (KES)
maxStakenumberMaximum trade amount (KES)
allowedDurationsnumber[]Valid trade durations in seconds e.g. [30, 60, 120, 300]
isEnabledbooleanWhether this pair is currently tradable
GET /trading/rates Bearer + X-API-Key

Returns current payout rates per duration. Poll this every 2–5 seconds to show live-updating rates on the UI. Key is duration in seconds, value is rate (e.g. 0.75 = 75% payout on stake).

{ "30": 0.72, "60": 0.78, "120": 0.81, "300": 0.85 }
POST /trading/buy Bearer + X-API-Key

Place a prediction trade. The user predicts whether the price will be higher or lower at expiry. The backend locks in the payout rate at the moment of purchase.

Request body

FieldTypeDescription
pairSymbolstringe.g. "BTC_KES"
stakeAmountnumberAmount to stake in KES (within min/max limits)
durationSecondsnumberMust be one of the pair's allowedDurations
isDemobooleantrue to use demo balance, false for real money

Response — TradeSession

FieldTypeDescription
idstringSession UUID — use this to match the WS result event
pairSymbolstring
stakeAmountnumber
entryPricenumberPrice at trade entry (locked)
lockedPayoutRatenumberRate locked at buy time (e.g. 0.78)
durationSecondsnumber
statusACTIVE | SETTLING | COMPLETED | CANCELLEDInitial value is ACTIVE
startedAtISO 8601 string
expiresAtISO 8601 stringWhen the trade settles
isDemoboolean
When does the trade settle? At expiresAt, the backend settles the trade and pushes the result via WebSocket to /user/queue/trade-result. The result includes outcome (WIN/LOSS), profitAmount, and actualExitPrice. You do not need to poll — just listen on the socket.
GET /trading/session/active Bearer + X-API-Key

Returns the user's current active (unsettled) trade session, or null (404) if none. Use on app load to re-hydrate an interrupted session.

GET /trading/session/history?page=0&size=10 Bearer + X-API-Key

Paginated list of the user's past completed trade sessions. Returns either a Spring Page object or an array (normalize accordingly).

GET /trading/feed Bearer + X-API-Key

Returns a list of recent social feed events across all users — anonymized win/loss announcements for the "activity ticker" UI component. Safe to display publicly.

[{
  "id": "uuid",
  "eventType": "TRADE_WIN",
  "displayName": "J***n",   // anonymized
  "action": "won on BTC/KES",
  "amount": 1200,
  "pairSymbol": "BTC_KES",
  "isSimulated": false
}]

Wallet

All wallet operations use the authenticated user's context. Amounts are in KES.

GET /wallet/balance Bearer + X-API-Key

Returns the user's current balances.

{
  "liveBalance":     5000.00,    // real money balance (KES)
  "demoBalance":     10000.00,   // paper trading balance (KES)
  "marketerBalance": 1200.00     // commission balance (null if not a marketer)
}
POST /wallet/deposit Bearer + X-API-Key

Initiates an M-Pesa STK Push to the user's phone. The user receives a push prompt on their Safaricom number and approves the payment.

{ "amount": 500, "phoneNumber": "0712345678" }

Response — 202

{ "depositId": "uuid", "status": "PENDING", "message": "STK Push sent" }

Poll GET /wallet/deposits/{depositId} or listen on WebSocket /user/queue/balance for completion. Status transitions: PENDING → COMPLETED | FAILED.

GET /wallet/deposits/{depositId} Bearer + X-API-Key

Poll for deposit status. mpesaReceiptNumber is populated once COMPLETED.

{
  "depositId": "uuid",
  "status": "COMPLETED",
  "amount": 500,
  "mpesaReceiptNumber": "QKM5X4B8T7",
  "failureReason": null
}
POST /wallet/withdraw Bearer + X-API-Key

Request a withdrawal to an M-Pesa number. Withdrawals below the tenant's autoWithdrawalLimit are processed immediately via Daraja B2C. Above the limit they queue for admin approval.

{ "amount": 1000, "phoneNumber": "0712345678" }
GET /wallet/transactions?page=0&size=20&isDemo=false Bearer + X-API-Key

Paginated transaction history for the current user. Filter by type and isDemo. Transaction types: DEPOSIT | WITHDRAWAL | TRADE_STAKE | TRADE_WIN | TRADE_LOSS | TRADE_REFUND | DEMO_REFILL | BONUS | ADMIN_ADJUSTMENT

Player

GET /player/profile Bearer + X-API-Key

Returns the authenticated user's profile including current balances.

FieldTypeDescription
idstringUser UUID
emailstring
fullNamestring
phoneNumberstring | null
rolestringPLAYER | ADMIN | SUPER_ADMIN
statusstring
kycStatusstringNONE | PENDING | APPROVED | REJECTED
isMarketerboolean
liveBalancenumberReal money balance (KES)
demoBalancenumberPaper trading balance (KES)
PUT /player/profile Bearer + X-API-Key

Update editable profile fields. All fields optional (partial update).

{ "fullName": "John Kamau", "phoneNumber": "0712345678" }
GET /player/stats Bearer + X-API-Key

All-time trading statistics for the authenticated user.

{
  "totalTrades":    142,
  "wins":          87,
  "losses":        55,
  "winRate":       0.613,   // 0.0–1.0
  "totalStaked":   142000,
  "totalProfit":   18400,
  "totalDeposited": 50000,
  "totalWithdrawn": 25000
}

WebSocket / STOMP

Real-time price ticks, trade results, and platform events are delivered over a persistent WebSocket connection using the STOMP protocol over SockJS. This is the same pattern used by the reference LeziFx trading app.

Dependencies

npm install @stomp/stompjs sockjs-client

Connecting

import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

const client = new Client({
  webSocketFactory: () => new SockJS('https://lezifx-backend-java-spring-boot-production.up.railway.app/ws'),
  connectHeaders: {
    'Authorization': `Bearer ${accessToken}`,
    'X-API-Key': apiKey,
  },
  reconnectDelay:    5000,
  heartbeatIncoming: 25000,
  heartbeatOutgoing: 25000,

  onConnect: () => {
    // Subscribe to topics here (see Topics Reference below)
    client.subscribe(`/topic/${tenantId}/prices/BTC_KES`, (msg) => {
      const tick = JSON.parse(msg.body);
      console.log('price tick:', tick.price, tick.timestamp);
    });
  },

  onDisconnect:     () => console.log('disconnected'),
  onStompError:     (frame) => console.error('STOMP error', frame),
  onWebSocketClose: () => console.log('WebSocket closed'),
});

client.activate();
Connect only after login The WebSocket connection requires a valid access token. Connect after a successful /auth/login or /auth/refresh. Reconnect if the token expires by calling /auth/refresh and reconnecting with the new token.

WebSocket Topics Reference

/topic/{tenantId}/prices/{symbol}

Live price tick for a specific trading pair. Subscribe per-pair — subscribe to the pair the user is currently viewing, unsubscribe when they switch pairs.

{ "symbol": "BTC_KES", "price": 8841234.50, "timestamp": 1719123456789 }

Ticks arrive approximately every 1 second. Use the timestamp (Unix ms) as the X-axis when building a candlestick chart.

/topic/{tenantId}/platform

Platform-wide events broadcast to all users. Handle these to update your UI without polling.

// Mode change
{ "event": "MODE_CHANGE",  "mode": "WIN" }

// Kill switch toggled
{ "event": "KILL_SWITCH",  "killSwitch": true }

// Floor balance updated
{ "event": "FLOOR_UPDATE", "floorBalance": 50000 }

When killSwitch: true, show a maintenance screen and disable trading buttons.

/topic/{tenantId}/social

Live activity feed — anonymized trade events across all users. Use to power a "recent activity" ticker in your UI.

{
  "eventType":   "TRADE_WIN",
  "displayName": "J***n",
  "action":      "won on BTC/KES",
  "amount":      780
}
/user/queue/trade-result

Delivered to the individual user when their trade settles. This is how you show the WIN / LOSS result without polling.

{
  "sessionId":       "uuid",
  "pairSymbol":      "BTC_KES",
  "outcome":         "WIN",        // or "LOSS"
  "stakeAmount":     500,
  "profitAmount":    390,          // payout received (0 on LOSS)
  "actualExitPrice": 8845100.00,
  "isDemo":          false
}
/user/queue/balance

Pushed whenever the user's balance changes (deposit completed, trade settled, withdrawal processed). Update the displayed balance immediately.

{ "newBalance": 6390.00, "isDemo": false }

Error Codes

All errors return JSON in the format { "status": 4xx, "message": "...", "code": "SNAKE_CASE_CODE" }.

401 TOKEN_EXPIRED Access token has expired. Call /auth/refresh with your refresh token, then retry.
401 INVALID_CREDENTIALS Wrong email or password on /auth/login.
401 REFRESH_TOKEN_INVALID Refresh token not found or already used (rotated). User must log in again.
400 CONCURRENT_REFRESH Two tabs tried to refresh the same token simultaneously. Retry with the token from localStorage — the first request already rotated it.
403 ACCOUNT_SUSPENDED User account has been suspended by admin.
403 INVALID_API_KEY The X-API-Key header is missing or does not match any known tenant.
422 INSUFFICIENT_BALANCE User does not have enough balance for the requested stake amount.
422 ACTIVE_TRADE_EXISTS User already has an active trade session and maxConcurrentTrades is set to 1.
422 INVALID_STAKE_AMOUNT Stake is below minStake or above maxStake for the pair.
422 PAIR_DISABLED The requested trading pair is currently disabled by admin.
503 PLATFORM_OFFLINE The kill switch is active. Show a maintenance screen. Also received as a KILL_SWITCH WebSocket event.
404 TENANT_NOT_FOUND The domain sent in X-Domain is not registered for any tenant. Check domain registration with LeziFx.
409 EMAIL_ALREADY_EXISTS Registration attempted with an email already registered in this tenant.

Starter Template

A minimal Vite + React + TypeScript project that demonstrates the full connection flow. Clone it, set your env vars, and build your custom UI on top.

What's included

Get the starter template

The starter template is available on request. Email [email protected] once your tenant is registered and you will receive a GitHub repo link.

Or build from scratch — minimal API client

If you prefer to build from scratch, here is the complete single-file API client you need. Drop it in your project as lezifx-api.ts:

lezifx-api.ts — copy this into your project
/**
 * LeziFx API Client — single-file edition
 * Copy this file into your project. Set VITE_API_BASE_URL and VITE_WS_HTTP_URL.
 */
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

export const API_BASE =
  import.meta.env.VITE_API_BASE_URL ??
  'https://lezifx-backend-java-spring-boot-production.up.railway.app/api/v1';

export const WS_URL =
  import.meta.env.VITE_WS_HTTP_URL ??
  'https://lezifx-backend-java-spring-boot-production.up.railway.app/ws';

// ── Token stores ──────────────────────────────────────────────────────────────
let _apiKey = '';
let _accessToken = '';
const RT_KEY = 'lzfx_rt';

export const store = {
  setApiKey:      (k: string) => { _apiKey = k; },
  getApiKey:      () => _apiKey,
  setAccessToken: (t: string) => { _accessToken = t; },
  getAccessToken: () => _accessToken,
  setRefreshToken:(t: string) => localStorage.setItem(RT_KEY, t),
  getRefreshToken:() => localStorage.getItem(RT_KEY) ?? '',
  clearTokens:    () => { _accessToken = ''; localStorage.removeItem(RT_KEY); },
};

// ── Error type ────────────────────────────────────────────────────────────────
export class ApiError extends Error {
  constructor(
    public status: number,
    message: string,
    public code?: string,
  ) { super(message); }
}

// ── Core request ──────────────────────────────────────────────────────────────
let _refreshPromise: Promise<string> | null = null;

async function doRefresh(): Promise<string> {
  const rt = store.getRefreshToken();
  if (!rt) throw new ApiError(401, 'Session expired');
  const res = await fetch(`${API_BASE}/auth/refresh`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-API-Key': _apiKey },
    body: JSON.stringify({ refreshToken: rt }),
  });
  if (!res.ok) { store.clearTokens(); throw new ApiError(401, 'Session expired'); }
  const data = await res.json();
  store.setAccessToken(data.accessToken);
  store.setRefreshToken(data.refreshToken);
  return data.accessToken;
}

export async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    'X-API-Key': _apiKey,
    ...((opts.headers ?? {}) as any),
  };
  if (_accessToken) headers['Authorization'] = `Bearer ${_accessToken}`;

  let res = await fetch(`${API_BASE}${path}`, { ...opts, headers });

  // Auto-refresh on 401/403
  if ((res.status === 401 || res.status === 403) && store.getRefreshToken()) {
    try {
      if (!_refreshPromise) _refreshPromise = doRefresh().finally(() => { _refreshPromise = null; });
      const token = await _refreshPromise;
      headers['Authorization'] = `Bearer ${token}`;
      res = await fetch(`${API_BASE}${path}`, { ...opts, headers });
    } catch { throw new ApiError(401, 'Session expired'); }
  }

  const body = await res.json().catch(() => ({}));
  if (!res.ok) throw new ApiError(res.status, body.message ?? res.statusText, body.code);
  return body as T;
}

export const api = {
  get:    <T>(p: string) => request<T>(p),
  post:   <T>(p: string, b?: unknown) => request<T>(p, { method: 'POST', body: JSON.stringify(b) }),
  put:    <T>(p: string, b?: unknown) => request<T>(p, { method: 'PUT',  body: JSON.stringify(b) }),
  delete: <T>(p: string) => request<T>(p, { method: 'DELETE' }),
};

// ── Public bootstrap ──────────────────────────────────────────────────────────
export async function getConfig() {
  const res = await fetch(`${API_BASE}/public/config`, {
    headers: { 'X-Domain': window.location.hostname },
  });
  if (!res.ok) throw new Error('Domain not registered with LeziFx');
  return res.json();
}

// ── Auth ──────────────────────────────────────────────────────────────────────
export const auth = {
  login:    (email: string, password: string) => api.post('/auth/login', { email, password }),
  register: (email: string, password: string, fullName: string, phoneNumber?: string) =>
    api.post('/auth/register', { email, password, fullName, phoneNumber }),
  logout:   () => api.post('/auth/logout').finally(() => store.clearTokens()),
  me:       () => api.get('/auth/me'),
  refresh:  (refreshToken: string) => api.post('/auth/refresh', { refreshToken }),
  forgotPassword: (email: string) => api.post('/auth/forgot-password', { email }),
  resetPassword:  (email: string, otp: string, newPassword: string) =>
    api.post('/auth/reset-password', { email, otp, newPassword }),
};

// ── Trading ───────────────────────────────────────────────────────────────────
export const trading = {
  pairs:         () => api.get('/trading/pairs'),
  rates:         () => api.get('/trading/rates'),
  buy:           (pairSymbol: string, stakeAmount: number, durationSeconds: number, isDemo: boolean) =>
    api.post('/trading/buy', { pairSymbol, stakeAmount, durationSeconds, isDemo }),
  activeSession: () => api.get('/trading/session/active').catch(() => null),
  history:       (page = 0, size = 10) => api.get(`/trading/session/history?page=${page}&size=${size}`),
  feed:          () => api.get('/trading/feed').catch(() => []),
};

// ── Wallet ────────────────────────────────────────────────────────────────────
export const wallet = {
  balance:       () => api.get('/wallet/balance'),
  deposit:       (amount: number, phoneNumber: string) => api.post('/wallet/deposit', { amount, phoneNumber }),
  depositStatus: (id: string) => api.get(`/wallet/deposits/${id}`),
  withdraw:      (amount: number, phoneNumber: string) => api.post('/wallet/withdraw', { amount, phoneNumber }),
  transactions:  (page = 0, size = 20, isDemo?: boolean) =>
    api.get(`/wallet/transactions?page=${page}&size=${size}${isDemo != null ? `&isDemo=${isDemo}` : ''}`),
};

// ── Player ────────────────────────────────────────────────────────────────────
export const player = {
  profile:       () => api.get('/player/profile'),
  updateProfile: (fullName?: string, phoneNumber?: string) => api.put('/player/profile', { fullName, phoneNumber }),
  stats:         () => api.get('/player/stats'),
};

// ── WebSocket ─────────────────────────────────────────────────────────────────
export function createWsClient(accessToken: string, apiKey: string, tenantId: string) {
  const handlers = new Map<string, Set<(e: any) => void>>();
  const emit = (type: string, data: any) => handlers.get(type)?.forEach(h => h(data));

  const client = new Client({
    webSocketFactory: () => new SockJS(WS_URL) as any,
    connectHeaders: { Authorization: `Bearer ${accessToken}`, 'X-API-Key': apiKey },
    reconnectDelay: 5000, heartbeatIncoming: 25000, heartbeatOutgoing: 25000,
    onConnect: () => {
      emit('CONNECTED', {});
      // Platform events
      client.subscribe(`/topic/${tenantId}/platform`, m => emit('PLATFORM', JSON.parse(m.body)));
      // Social feed
      client.subscribe(`/topic/${tenantId}/social`, m => emit('SOCIAL', JSON.parse(m.body)));
      // Per-user: trade results + balance
      client.subscribe('/user/queue/trade-result', m => emit('TRADE_RESULT', JSON.parse(m.body)));
      client.subscribe('/user/queue/balance', m => emit('BALANCE', JSON.parse(m.body)));
    },
    onDisconnect: () => emit('DISCONNECTED', {}),
    onStompError: frame => emit('ERROR', frame),
  });

  return {
    connect: () => client.activate(),
    disconnect: () => client.deactivate(),
    // Subscribe to live prices for a pair. Returns unsubscribe fn.
    subscribePrices: (symbol: string, cb: (tick: any) => void) => {
      if (!client.connected) return () => {};
      const sub = client.subscribe(`/topic/${tenantId}/prices/${symbol}`, m => cb(JSON.parse(m.body)));
      return () => sub.unsubscribe();
    },
    // Event bus: 'CONNECTED' | 'DISCONNECTED' | 'PLATFORM' | 'SOCIAL' | 'TRADE_RESULT' | 'BALANCE'
    on: (type: string, handler: (e: any) => void) => {
      if (!handlers.has(type)) handlers.set(type, new Set());
      handlers.get(type)!.add(handler);
      return () => handlers.get(type)?.delete(handler);
    },
  };
}

Usage example

import { getConfig, auth, trading, wallet, store, createWsClient } from './lezifx-api';

// 1. Bootstrap on app load
const config = await getConfig();
store.setApiKey(config.apiKey);
const tenantId = config.tenantId;

// 2. Restore session
const rt = store.getRefreshToken();
if (rt) {
  const tokens = await auth.refresh(rt);
  store.setAccessToken(tokens.accessToken);
  store.setRefreshToken(tokens.refreshToken);
}

// 3. Login
const { accessToken, refreshToken, user } = await auth.login(email, password);
store.setAccessToken(accessToken);
store.setRefreshToken(refreshToken);

// 4. Connect WebSocket
const ws = createWsClient(accessToken, config.apiKey, tenantId);
ws.on('CONNECTED', () => {
  ws.subscribePrices('BTC_KES', tick => {
    console.log('price:', tick.price);
  });
});
ws.on('TRADE_RESULT', result => {
  console.log(result.outcome, result.profitAmount); // "WIN", 390
});
ws.on('BALANCE', ({ newBalance, isDemo }) => {
  console.log('balance updated:', newBalance);
});
ws.connect();

// 5. Place a trade
const session = await trading.buy('BTC_KES', 500, 60, false);
console.log('trade expires at', session.expiresAt);
// Result arrives on ws.on('TRADE_RESULT', ...)

// 6. Deposit
const deposit = await wallet.deposit(500, '0712345678');
// Poll wallet.depositStatus(deposit.depositId) until status !== 'PENDING'

FAQ

Can I use my own database?

No. All tenant data lives on the LeziFx managed infrastructure. Your tenant partition is isolated — you cannot see other tenants' data and they cannot see yours.

Can I customize the trading pairs available to my users?

Yes. In the Admin Console under Assets, you can enable or disable any trading pair. Disabled pairs are hidden from all frontends immediately.

Does LeziFx take a commission on trades?

Yes. Commission is calculated on gross profit each period. The rate and period are agreed when your tenant is provisioned. You can view pending and paid commissions in the SuperAdmin dashboard.

How do I handle the kill switch in my UI?

When killSwitchActive is true (from /public/config or the KILL_SWITCH WebSocket event), show a maintenance screen and disable all trading UI. The backend will reject any /trading/buy requests with 503 PLATFORM_OFFLINE during this time.

Can I have multiple admins?

Yes. In the Admin Console under Settings, you can create additional admin accounts for your tenant. Each admin has access to the full Admin Console for your tenant.

Where are withdrawals processed?

Withdrawals under your configured autoWithdrawalLimit are processed automatically via Safaricom Daraja B2C using your own M-Pesa credentials. Larger withdrawals queue for your admin review in the Admin Console.

What framework must I use for my frontend?

Any. The API is plain HTTP + WebSocket. We provide a React + Vite starter template, but you can use Vue, Svelte, Angular, Next.js, or plain HTML/JavaScript. The only dependencies you need are @stomp/stompjs and sockjs-client for real-time prices.

Do I need to host the frontend?

Yes — you host your own frontend on any platform (Vercel, Netlify, Cloudflare Pages, Railway, etc.). You only point two environment variables at the LeziFx backend. The backend is fully managed.