RevenueCat paywalls in Expo apps work when entitlements own the gating logic
Monetization is where many AI-built Expo apps stall. The app works, the onboarding reads well, the demo impresses — and then subscriptions arrive with StoreKit quirks, Google Play test tracks, receipt validation, restore flows, and review rejections. Teams that hand-roll billing usually ship three bugs for every edge case they cover: a purchase that never enables, a restore that loses state, and a paywall change that needs a full binary resubmission.
A purchase SDK with a hosted backend removes most of that surface. You keep one entitlement model, one paywall configuration, and one customer record across iOS and Android, while the stores keep doing what they do best: charging cards and sending receipts.
This guide shows the production pattern: Expo dev builds, one entitlement per access tier, screen gating driven by customer info, server-side sync into your own backend, and remotely editable paywalls.
Use a billing backend instead of talking to the stores directly
StoreKit and Google Play Billing are capable but unforgiving. Receipts expire,introductory offers have eligibility rules, grace periods differ per platform, and sandbox behavior rarely matches production. If your client validates receipts directly, you now own cryptography, clock skew, subscription lifecycle webhooks, and cross-platform identity — none of which is your product.
The alternative is a thin client SDK plus a service that normalizes every store event into a single customer record. Your app asks one question — does this user hold this entitlement — and the answer stays correct whether they bought on iPhone, renewed on the web, cancelled in settings, or restored on a new device.
That single question is the whole integration. Everything else in this post exists to make that question fast, testable, and review-safe.
Teams already running Expo Router auth guards in production will recognize the shape: identity resolves once at startup, guards read derived state, and screens never trust a local boolean they cannot re-verify.
Test purchases only inside development builds
Expo Go cannot carry native purchase modules, so every purchase test must run in a development build. This surprises teams once and then becomes routine: install the dev client, install the purchase packages, then run the full native build before testing anything.
npx expo install expo-dev-client
npx expo install react-native-purchases react-native-purchases-uiAfter installing, build for device or simulator with the development profile and launch through the dev client. Hot reloading JavaScript without rebuilding after a native dependency change produces confusing native-module errors that look like SDK bugs but are really stale binaries. When something fails right after an install, rebuild first and investigate second.
Keep one test account per store, document which sandbox user belongs to which scenario (fresh trial, expired subscription, cancelled-but-active, family sharing), and reset them deliberately. Most billing debugging time is really test-account confusion wearing a trench coat.
This discipline pairs well with the app store submission checklist for an AI-built app — store testing habits you build now pay off again at review time.
One codebase. iOS, Android, and web.
The Fitness Kit ships with auth, a database, and a backend already connected — no setup. Live demo at fitness-preview.otf-kit.dev.
Model access with entitlements, not product identifiers
Products are store objects: SKUs, prices, durations, introductory offers. Entitlements are your objects: pro, team, lifetime. One entitlement can sit behind many products — monthly and annual iOS SKUs plus their Android counterparts all enable the same pro entitlement.
Set up one project, connect each store you ship to, register products per store, create the entitlement, attach products to it, then group products into an offering that your paywall presents. The offering is what the user sees; the entitlement is what your code checks.
import { Platform } from 'react-native';
import { useEffect } from 'react';
import Purchases, { LOG_LEVEL } from 'react-native-purchases';
export default function App() {
useEffect(() => {
Purchases.setLogLevel(LOG_LEVEL.VERBOSE);
if (Platform.OS === 'ios') {
Purchases.configure({ apiKey: '<APPLE_API_KEY>' });
} else if (Platform.OS === 'android') {
Purchases.configure({ apiKey: '<GOOGLE_API_KEY>' });
}
}, []);
}Configure once at startup with the per-platform key, before any paywall or gating code runs. Keys differ per platform because each store project has its own credential; mixing them up is the most common first-run failure and it always looks like a network problem.
Never branch product logic on SKU strings in the client. When prices change, trials get restructured, or a new annual plan appears, only the dashboard mapping changes. Client code keeps asking about entitlements and never needs an update.
Gate screens on customer info, never on local flags
After configuration, the customer record is the source of truth. Fetch it at startup, refresh it on foreground and after any purchase or restore event, and derive every gate from its entitlements map.
import Purchases from 'react-native-purchases';
export async function hasProAccess(): Promise<boolean> {
try {
const customerInfo = await Purchases.getCustomerInfo();
return customerInfo.entitlements.active['pro'] !== undefined;
} catch {
return false;
}
}Three rules keep this solid. First, default to locked when the fetch fails — an offline user keeps whatever the last verified state was, and a failed fetch never upgrades anyone. Second, listen for customer-info updates rather than polling on a timer; refresh on app foreground, after purchase completion, and after restore. Third, keep the check synchronous from the screen's point of view by caching the last known value in state and updating it when the SDK notifies you.
If you already hardened sessions with Supabase Auth sessions in Expo that stay signed in, apply the same instinct here: identify the purchase SDK with your stable user id right after sign-in, so anonymous pre-purchase activity merges into the real account instead of stranding entitlements on a device-scoped ghost.
Mirror entitlement state into Supabase for server-side truth
Client gating handles the UI, but anything valuable — premium API routes, AI credit grants, team seats, download permissions — must be enforced server-side. The clean pattern is a webhook from the purchase backend into your own tables whenever entitlement state changes, keyed by your user id.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.EXPO_PUBLIC_SUPABASE_URL as string,
process.env.SUPABASE_SERVICE_KEY as string
);
export async function syncProStatus(
userId: string,
isProActive: boolean
) {
const { error } = await supabase
.from('profiles')
.update({
is_pro: isProActive,
pro_synced_at: new Date().toISOString(),
})
.eq('id', userId);
if (error) {
throw new Error('pro status sync failed');
}
}Run this from a server endpoint that receives verified entitlement events, never from the client directly. The client tells you what the user claims; the webhook tells you what the store confirmed. Row-level security then does the enforcement: premium reads require profiles.is_pro = true for the requesting user, so a tampered client gains nothing.
This is the same separation that makes the Supabase edge functions mobile backend pattern work — logic your client should never hold moves behind an authenticated boundary, and the phone becomes a view layer again.
Edit paywalls remotely instead of resubmitting binaries
Hard-coded paywall screens freeze your pricing experiments. Every headline tweak, trial-length test, or plan reorder becomes an app release with review latency attached. Remote paywall configuration breaks that coupling: the offering defines which products appear, the dashboard defines layout and copy variants, and the client renders whatever the current offering contains.
Ship one paywall component that renders the active offering dynamically — product names, durations, prices, and trial text all come from the offering object at runtime. Then price tests, seasonal offers, and onboarding variants become dashboard operations with instant rollout and rollback.
Keep the component defensive anyway: handle empty offerings, single-product offerings, and missing trial metadata with sensible fallbacks. A paywall that crashes when an experiment misconfigures products is worse than no experiment at all. Log offering presentation and purchase-start events so conversion changes are measurable rather than vibes-based.
When the paywall evolves alongside the rest of the product, the ship an AI MVP to production checklist is a useful final sweep — pricing is part of production readiness, not decoration applied afterwards.
Follow the review-safe launch checklist
Store review kills more monetization launches than code bugs. Walk this list before submitting anything with purchases:
- Restore purchases is visible and works on a fresh install with a previously entitled account.
- Every paid feature is unreachable without the entitlement, including deep links and restored navigation state.
- Account deletion does not orphan active subscriptions — document where cancellation happens per store.
- Sandbox and TestFlight or internal-track testing cover purchase, cancel, refund, expiry, and restore before review ever sees the build.
- Trial and introductory-offer eligibility text matches the store configuration exactly; reviewers check this line by line.
- The paywall states billing terms (duration, price, renewal) adjacent to the purchase action, not buried in settings.
- Server-side enforcement mirrors every client gate, verified by calling premium endpoints with a non-entitled token.
- Webhook handling is idempotent — duplicate entitlement events converge instead of double-granting credits or seats.
Most rejections cite items one through six. Item seven is what protects revenue after approval. Item eight is what keeps your support inbox quiet during renewal spikes.
Instrument the funnel from the start: paywall views, product selection, purchase starts, completions, restores, and cancellations. The first pricing argument after launch should be settled with numbers, not opinions.
Sources
Primary integration reference: RevenueCat Expo installation and SDK configuration — verified live; covers dev builds, SDK install, dashboard setup, entitlements, offerings, and customer-info checks.
Related production guides on this site: Expo Router auth guards in production, Supabase Auth sessions in Expo, Supabase Edge Functions for mobile backends, and app store submission checklist.
Stop wiring. Start shipping.
- Login, database, and backend already connected — nothing to set up
- iOS + Android + web from one codebase
- AI configs pre-tuned + 40+ tested prompts included