12 KiB
Expo Apple IAP Unified Billing — Design Spec
Date: 2026-05-02
Scope: xtablo-expo + apps/api + Supabase
Summary
Add iOS in-app subscriptions to xtablo-expo using RevenueCat, while keeping Supabase as the billing source of truth and Stripe as the existing web billing rail.
The mobile app should:
- only show a paywall after login/signup
- only show purchase actions when Supabase says the authenticated organization has no active access
- only allow the organization billing owner to purchase
- sell
soloandfounder/annualin iOS v1 - keep
teamas Stripe/web-only in v1
The backend should normalize Apple and Stripe into one internal billing state so existing authorization and upgrade logic can continue to rely on getOrganizationBillingState.
Product Decisions
- Billing target: mobile-first unified billing
- iOS billing rail: Apple in-app purchase via RevenueCat
- Web billing rail: existing Stripe flow remains in place
- Source of truth for unlocking: Supabase billing state, not client purchase callbacks
- Purchase attachment model: purchase attaches to the current authenticated user; organization access is inferred through that user being the billing owner
- Existing active subscribers: if Supabase already reports active access, the Expo app shows status only and does not offer Apple purchase
- iOS v1 purchasable plans:
solo,annual - iOS v1 excluded plan:
team
External Constraints
- Apple requires in-app purchase for unlocking digital functionality inside the app. Apps may let users access entitlements bought elsewhere, but direct in-app unlocking of digital features must use Apple’s purchase flow.
- Expo’s current in-app purchase guidance recommends native libraries such as
react-native-purchases, and this requires a development/custom build rather than Expo Go.
References:
- Apple App Review Guidelines: https://developer.apple.com/app-store/review/guidelines/
- Expo in-app purchases guide, updated March 9, 2026: https://docs.expo.dev/guides/in-app-purchases/
Architecture
High-level model
xtablo-expohandles purchase UI and restore actions through RevenueCat- RevenueCat handles StoreKit integration, renewal lifecycle, and Apple-side state changes
apps/apireceives RevenueCat webhooks and normalizes Apple subscription state into Supabase- Supabase stores first-party normalized Apple billing records alongside the existing Stripe-backed data inputs
apps/api/src/helpers/billing.tsresolves one shared organization billing state from both sources
Non-goals
- No attempt to mirror Apple subscriptions into Stripe
- No direct app unlock from local purchase success
- No iOS sale of
team - No Stripe-to-Apple migration flow in v1
- No Android billing in v1
Supabase Data Model
Add first-party Apple billing tables instead of reusing the stripe schema.
public.apple_customers
Purpose:
- map xtablo users to RevenueCat customer identity
Suggested columns:
id bigint generated ... primary keyuser_id uuid not null references public.profiles(id)revenuecat_app_user_id text not nulloriginal_app_user_id text nulllast_seen_environment text nullcreated_at timestamptz not null default now()updated_at timestamptz not null default now()
Constraints:
- unique on
user_id - unique on
revenuecat_app_user_id
public.apple_subscriptions
Purpose:
- hold normalized current and historical Apple subscription state used by billing resolution
Suggested columns:
id bigint generated ... primary keyowner_user_id uuid not null references public.profiles(id)revenuecat_app_user_id text not nullstore_product_id text not nullplan text not nullstatus text not nullenvironment text not nullstore text not null default 'app_store'original_transaction_id text not nulltransaction_id text nullcurrent_period_start timestamptz nullcurrent_period_end timestamptz nullcancel_at_period_end boolean not null default falserevoked_at timestamptz nullraw_customer_id text nulllast_event_type text nullcreated_at timestamptz not null default now()updated_at timestamptz not null default now()
Constraints and indexes:
- unique on
original_transaction_id - index on
owner_user_id - index on
status - index on
current_period_end
public.apple_subscription_events
Purpose:
- store raw RevenueCat webhook payloads for audit, replay, and idempotency debugging
Suggested columns:
id bigint generated ... primary keyevent_id text not null uniqueevent_type text not nullenvironment text nullpayload jsonb not nullreceived_at timestamptz not null default now()processed_at timestamptz null
Plan Mapping
Use explicit backend configuration for product mapping. Do not infer plan from display name.
Suggested mapping:
solo_ios_monthly->solofounder_ios_monthlyorannual_ios->annual
Rules:
- no
teamApple mapping in v1 active,trialing, grace-period-like states count as valid paid accessexpired,revoked,refunded, or equivalent inactive states do not
API Design
New webhook route
Add a route such as:
POST /api/revenuecat/webhook
Responsibilities:
- validate webhook authenticity
- persist raw event to
apple_subscription_events - resolve xtablo user from RevenueCat app user id
- upsert
apple_customers - upsert normalized
apple_subscriptions - return success even for duplicate already-processed events
Implementation notes:
- make handler idempotent
- treat event logging and normalized state upsert as one logical processing unit
- keep product-to-plan mapping in config/env, not inline literals spread across the codebase
Existing read surfaces
Prefer reusing:
GET /api/v1/users/organization
Reason:
- the payload already contains
trial_starts_at,trial_ends_at,required_plan,required_team_quantity,active_subscription_plan,active_subscription_quantity, andis_billing_owner - reusing it keeps mobile aligned with the existing web gate
Optional follow-up:
- add a smaller mobile billing endpoint later if the organization payload proves too heavy
Billing Resolution Changes
Extend apps/api/src/helpers/billing.ts so organization billing state resolves from both Stripe and Apple.
Existing behavior to preserve
- billing is organization-scoped
- the owner user determines billing ownership
- member count and required plan logic stay unchanged
- trial window logic stays unchanged
teamseat logic remains derived from Stripe/web billing only in v1
New Apple resolution behavior
Resolve Apple entitlements for the organization owner from apple_subscriptions, then combine them with the current Stripe-derived candidate selection.
Candidate precedence:
- highest plan weight:
annual>team>solo - healthiest status
- latest
current_period_end
Combination rules:
- choose one winning entitlement source; do not add Apple and Stripe quantities together
- Apple
solosatisfiesrequired_plan = solo - Apple
annualsatisfies any paid requirement in v1 - Apple never satisfies
teamseat count semantics beyondannual
Quantity rules:
- Apple
solo-> quantity1 - Apple
annual-> quantity1, but plan weight makes it satisfy the higher tier
Expo App Changes
Billing state fetch
After authentication, the app should load billing-bearing organization state and keep it available to:
- settings screen
- any post-login paywall gate
- purchase success pending-sync state
This likely means:
- extending
xtablo-expo/stores/auth.tsxbootstrap, or - adding a dedicated organization/billing hook consumed by the authenticated app shell
Paywall visibility
The paywall appears only when all of the following are true:
- the user is authenticated
- Supabase-backed billing state reports no active access
- the authenticated user is the organization billing owner
- the platform is iOS
Do not show Apple purchase CTA when:
- the org already has active Stripe or Apple access
- the user is not the billing owner
- the plan required is
teamand the app only supportssoloandannualpurchases in v1
UI flow
Suggested flow:
- user logs in
- app fetches organization billing state
- if access exists, app shows status only
- if no access and the user is the billing owner, app shows paywall
- user can purchase
soloorannual, or restore purchases - on purchase success callback, app shows “syncing purchase” state and polls billing state
- unlock only after the backend-normalized Supabase state reflects access
Placement
Recommended first placement:
- billing entry point in
xtablo-expo/app/(app)/(tabs)/settings.tsx
Acceptable alternative:
- a dedicated authenticated billing/paywall route if the settings screen becomes too crowded
Error Handling
Purchase success but backend not updated yet
- show a pending success state
- refetch organization billing state for a short bounded window
- if still unpaid, show a “purchase received, still syncing” message and expose restore/retry
Restore on different xtablo login
- do not silently transfer entitlement across users
- only associate restored state when RevenueCat identity maps to the authenticated xtablo user
- otherwise fail closed and route to support/manual resolution
Existing paid web subscriber on iOS
- do not offer Apple purchase
- show current access status only
Non-owner unpaid member on iOS
- do not offer purchase
- show explanatory copy that billing is managed by the organization owner
Organization requires team but iOS v1 cannot sell it
- do not offer a misleading in-app purchase CTA
- show that the organization needs the
teamplan - direct the user to web billing or the billing owner as appropriate
Apple cancellation/refund/revoke
- maintain access until normalized entitlement expiry when appropriate
- drop access once the normalized state is no longer valid
Offline after purchase
- never unlock from local callback alone
- show syncing/reconnect guidance until the server state can be refreshed
RevenueCat or App Store outage
- preserve existing access from last known Supabase state
- fail new purchases and restores closed
- show a retry message rather than changing billing state locally
Testing Strategy
Backend
- unit tests for Apple product-to-plan normalization
- unit tests for combined Stripe/Apple winner selection
- route tests for RevenueCat webhook idempotency
- route tests for malformed or unauthorized webhook rejection
Supabase
- migration tests for table constraints and uniqueness
- SQL or integration checks for owner-user lookup and entitlement filtering
Expo
- unpaid owner sees paywall
- paid owner does not see paywall
- unpaid non-owner does not see purchase CTA
- purchase success enters pending-sync state
- restore purchase path refreshes billing state correctly
Rollout Notes
- create App Store Connect subscription products for iOS
soloandannual - configure RevenueCat offerings and map them explicitly in backend config
- use an Expo development/custom build for local testing because Expo Go is insufficient for native IAP
- keep sandbox and production Apple environments distinct in persisted records and operational logging
Implementation Boundaries
In scope
- RevenueCat integration in
xtablo-expo - iOS post-login paywall
- owner-only purchase gating
- RevenueCat webhook ingestion in
apps/api - Apple billing tables and migrations in Supabase
- unified billing resolution in
getOrganizationBillingState
Out of scope
- iOS
teampurchase flow - Stripe-to-Apple migration
- Android billing
- cross-account entitlement transfer tooling
- direct client-side unlock without backend normalization
Likely File Touch Points
xtablo-expo/app/(app)/(tabs)/settings.tsxxtablo-expo/stores/auth.tsx- new Expo billing hooks/components
apps/api/src/helpers/billing.ts- new
apps/api/src/routers/revenuecat.tsorapple-billing.ts apps/api/src/routers/index.ts- new Supabase migrations for Apple billing tables