Skip to content

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

  1. Call api.getWsToken()POST /api/ws/token → short-lived token (60 s).
  2. 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.
  3. Set ws.binaryType = 'arraybuffer'.
  4. 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.