Skip to main content
The OpenInsure mobile app gives policyholders native access to their coverage, claims, payments, and documents on iOS and Android. Built with Expo SDK 55 and React Native’s New Architecture.

Expo SDK 55

React Native 0.84, New Architecture, Expo Router, NativeWind 5

OTP Auth

2-step OTP login + biometric (Face ID / Touch ID) + SecureStore JWT

Offline First

useOfflineQuery caches API responses (7-day TTL) + PDF cache (30-day)

EAS Build

Fingerprint → OTA update or native build + store submit

Architecture

apps/mobile/
├── app/
│   ├── _layout.tsx              # Root: SplashScreen, AuthGate, providers
│   ├── login.tsx                # 2-step OTP login (policy number → 6-digit code)
│   ├── auth/verify.tsx          # Magic link deep-link handler
│   └── (tabs)/
│       ├── _layout.tsx          # NativeTabs (5 tabs)
│       ├── (home,claims)/
│       │   ├── home.tsx         # Dashboard: KPIs, quick actions, recent claims
│       │   ├── claims.tsx       # Claims list (FlatList, offline cached)
│       │   ├── file-claim.tsx   # FNOL form (Zod validated)
│       │   ├── i/[id].tsx       # Claim detail + error boundary
│       │   ├── support.tsx      # AI chat support
│       │   └── members/         # Policy member management (admin)
│       ├── (policy)/
│       │   ├── index.tsx        # Policy detail + coverages + endorsements
│       │   ├── id-card.tsx      # Digital ID card with QR code
│       │   ├── renewal.tsx      # Renewal intent form
│       │   ├── request-coi.tsx  # COI request (Zod validated)
│       │   └── request-endorsement.tsx  # Endorsement request (Zod validated)
│       ├── (documents)/
│       │   └── index.tsx        # Document vault (authenticated download)
│       ├── (payments)/
│       │   └── index.tsx        # Invoices + Stripe payment initiation
│       └── (settings)/
│           ├── index.tsx        # Account, biometric, notifications, sign-out
│           └── profile.tsx      # Edit profile (Zod validated)
├── components/
│   └── ui/                      # NativeWind primitives: Button, Input, Card, etc.
├── lib/
│   ├── api-provider.tsx         # MobileApiProvider: QueryClient + token injection
│   ├── store/auth-mobile.ts     # TanStack Store: OTP auth + biometric + SecureStore
│   ├── use-offline-query.ts     # Cache wrapper for TanStack Query
│   ├── use-reduced-motion.ts    # Accessibility: respect system reduce-motion
│   ├── validation.ts            # Zod v4 schemas (FNOL, COI, endorsement, profile, member)
│   ├── offline-cache.ts         # AsyncStorage (JSON) + FileSystem (PDF) caching
│   ├── document-download.ts     # PDF download → share sheet
│   ├── notifications.ts         # Push token registration
│   ├── sentry.ts                # Error tracking (PII-filtered)
│   └── brand.ts                 # White-label config
├── test/
│   ├── setup.ts                 # Vitest mocks for RN modules
│   └── mocks/factories.ts       # Realistic insurance mock data
└── eas.json                     # Build profiles + App Store submit config
EAS project: 2c00cf79-cdc7-40e0-a4bd-6e4866336b22 Account / slug: mhcis / openinsure Bundle IDs: dev.openinsure.mobile (iOS + Android) Apple Team ID: 2D65GH64H3 | ASC App ID: 6761020503

Authentication

Auth state lives in a TanStack Store at lib/store/auth-mobile.ts. There is no React Context — all screens subscribe directly with individual selectors.
const user = useAuthStore((s) => s.user);
const requestOtp = useAuthStore((s) => s.requestOtp);
const verifyOtp = useAuthStore((s) => s.verifyOtp);

OTP Login Flow

  1. User enters their policy number on the login screen 2. App calls POST /auth/policyholder-otp-request with the policy number 3. API sends a 6-digit OTP to the email on file (10-minute expiry) 4. User enters the OTP code 5. App calls POST /auth/policyholder-token with policy number + OTP 6. API returns a JWT (8-hour expiry) — stored in SecureStore (Keychain) 7. Push notification token is registered (best-effort)

Biometric (Face ID / Touch ID)

On app launch, app/_layout.tsx blocks routing behind a biometricPending gate. If biometric is enabled and the device supports it, a Face ID / Touch ID prompt fires before any screen renders. Failed biometric clears the session and forces re-login via OTP.

Session Teardown

clearSession() clears: SecureStore JWT, biometric flag, offline caches (AsyncStorage + FileSystem PDFs), and the TanStack Query cache via the API provider’s token-change listener. No PII persists on device after logout.

API Integration

The mobile app calls the API directly with a Bearer JWT — no Next.js proxy layer.

Endpoint Mapping

Mobile ScreenAPI EndpointMethod
Home (policy)/v1/portal/policies/:idGET
Home (claims)/v1/claims?orgId=...&policyId=...GET
Home (invoices)/v1/portal/invoicesGET
Claims list/v1/claimsGET
File claim/v1/claims/fnolPOST
Policy detail/v1/portal/policies/:idGET
Coverages/v1/policies/:id/jacket/coveragesGET
Endorsements/v1/policies/:id/endorsementsGET
Request COI/v1/coi/generatePOST
Request endorsement/v1/policies/:id/endorsementsPOST
Documents/v1/portal/documentsGET
Document download/v1/portal/documents/:id/downloadGET
Invoices/v1/portal/invoicesGET
Pay invoice/v1/invoices/:id/payment-intentPOST
Profile/v1/portal/profileGET / PUT
Push token/v1/portal/push-tokensPOST
Support chat/v1/chatPOST
Renewal intent/v1/policies/renewal/intentPOST
The MobileApiProvider creates a fetcher that prepends the API base URL and injects the Bearer token from the auth store. 401 responses auto-clear the session.

Offline Support

useOfflineQuery

lib/use-offline-query.ts wraps useApiQuery with offline cache fallback:
  • On successful fetch → persists to AsyncStorage via setCache(path, data)
  • On fetch failure when offline → returns cached data with isStale: true
  • Screens show a “Showing cached data” banner when serving stale data
Used on: Home dashboard, policy detail, and claims list.

Cache TTLs

CacheTTLStorage
API JSON responses7 daysAsyncStorage
PDF documents30 daysFileSystem
pruneExpired() runs on app start to clean up stale entries.

Form Validation

All 6 form screens use Zod v4 schemas from lib/validation.ts:
FormSchemaFields Validated
File ClaimfnolClaimSchemalossDate, lossDescription (min 10 chars), estimatedLoss (optional, positive)
Request COIcoiRequestSchemaholderName, holderAddress (required)
Request EndorsementendorsementRequestSchemachangeType, description (min 10 chars)
Edit ProfileprofileSchemaemail, phone, ZIP (format validated)
Add MembermemberSchemaentityName, email, EIN (XX-XXXXXXX), memberType
The <Input error={}> component displays inline validation errors with red border + helper text.

Testing

42 tests across 3 test files, run with Vitest:
pnpm --filter @openinsure/mobile test
Test FileTestsCoverage
lib/__tests__/validation.test.ts15All 5 Zod schemas, edge cases
lib/__tests__/offline-cache.test.ts8TTL expiry, pruning, JSON parse errors
lib/store/__tests__/auth-mobile.test.ts19OTP request/verify, init, logout, biometric, 401

Accessibility

  • Reduce Motion: useReducedMotion() hook gates Reanimated entry animations on 3 key screens
  • Accessibility labels: Button (accessibilityRole="button"), Input (accessibilityLabel, accessibilityState), ListItem (accessibilityLabel, accessibilityHint)
  • Privacy Manifest: Declares no tracking, email + crash data collection for app functionality

Icon System

The app uses phosphor-react-native@^3.0.3 (NOT @phosphor-icons/react — that’s for web). Tab bar icons use Expo’s NativeTabs.Trigger.Icon sf="..." SF Symbols.
import { Bell, FileText, CreditCard } from 'phosphor-react-native';

EAS Build Workflows

Workflows live in apps/mobile/.eas/workflows/.

Production (production.yml)

Triggered by CircleCI deploy-mobile job on merge to master.
fingerprint → get-build (check existing)
  ├─ JS-only change → OTA update to production channel (minutes)
  └─ Native change  → full build + App Store / Play Store submit (hours)

Preview (preview.yml)

Delivers OTA update to preview channel — testers get changes instantly.

Development (development.yml)

Manual trigger. Builds dev client for local debugging with expo start --dev-client.

CircleCI Integration

The deploy-mobile job in .circleci/config.yml:
  1. Gates on full CI suite (test, typecheck, lint, slo-check)
  2. Maps EXPO_CIRCLECI_ACCESS_TOKENEXPO_TOKEN
  3. Fails fast if token is missing
  4. Calls eas workflow:run .eas/workflows/production.yml

Local Development

cd apps/mobile

# Start Metro bundler
npx expo start

# Run on iOS simulator
npx expo run:ios

# Run on Android emulator
npx expo run:android

# Point at local API worker
EXPO_PUBLIC_API_URL=http://localhost:8787 npx expo start

# Run tests
pnpm test

First Build Setup

Caution: Apple credentials must be set up interactively the first time. Run eas build --profile production --platform ios and follow the prompts to generate a Distribution Certificate and provisioning profile.
cd apps/mobile

# iOS production build (interactive first time for Apple credentials)
eas build --platform ios --profile production

# Android production build
eas build --platform android --profile production

# Submit to stores
eas submit --platform ios --profile production
eas submit --platform android --profile production