StudiosTV OS / Livewire -- Expo App Integration

Integrate an Expo React Native app with the StudiosTV OS / Livewire backend API. Use when building mobile app screens, implementing authentication, fetching content, handling deep links, or connecting to any StudiosTV OS backend endpoint. Covers universal API key flow, voucher-based registration, client-scoped content, live classes, session tracking, and AI features.

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.

Header Value
X-API-Key The universal key (env: UNIVERSAL_API_KEY)

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 clientId claim; /auth/me always returns data.client.enabledFeatures

2. Per-Client Key (dedicated partner app)

Used when a partner has their own branded app with a dedicated API key.

Header Value
X-API-Key Client-specific key (starts with fytos_...)

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

Feature Key What It Unlocks
classes On-demand video classes, class browsing, class plans
live_classes Scheduled live streaming classes, calendar view
workout_builder AI workout generation, workout player, workout plans
nutrition Nutrition tracking, food scanning, nutrition plans
coach_connect Trainer browsing, booking one-on-one sessions
ai_coach AI coaching via voice/chat (Hume integration)

URL scheme: livewire://join/VOUCHERCODE


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:
    1. voucherCode provided -> use voucher's client (voucher is redeemed)
    2. No voucher -> fall back to the default client (set in admin portal)
    3. No voucher and no default client -> returns 400 NO_DEFAULT_CLIENT
  • Per-client key mode: voucherCode is 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 client object with branding
  • Per-client key mode: user found within client scope; no client object 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.

Feature Key Description
classes On-demand class library
live_classes Live streaming classes
workout_builder AI workout generation and workout player
nutrition Nutrition tracking, food scanning, meal plans
coach_connect Connect with trainers/coaches
ai_coach AI coaching features
// 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 }
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

Video Access (requires bearer token)

Video URLs are never returned in class listing or detail endpoints. When the user taps play, request time-limited playback URLs:

GET /video/access/:classId           -- Get playback URLs for a class

Response:

{
  "success": true,
  "data": {
    "embedUrl": "https://player.vimeo.com/video/...",
    "streamUrl": "https://player.vimeo.com/play/.../hls?s=...",
    "streamExpiresAt": "2026-03-09T18:15:00Z",
    "introEmbedUrl": null,
    "introStreamUrl": null,
    "introStreamExpiresAt": null,
    "token": "<signed-jwt>",
    "expiresAt": "2026-03-09T12:15:00Z",
    "isLive": false
  }
}
  • embedUrl -- Web: load in iframe via Vimeo Player SDK (domain-restricted)
  • streamUrl -- Mobile (expo-video): direct HLS manifest URL. Pass to useVideoPlayer(streamUrl). Expires naturally (typically 6-12 hours)
  • streamExpiresAt -- When the HLS URL expires. Request a fresh URL before expiry if the user is still watching
  • introEmbedUrl / introStreamUrl -- Same as above for the intro video (if present)
  • token / expiresAt -- Backend-signed JWT (15 min for on-demand, 2 hours for live)

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:

Header Description
X-RateLimit-Limit Max requests per hour
X-RateLimit-Remaining Requests remaining
X-RateLimit-Reset Reset timestamp (ISO 8601)

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

  1. Always send X-API-Key on every request except /app/join/:code and /auth/refresh
  2. Bearer tokens expire in 15 minutes; refresh tokens last 7 days
  3. 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
  4. Emails are globally unique -- one user account per email, mapped to one client
  5. Voucher codes are case-insensitive and automatically uppercased by the backend
  6. The client object (with branding) is only included in universal key mode responses
  7. 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_CLIENT error 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 endpoints (StudiosTV OS Client API)
    • FytOS_Trainer_API.postman_collection.json -- trainer portal endpoints (StudiosTV OS Trainer API)