API Client
Base URL Selection
Defined at the top of ftl-frontend/src/lib/api.ts. The correct URL is selected at build time and the unused branches are tree-shaken.
STG_API_BASE = 'https://api.ftljeta.cloud/api'
PRD_API_BASE = 'https://prd-api.ftljeta.cloud/api'
BASE = import.meta.env.PROD ? (MODE === 'staging' ? STG : PRD) : '/api'
In dev (pnpm dev), import.meta.env.PROD is false, so BASE = '/api' and Vite's proxy handles the rewrite to localhost:8080.
Decision: URLs are hardcoded constants, not read from VITE_API_BASE. A missing or malformed env var once shipped a broken bundle; hardcoding prevents that class of error.
The request() Wrapper
request<T>(path, options?, opts?) in ftl-frontend/src/lib/api.ts:
- Always sets
credentials: 'include' so auth cookies travel with every request.
- Appends
Content-Type: application/json unless the caller overrides it.
- On a
401 response, runs tryRefresh() before retrying once.
tryRefresh() uses single-flight coalescing: if N in-flight requests all get a 401 at the same time, only one POST /api/auth/refresh actually runs; the others wait for that promise to settle.
- Refresh outcomes:
'ok' → retry the original request with the fresh cookie.
'unauthorized' → the refresh token is dead; call onAuthLost() (clears local state + routes to /landing) then throw Error('Unauthorized').
'error' (transient failure) → retry tryRefresh() once more. If still 'error', throw a 503 error without clearing local state. The session is intact; the next user action will retry.
onAuthLost is registered by useAuthStore on boot via setOnAuthLost(), decoupling the api module from the store to avoid a circular import.
API Method Groups
Auth
| Method |
Endpoint |
Notes |
api.authGoogle(idToken, nonce) |
POST /auth/google |
Peeks at 422 (needsProfile) without throwing |
api.authGoogleComplete(payload) |
POST /auth/google/complete |
Completes profile form step |
api.authVerifyOtp(ticket, otp) |
POST /auth/verify-otp |
6-digit OTP; 401 payload carries attemptsLeft |
api.authResendOtp(ticket) |
POST /auth/resend-otp |
429 if inside 60 s cooldown |
api.refresh() |
POST /auth/refresh |
Raw fetch, returns boolean |
api.logout() |
POST /auth/logout |
Clears server cookies |
api.checkUsername(username) |
POST /users/check-username |
|
api.publicReferralValidate(code) |
GET /public/referral/validate?code=… |
Pre-auth, always returns 200 |
api.getWsToken() |
POST /ws/token |
Returns short-lived { token } |
api.authDev(...) |
POST /auth/dev |
Local dev only; gated by VITE_DEV_AUTH |
User
| Method |
Endpoint |
api.getMe() |
GET /me |
Instruments
| Method |
Endpoint |
api.getInstruments() |
GET /instruments |
api.getInstrument(id) |
GET /instruments/:id |
api.getPriceHistory(id, period) |
GET /instruments/:id/price-history?period= |
api.getMovers() |
GET /instruments/movers |
api.getRecentTrades(id, limit) |
GET /instruments/:id/recent-trades?limit= |
PeriodId values: '5m' | '6h' | '1d' | 'all'.
Matches
| Method |
Endpoint |
api.getMatches() |
GET /matches |
api.getMatchEvents(matchId, limit) |
GET /matches/:id/events?limit= |
api.getSquad(matchId, team) |
GET /matches/:id/squad?team= |
Positions (CFD)
| Method |
Endpoint |
Notes |
api.openPosition(data) |
POST /positions/open |
Sends Idempotency-Key header |
api.closePosition(positionId, opts) |
POST /positions/:id/close |
Sends Idempotency-Key header |
api.modifyPosition(positionId, body) |
PATCH /positions/:id |
SL/TP levels; use clearStopLoss: true to clear |
api.listPositions(opts) |
GET /positions?status=&limit=&offset= |
|
Portfolio
| Method |
Endpoint |
api.getPortfolio() |
GET /portfolio |
api.getWallet() |
GET /wallet |
api.getTrades(limit, offset) |
GET /trades?limit=&offset= |
Leaderboard
| Method |
Endpoint |
api.getGlobalLeaderboard(limit) |
GET /leaderboard/global |
api.getDistrictLeaderboard(id, limit) |
GET /leaderboard/district/:districtId |
api.getFriendsLeaderboard(limit) |
GET /leaderboard/friends |
api.getMyRank() |
GET /leaderboard/me |
api.getROIGlobal(limit) |
GET /leaderboard/roi/global |
api.getROIDistrict(id, limit) |
GET /leaderboard/roi/district/:districtId |
api.getROIFriends(limit) |
GET /leaderboard/roi/friends |
api.getDistrictStandings() |
GET /districts/standings |
api.getSpinnerCurrent() |
GET /spinner/current |
api.getSpinnerHistory(limit) |
GET /spinner/history |
api.getRankContext() |
GET /leaderboard/me/context |
Notifications
| Method |
Endpoint |
api.listNotifications(limit, unreadOnly) |
GET /notifications |
api.unreadNotificationCount() |
GET /notifications/unread-count |
api.markNotificationRead(id) |
POST /notifications/:id/read |
api.markAllNotificationsRead() |
POST /notifications/read-all |
Referral
| Method |
Endpoint |
api.validateReferral(code) |
POST /referral/validate |
Achievements
| Method |
Endpoint |
api.getAchievements() |
GET /achievements |
Activity
| Method |
Endpoint |
api.getActivity(scope, limit, districtId) |
GET /activity?scope=&limit=&districtId= |
Feature Flags
| Method |
Endpoint |
Auth |
api.getPublicFlags() |
GET /flags |
Public |
api.adminListFlags() |
GET /admin/flags |
Admin only |
api.adminSetFlag(key, enabled) |
PUT /admin/flags/:key |
Admin only |
Admin Settings
| Method |
Endpoint |
api.adminListSettings() |
GET /admin/settings |
api.adminSetSetting(key, value) |
PUT /admin/settings/:key |
api.adminScoringMultiplierKeys() |
GET /admin/settings/scoring/multiplier-keys |
Admin Users
| Method |
Endpoint |
api.adminListUsers(limit, offset) |
GET /admin/users |
api.adminUsersExportUrl() |
Returns the absolute CSV download URL |
Admin Bot
| Method |
Endpoint |
api.adminGameplayBotFixtures() |
GET /admin/gameplay-bot/fixtures |
api.adminGameplayBotStatus() |
GET /admin/gameplay-bot/status |
api.adminGameplayBotStart(fixture, speedSec) |
POST /admin/gameplay-bot/start |
api.adminGameplayBotStop() |
POST /admin/gameplay-bot/stop |
WebSocket Client (useWebSocket)
Defined in ftl-frontend/src/hooks/useWebSocket.ts. The hook is instantiated once, inside GlobalPriceSubscriber (ftl-frontend/src/components/GlobalPriceSubscriber.tsx), which is mounted at the top of the App tree whenever the user is authenticated and coming_soon is off.
Connection Sequence
- Call
api.getWsToken() → POST /api/ws/token → short-lived token (60 s).
- Build the WebSocket URL: in production,
wss://ws.ftljeta.cloud/ws/prices?token=… (staging: wss://ws.ftljeta.cloud); in dev, same-origin so Vite's proxy can rewrite to ws://localhost:8081.
- Set
ws.binaryType = 'arraybuffer'.
- On
open: send { action: 'subscribe', instrumentId } for all tracked instruments, then send { action: 'subscribe_portfolio' } to receive portfolio frames.
Binary Msgpack Frames
Each price update is ~21 bytes:
| Field |
Wire name |
Type |
Description |
| instrument index |
i |
uint16 |
Resolved to UUID via useInstrumentIndex |
| price |
p |
float64 |
Current price in points |
| source |
s |
uint8 |
0=live, 1=trade, 2=postmatch, 3=replay, 4=synthetic |
On receipt: decode with @msgpack/msgpack, look up instrumentId = byIdx[raw.i], call usePriceStore.setPrice(), bump useWsStore.bumpFrame(), then call useAuthStore.recomputeEquityFromPositions().
Text JSON Frames
Portfolio updates are sent as text JSON with kind: 'portfolio'. On receipt: call useAuthStore.syncFromCfdPortfolio(frame). If lastEvent === 'position_close' and positionId is present, also call useAuthStore.removeCfdPosition(positionId). If lastEvent === 'position_open', call useAuthStore.bumpTradesEpoch().
Subscribe / Unsubscribe
const unsubscribe = subscribe(instrumentId, (update: PriceUpdate) => { ... })
// ...
unsubscribe() // sends { action: 'unsubscribe', instrumentId } when last listener is removed
Reconnection
Exponential backoff: 3 s, 6 s, 12 s, 24 s, capped at 30 s. A 503 from getWsToken() (backend ComingSoonGate) stops retrying — the gate won't lift until an admin toggles the flag and the user reloads.