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
| Surface | Status | Notes |
|---|---|---|
PurchasesProvider | Native-only | Wraps RevenueCat, fetches offerings, purchases/restores, and logs into RevenueCat with the Supabase user UUID |
EntitlementProvider | Integrated | Reads subscriptions with RLS and exposes useEntitlement().isEntitled |
app/paywall.tsx | Native-only | Modal paywall; renders packages from the active RevenueCat offering |
app/redeem-code.tsx | Native-only | iOS redemption sheet; Android opens Google Play redeem URL; web shows guidance |
app/manage-subscription.tsx | Native-only | Store management link, restore, status summary from RevenueCat cache |
PremiumGate | Integrated | Locks arbitrary UI behind the Supabase entitlement boolean |
home-paywall / home-redeem-code | Native-only | Tab-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" -> unconfiguredWhen 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 stateThe 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.status | Entitled while |
|---|---|
active / trialing + one_time | Always |
active / trialing + month or year | period_end is in the future |
canceled | period_end is in the future |
unpaid / expired | Never |
unknown | Never |
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:
- Same provider events always apply, so renewals, cancellations, and expirations from that provider are reflected.
- Different provider events apply only when the incoming
period_endis 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 -> LifetimeMonthly 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.
