#
FytOS / Livewire -- Expo App Integration
#
Backend Overview
- Base URL:
https://your-api-host/api/v1(dev:http://localhost:3010/api/v1) - Protocol: REST over HTTPS, JSON request/response bodies
- Auth model: API key (header) + JWT bearer tokens (per-user)
Postman collections are the source of truth for every endpoint, request body, and response shape. They are included alongside this skill and should be referenced for exact payloads.
#
Authentication Architecture
There are two API key modes. The app should support both.
#
1. Universal Key (Livewire single app)
Used when a single app serves users from multiple partners/clients. The client context is resolved dynamically.
How client context works:
- App launch:
GET /app/config(public, no key needed) returns default client's branding and enabled features - Deep link / onboarding:
GET /app/join/:voucherCode(public, no key needed) returns specific client branding - Registration: voucher code in body maps user to a client; if no voucher, the default client is used
- Login: user looked up globally by email; client resolved from user record
- Authenticated routes: client resolved from JWT
clientIdclaim;/auth/mealways returnsdata.client.enabledFeatures
#
2. Per-Client Key (dedicated partner app)
Used when a partner has their own branded app with a dedicated API key.
All requests are automatically scoped to that client.
#
App Launch / Feature Discovery
On first launch (before any auth), the app must determine which client context to use and what features to present. This drives the entire UX: registration screens, tab bar items, and available functionality.
#
Flow
1. App launches
2. GET /api/v1/app/config (public, no auth/key needed)
3. Response: { data: { hasDefaultClient, requiresVoucher, client } }
4. If hasDefaultClient is true:
- Use client.enabledFeatures to build the onboarding/registration flow
- Use client.logo / client.splashScreen for branding
- Registration can proceed without a voucher (user assigned to default client)
5. If hasDefaultClient is false (requiresVoucher is true):
- Prompt the user for a voucher code or wait for a deep link
- Validate with GET /api/v1/app/join/{code}
6. After login/register:
- GET /api/v1/auth/me always returns data.client.enabledFeatures
- Use this to gate features in the authenticated app
#
Example: App Config Check
interface AppConfig {
hasDefaultClient: boolean;
requiresVoucher: boolean;
client: {
id: string;
name: string;
logo: string | null;
splashScreen: string | null;
enabledFeatures: string[];
} | null;
}
const getAppConfig = async (): Promise<AppConfig> => {
const res = await fetch(`${API_BASE}/app/config`);
const { data } = await res.json();
return data;
};
// On app launch:
const config = await getAppConfig();
if (config.hasDefaultClient && config.client) {
// Use config.client.enabledFeatures to build navigation
// Show config.client.splashScreen if available
} else {
// Show voucher input screen or deep link landing
}
#
enabledFeatures Reference
#
Deep Link Flow
URL scheme: livewire://join/VOUCHERCODE
1. App receives deep link -> extract voucher code
2. GET /api/v1/app/join/{code} (no auth)
3. Response: { data: { valid, client: { id, name, logo, splashScreen, enabledFeatures } } }
4. If valid -> show client-branded onboarding/signup screen
5. User registers with POST /api/v1/auth/register (voucherCode in body)
6. Backend creates user mapped to voucher's client, redeems voucher
#
Example: Deep Link Validation
const validateDeepLink = async (code: string) => {
const res = await fetch(`${API_BASE}/app/join/${code}`);
const { data } = await res.json();
if (data.valid) {
// Show branded splash: data.client.splashScreen, data.client.logo
// Store code for registration
}
};
#
Auth Endpoints
All auth endpoints require X-API-Key header (universal or per-client).
#
Register
POST /auth/register
Body: { email, password, name?, voucherCode?, sessionId?, coachId?, profile?, settings? }
Response 201: { success, data: { user, tokens: { accessToken, refreshToken, expiresIn } } }
- Universal key mode: client is resolved in this order:
voucherCodeprovided -> use voucher's client (voucher is redeemed)- No voucher -> fall back to the default client (set in admin portal)
- No voucher and no default client -> returns
400 NO_DEFAULT_CLIENT
- Per-client key mode:
voucherCodeis optional (client from API key) - Email must be globally unique (one account per email across all clients)
#
Login
POST /auth/login
Body: { email, password }
Response 200: { success, data: { user, tokens, client? } }
- Universal key mode: user found globally by email; response includes
clientobject with branding - Per-client key mode: user found within client scope; no
clientobject in response
#
Refresh Token
POST /auth/refresh
Body: { refreshToken }
Response 200: { success, data: { accessToken, expiresIn } }
No API key needed for refresh.
#
Get Current User
GET /auth/me
Headers: Authorization: Bearer {accessToken}
Response 200: { success, data: { user, client? } }
Universal key mode includes client branding in the response.
#
Token Management
// Store tokens securely (use expo-secure-store)
import * as SecureStore from 'expo-secure-store';
const storeTokens = async (tokens: { accessToken: string; refreshToken: string }) => {
await SecureStore.setItemAsync('accessToken', tokens.accessToken);
await SecureStore.setItemAsync('refreshToken', tokens.refreshToken);
};
// Create an API client with auto-refresh
const apiClient = async (path: string, options: RequestInit = {}) => {
let token = await SecureStore.getItemAsync('accessToken');
const apiKey = Config.UNIVERSAL_API_KEY; // or per-client key
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (res.status === 401) {
// Try refresh
const refreshToken = await SecureStore.getItemAsync('refreshToken');
if (refreshToken) {
const refreshRes = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (refreshRes.ok) {
const { data } = await refreshRes.json();
await SecureStore.setItemAsync('accessToken', data.accessToken);
// Retry original request
return apiClient(path, options);
}
}
// Refresh failed -> redirect to login
}
return res;
};
#
Client Capabilities / Feature Gating
Each client has an enabledFeatures array. Use it to show/hide app sections.
// After login or /auth/me, check features:
const features = client.enabledFeatures; // string[]
const hasLiveClasses = features.includes('live_classes');
const hasNutrition = features.includes('nutrition');
#
Content Discovery (requires bearer token)
Content is automatically scoped to the user's client based on assigned categories.
#
Home Screen
GET /content/discover
Response: { featured[], continueWatching[], upcomingLive[], categories[], newThisWeek[] }
#
Browse Classes
GET /content/classes?page=1&limit=20&category=1&trainer=1&intensity=BEGINNER&sort=newest
Response: { classes[], pagination: { page, limit, total, pages } }
#
Class Detail
GET /content/classes/:id
Response: { class with trainer, category, episodes, related classes }
#
Search
GET /content/search?q=yoga&page=1&limit=20
Response: { results[], pagination }
#
Categories
GET /content/categories
Response: { categories[] with class counts }
#
Live Classes (requires bearer token)
GET /live/schedule -- Upcoming live classes
POST /live/schedule/:classId -- Add to user's schedule
DELETE /live/schedule/:classId -- Remove from schedule
GET /live/my-schedule -- User's scheduled live classes
#
Session Tracking (requires bearer token)
Track video viewing for "Continue Watching" and analytics.
POST /sessions/start -- Body: { classId, totalDurationSeconds }
POST /sessions/:id/progress -- Body: { progressSeconds, completionPercentage }
POST /sessions/:id/complete -- Mark session complete
GET /sessions/history -- Viewing history with progress
#
AI Services (requires bearer token)
POST /ai/workout-plan -- Generate personalized workout plan
POST /ai/nutrition-plan -- Generate nutrition plan
POST /ai/class-plan -- Generate class recommendation plan
#
Food Scanner (requires bearer token)
POST /food-scanner/scan -- Body: multipart with image file
GET /food-scanner/formats -- Supported image formats
#
Onboarding Flow
For new users before registration:
POST /onboarding/save-profile -- Save pending profile (returns sessionId)
POST /onboarding/recommend-coaches -- Get coach recommendations
POST /onboarding/select-coach -- Select a coach
Then pass sessionId and coachId to /auth/register to link onboarding data.
#
Trainers & Bookings (requires bearer token)
GET /trainers -- List available trainers
GET /trainers/:id -- Trainer details
GET /trainers/:id/services -- Trainer services and pricing
GET /trainers/:id/availability -- Available time slots
POST /trainers/:id/book -- Book one-on-one session
POST /trainers/:id/group-classes/:id/join -- Join group class
POST /trainers/:id/programmes/:id/subscribe -- Subscribe to programme
GET /my-bookings/summary -- Booking overview
GET /my-bookings/sessions -- List sessions
#
LiveKit (Video Calls)
POST /livekit/token -- Body: { roomName, participantName }
Response: { token, wsUrl }
Use @livekit/react-native to connect with the returned token and WebSocket URL.
#
Error Handling
All error responses follow this structure:
{
"error": "Human-readable message",
"code": "MACHINE_READABLE_CODE",
"details": "Additional context"
}
Common codes: MISSING_API_KEY, INVALID_API_KEY, INVALID_CREDENTIALS, USER_EXISTS, VOUCHER_REQUIRED, INVALID_VOUCHER, USER_NOT_FOUND, RATE_LIMIT_EXCEEDED.
#
Rate Limiting
Response headers on client-scoped routes:
Handle 429 Too Many Requests with exponential backoff.
#
Environment Configuration
// app.config.ts or env
export default {
API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3010/api/v1',
UNIVERSAL_API_KEY: process.env.EXPO_PUBLIC_UNIVERSAL_API_KEY || '',
DEEP_LINK_SCHEME: 'livewire',
};
For deep linking in app.json:
{
"expo": {
"scheme": "livewire",
"ios": { "bundleIdentifier": "com.livewire.app" },
"android": { "package": "com.livewire.app" }
}
}
#
Key Integration Notes
- Always send
X-API-Keyon every request except/app/join/:codeand/auth/refresh - Bearer tokens expire in 15 minutes; refresh tokens last 7 days
- Content is client-scoped -- users only see classes in categories assigned to their client. If no categories are assigned, all content endpoints return empty arrays
- Emails are globally unique -- one user account per email, mapped to one client
- Voucher codes are case-insensitive and automatically uppercased by the backend
- The
clientobject (with branding) is only included in universal key mode responses - Default client fallback -- if a user registers without a voucher (e.g. organic signup), they are assigned to the admin-configured default client. The app should handle the
NO_DEFAULT_CLIENTerror gracefully if no default is configured
#
Additional Resources
- For exact request/response shapes, see the attached Postman collections:
FytOS_Client_API.postman_collection.json-- all user-facing endpointsFytOS_Trainer_API.postman_collection.json-- trainer portal endpoints