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.
https://lezifx-backend-java-spring-boot-production.up.railway.app/api/v1WebSocket endpoint:
https://lezifx-backend-java-spring-boot-production.up.railway.app/ws
What you get as a tenant
- Your own isolated database partition — users, balances, trades are fully scoped to your tenant
- Your own branding — logo, colors, brand name, favicon served from
/public/config - Your own M-Pesa Daraja credentials — deposits and withdrawals go to your account
- Your own Admin Console — user management, withdrawals, kill-switch, platform mode
- Real-time house balance tracking with floor-balance protection
- Platform modes:
WIN/NORMAL/LOSE(adjustable live) - Social feed — live activity across your tenant's users
- Demo mode — players can trade without depositing real money
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.
X-API-Key header is what tells the server which tenant context to use.
Quickstart
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.
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.
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
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.
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
- Create a GitHub repo and commit the
index.htmlbelow as the only file - Go to pages.cloudflare.com → Create project → Connect GitHub
- Select the repo, leave build command blank, set output directory to
/ - Add your custom domain
yourdomain.comin the Pages project settings - 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>
<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:
- Must be served over HTTPS
- The domain must be registered with LeziFx as an allowed origin for your tenant
- Set the two environment variables below at build time
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:
- View real-time KPIs (users, active traders, deposits, withdrawals, gross profit)
- Manage users — suspend, activate, reset demo balance
- Approve or reject withdrawal requests
- Enable/disable trading pairs (assets)
- Set platform limits (min/max deposit, withdrawal, concurrent trades)
- Configure Daraja M-Pesa credentials
- Control platform mode (WIN / NORMAL / LOSE) and kill-switch
- Broadcast alerts to all users
- View transaction history and audit logs
Request Headers
Every request to the API must include the appropriate headers listed below.
| Header | Required | Description |
|---|---|---|
| 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 |
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.
Returns tenant branding, features, and API key. Identified by X-Domain header.
Response
| Field | Type | Description |
|---|---|---|
| tenantId | string (UUID) | Your tenant's unique identifier — used in WebSocket topic paths |
| brandName | string | Your brand name as configured by SuperAdmin |
| logoUrl | string | null | Cloudinary URL for your logo |
| faviconUrl | string | null | Cloudinary URL for your favicon |
| primaryColor | string | Hex color for primary actions and accents |
| accentColor | string | Hex color for secondary accents |
| supportEmail | string | null | Your support contact email |
| apiKey | string | Store this. Required as X-API-Key on all subsequent requests |
| killSwitchActive | boolean | If true, show a maintenance screen — no trading allowed |
| platformMode | WIN | NORMAL | LOSE | Current house mode |
| features.registrationOpen | boolean | Whether new user registration is currently allowed |
| features.kycRequired | boolean | Whether users must complete KYC before trading |
| features.demoEnabled | boolean | Whether demo/paper trading is available |
| features.defaultDemoBalance | number | Starting demo balance for new users (KES) |
| features.minDeposit | number | Minimum deposit amount (KES) |
| features.maxDeposit | number | Maximum deposit amount (KES) |
| features.minWithdrawal | number | Minimum withdrawal amount (KES) |
| features.maxWithdrawal | number | Maximum withdrawal amount (KES) |
| features.autoWithdrawalLimit | number | Withdrawals below this are processed automatically via M-Pesa B2C |
| features.maxConcurrentTrades | number | Max open positions per user at once |
Auth
Create a new player account. Does not auto-login — user must call /auth/login after.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| string | Yes | User email address (unique per tenant) | |
| password | string | Yes | Min 6 characters |
| fullName | string | Yes | Display name |
| phoneNumber | string | No | Kenyan phone (07XXXXXXXX or 2547XXXXXXXX) |
Response — 201
{ "userId": "uuid", "email": "[email protected]", "message": "Registration successful" }
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
| Field | Type | Description |
|---|---|---|
| accessToken | string (JWT) | Store in memory. Expires in accessTokenExpiresIn seconds |
| refreshToken | string | Store in localStorage. Use to get a new access token before it expires |
| accessTokenExpiresIn | number | Access token TTL in seconds (typically 900) |
| user.id | string (UUID) | User's unique ID |
| user.tenantId | string (UUID) | Your tenant ID (same as from /public/config) |
| user.email | string | |
| user.fullName | string | |
| user.role | PLAYER | ADMIN | SUPER_ADMIN | Determines which routes/features are accessible |
| user.status | string | ACTIVE | SUSPENDED | FLAGGED | PENDING_VERIFICATION |
| user.isMarketer | boolean | Whether user has marketer earning status |
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.
refreshOnce() implementation.
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.
Returns the currently authenticated user's profile. Use this to verify a session is still valid on app load.
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]" }
Verify the OTP from the forgot-password email and set a new password.
{ "email": "[email protected]", "otp": "123456", "newPassword": "newsecret123" }
Trading
List all trading pairs enabled for your tenant. Call this once on load and cache it.
Response — array of
| Field | Type | Description |
|---|---|---|
| id | string | Pair UUID |
| symbol | string | e.g. BTC_KES, USD_KES, SAFARICOM_KES |
| name | string | Display name e.g. "Bitcoin / Kenyan Shilling" |
| category | kenyan | crypto | fiat | Asset category for grouping in UI |
| basePrice | number | Current reference price in KES |
| minStake | number | Minimum trade amount (KES) |
| maxStake | number | Maximum trade amount (KES) |
| allowedDurations | number[] | Valid trade durations in seconds e.g. [30, 60, 120, 300] |
| isEnabled | boolean | Whether this pair is currently tradable |
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 }
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
| Field | Type | Description |
|---|---|---|
| pairSymbol | string | e.g. "BTC_KES" |
| stakeAmount | number | Amount to stake in KES (within min/max limits) |
| durationSeconds | number | Must be one of the pair's allowedDurations |
| isDemo | boolean | true to use demo balance, false for real money |
Response — TradeSession
| Field | Type | Description |
|---|---|---|
| id | string | Session UUID — use this to match the WS result event |
| pairSymbol | string | |
| stakeAmount | number | |
| entryPrice | number | Price at trade entry (locked) |
| lockedPayoutRate | number | Rate locked at buy time (e.g. 0.78) |
| durationSeconds | number | |
| status | ACTIVE | SETTLING | COMPLETED | CANCELLED | Initial value is ACTIVE |
| startedAt | ISO 8601 string | |
| expiresAt | ISO 8601 string | When the trade settles |
| isDemo | boolean |
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.
Returns the user's current active (unsettled) trade session, or null (404) if none. Use on app load to re-hydrate an interrupted session.
Paginated list of the user's past completed trade sessions. Returns either a Spring Page object or an array (normalize accordingly).
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.
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)
}
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.
Poll for deposit status. mpesaReceiptNumber is populated once COMPLETED.
{
"depositId": "uuid",
"status": "COMPLETED",
"amount": 500,
"mpesaReceiptNumber": "QKM5X4B8T7",
"failureReason": null
}
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" }
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
Returns the authenticated user's profile including current balances.
| Field | Type | Description |
|---|---|---|
| id | string | User UUID |
| string | ||
| fullName | string | |
| phoneNumber | string | null | |
| role | string | PLAYER | ADMIN | SUPER_ADMIN |
| status | string | |
| kycStatus | string | NONE | PENDING | APPROVED | REJECTED |
| isMarketer | boolean | |
| liveBalance | number | Real money balance (KES) |
| demoBalance | number | Paper trading balance (KES) |
Update editable profile fields. All fields optional (partial update).
{ "fullName": "John Kamau", "phoneNumber": "0712345678" }
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();
/auth/login or /auth/refresh. Reconnect if the token expires by
calling /auth/refresh and reconnecting with the new token.
WebSocket Topics Reference
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.
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.
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
}
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
}
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" }.
/auth/refresh with your refresh token, then retry.
/auth/login.
X-API-Key header is missing or does not match any known tenant.
maxConcurrentTrades is set to 1.
minStake or above maxStake for the pair.
KILL_SWITCH WebSocket event.
X-Domain is not registered for any tenant. Check domain registration with LeziFx.
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
- Single-file API client (
src/lezifx-api.ts) — all endpoints, types, token management, and WebSocket client - Bootstrap hook — fetches
/public/config, restores session, connects WebSocket - Login form with access + refresh token handling
- Live price ticker (WebSocket)
- Trading pairs list from backend
- Place trade + show WIN/LOSS result from WebSocket
- Balance display with real-time updates
- Logout with token revocation
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.