Subscriptions

Understand account-bound entitlements and subscription gates.

The Expo template ships a RevenueCat paywall, restore flow, code redemption, and an account-bound entitlement model. The important distinction: RevenueCat sells the product, but Supabase decides what the account can access.

The subscriptions table is the entitlement source of truth. RevenueCat CustomerInfo is purchase state and local cache only; do not use it as the gate for premium features.

What it provides

SurfaceStatusNotes
PurchasesProviderNative-onlyWraps RevenueCat, fetches offerings, purchases/restores, and logs into RevenueCat with the Supabase user UUID
EntitlementProviderIntegratedReads subscriptions with RLS and exposes useEntitlement().isEntitled
app/paywall.tsxNative-onlyModal paywall; renders packages from the active RevenueCat offering
app/redeem-code.tsxNative-onlyiOS redemption sheet; Android opens Google Play redeem URL; web shows guidance
app/manage-subscription.tsxNative-onlyStore management link, restore, status summary from RevenueCat cache
PremiumGateIntegratedLocks arbitrary UI behind the Supabase entitlement boolean
home-paywall / home-redeem-codeNative-onlyTab-context wrappers that reuse the modal experiences with tab header chrome

Purchases no-op gracefully when unconfigured. On web, or on iOS/Android without the matching EXPO_PUBLIC_REVENUECAT_*_KEY, isConfigured is false, offerings do not load, and purchase/restore calls return friendly errors.

Provider behavior

lib/purchases.tsx chooses a public RevenueCat SDK key by platform:

Platform.OS === "ios"     -> EXPO_PUBLIC_REVENUECAT_IOS_KEY
Platform.OS === "android" -> EXPO_PUBLIC_REVENUECAT_ANDROID_KEY
Platform.OS === "web"     -> unconfigured

When a signed-in Supabase user exists, PurchasesProvider calls:

Purchases.logIn(user.id);

That makes RevenueCat app_user_id equal the Supabase UUID, so webhook events can upsert the row where subscriptions.user_id is the same value. When the RevenueCat customer-info listener fires, the app refreshes the Supabase entitlement query too.

Account-bound flow

Store purchase
  -> RevenueCat webhook
  -> Supabase Edge Function
  -> public.subscriptions row
  -> EntitlementProvider
  -> useEntitlement().isEntitled
  -> PremiumGate / Home card / Me row / paywall owned state

The current server mapper in supabase/functions/_shared/revenuecat.ts accepts APP_STORE and MAC_APP_STORE events. PLAY_STORE events are deliberately ignored today, so iOS purchases are end-to-end with the shipped backend, while Android needs the mapper and database enum extended before Google Play purchases can grant the shared Supabase entitlement.

Entitlement truth table

isSubscriptionEntitled() in lib/entitlement.tsx is the single gate:

subscriptions.statusEntitled while
active / trialing + one_timeAlways
active / trialing + month or yearperiod_end is in the future
canceledperiod_end is in the future
unpaid / expiredNever
unknownNever

Signed-out, loading, and error states are never entitled. Gates should stay locked when the state is indeterminate.

Cross-provider conflict rule

The subscriptions table has one current row per user. That keeps the starter a single-tier product, but it means webhooks need deterministic takeover rules:

  1. Same provider events always apply, so renewals, cancellations, and expirations from that provider are reflected.
  2. Different provider events apply only when the incoming period_end is later than the existing row.

This prevents an Apple purchase from shortening an active Creem desktop plan, and vice versa.

Paywall packages

The app ships no product-ID config. app/paywall.tsx renders whichever packages are present in offerings.current.availablePackages, sorted:

Monthly -> Annual -> Lifetime

Monthly and Annual are analytics-labeled as tier: "pro"; Lifetime is labeled as tier: "lifetime". That classifyPurchase() value is only for the purchase completed analytics event. It is not used for gating.

The paywall pre-selects Annual when present, shows annual savings if RevenueCat returns price-per-month data, and calls entitlement.pollAfterPurchase() after purchase or restore so the UI can catch the webhook write.

Single-tier model

The starter has one Pro entitlement and one boolean gate:

const { isEntitled } = useEntitlement();

To add real tiers, change the data model first: remove or relax the subscriptions.user_id uniqueness assumption, introduce a product-to-feature map, and expose tier-aware helpers from EntitlementProvider. Do not start by branching on RevenueCat product IDs in UI components; that would miss desktop, manual, or future server-side grants.

On this page