RevenueCat
Set up RevenueCat for payments in an Expo app with ReadyNative: in-app subscriptions + paywall · dev build required (no Expo Go)
Expo Go: no - dev build required
Ships native code. Make a dev build with bunx expo run:ios / bunx expo run:android (or bunx eas-cli build --profile development).
This gives you in-app subscriptions on both stores through RevenueCat: src/lib/payments.ts implements the shim on react-native-purchases, a PaymentsProvider (order 70) keeps the RevenueCat app user id on the signed-in user and listens for customer-info changes, and /paywall ships as a modal with your feature bullets, the current offering's packages and prices, Buy, the store's renewal terms, "Compare all plans", Restore, and Terms/Privacy links from readynative.config.ts urls. If you set up with --with-examples there's an example at /examples/paywall and jest mocks for both native packages. @/lib/payments keeps the same API whichever option you pick - pick RevenueCat when you want StoreKit and Play Billing handled for you, plus remote paywalls you can edit without shipping an update.
Setup
bun run setup --payments revenuecat- Create a project at app.revenuecat.com, then add an App Store app and a Play Store app under [Project settings] → [Apps]. Use the bundle id from
readynative.config.tsapp.ios.bundleId(plus.devfor the dev variant) and the matching Android package. - Open [Project settings] → [API keys]. Copy the App Store key (
appl_…) into.envasEXPO_PUBLIC_REVENUECAT_IOS_KEYand the Play Store key (goog_…) asEXPO_PUBLIC_REVENUECAT_ANDROID_KEY. Both are public SDK keys. Without the key for the platform you're running, the module no-ops:useEntitlements()returns{ active: [], loading: false }and/paywallshows "Configure RevenueCat". - Create the products in [App Store Connect] → [Subscriptions] and [Play Console] → [Monetize] → [Subscriptions], then add them in RevenueCat under [Products].
- Open [Entitlements] and create one with the identifier
pro(PRO_ENTITLEMENTinsrc/lib/payments.ts- change the constant if you pick another id), then attach your products to it. - Open [Offerings], create an offering with a monthly package, and mark it current. The bundled paywall lists
offerings.current.availablePackages. - Optional: design a paywall under [Paywalls] in the RevenueCat dashboard and attach it to the current offering.
payments.presentPaywall()shows it when it exists and falls back to the bundled/paywallroute when it doesn't. The template RevenueCat pre-fills (a sample app such as "CatPrrro") is placeholder copy - replace the name, images and text before shipping, or your users will see it. - Add a sandbox tester in [App Store Connect] → [Users and Access] → [Sandbox] → [Testers], and a license tester in [Play Console] → [Setup] → [License testing]. Purchases only work on a real device signed into one of those accounts.
- Make a dev build -
npx expo run:ios,npx expo run:android, oreas build --profile development. This module uses native StoreKit and Play Billing, so Expo Go can't run it.
Going to production?
Point the app at your production bundle id and package name in RevenueCat, and make sure every product id exists and is approved in App Store Connect and Play Console - a product that's only in RevenueCat never shows a price. Check that the offering you want live is the one marked current, since that's what the paywall reads.
- Run
bun run doctor- every row for this module should be green.
The keys, in one place:
| Key | Where to get it |
|---|---|
EXPO_PUBLIC_REVENUECAT_IOS_KEY | RevenueCat → Project settings → API keys → App Store app (appl_…) |
EXPO_PUBLIC_REVENUECAT_ANDROID_KEY | RevenueCat → Project settings → API keys → Play Store app (goog_…) |
Dashboards: API keys · Products, Entitlements and Offerings in the project sidebar · Paywalls · App Store Connect sandbox testers · Play Console license testers. Deps: react-native-purchases ^10.9.1 and react-native-purchases-ui ^10.9.1 (RN ≥ 0.73, no config plugin).
Usage
Gate a feature on an entitlement:
import { payments } from "@/lib/payments";
const { active, loading } = payments.useEntitlements(); // active: ["pro"]
if (!loading && !active.includes("pro")) await payments.presentPaywall?.();Sell from your own screen:
import { getCurrentPackages, purchasePackage } from "@/lib/payments";
const packages = await getCurrentPackages();
const bought = await purchasePackage(packages[0]); // false when the user cancelsRestore, which the stores require you to offer:
import { payments } from "@/lib/payments";
await payments.restorePurchases();Let subscribers manage or cancel, e.g. from a "Pro" row in your settings screen (the bundled /paywall shows the button in its "You are Pro" state):
import { manageSubscription, payments, PRO_ENTITLEMENT } from "@/lib/payments";
const { active } = payments.useEntitlements();
if (active.includes(PRO_ENTITLEMENT)) await manageSubscription(); // Apple's / Google's pageShow the dashboard paywall even to Pro users (a "Compare all plans" link, a debug menu):
import { presentNativePaywall } from "@/lib/payments";
const result = await presentNativePaywall({ force: true }); // "purchased" | "restored" | "cancelled" | "error" | "unavailable""error" means the paywall was shown and a purchase or restore inside it failed - the paywall already told the user, so don't show anything else. Only "unavailable" (nothing was shown: no paywall designed, offline, no key) warrants a fallback.
Entitlement ids are whatever you named them in RevenueCat. PRO_ENTITLEMENT ("pro") is the one presentPaywall() checks: users who already have it skip the native paywall and land on /paywall's "You are Pro" state.
With an auth module, PaymentsProvider reads auth.useSession() and calls Purchases.logIn(user.id) on sign-in and Purchases.logOut() on sign-out, so purchases follow the account across devices (and can match a server-side rail such as Stripe). Without one the app user stays anonymous. Entitlements read loading while a switch is in flight and never show the previous account's. Switches run one at a time and always end on the latest session user; a failed logIn / logOut is retried when the app returns to the foreground, and customer-info updates are ignored until it succeeds.
Gotchas
- Expo Go can't run this module - native StoreKit and Play Billing need a dev build.
requiredisfalsein the module'senv[]on purpose:src/lib/env.tswould throw at startup for a missing required key.bun run doctorstill reports the keys.react-native-purchases10.x has no JS implementation under jest. The root__mocks__files are picked up automatically - don't addjest.mock()for them.presentPaywall()returns when the sheet closes; entitlements arrive a moment later through the customer-info listener.- Every function in
@/lib/paymentsconfigures the SDK on first use. Screens' effects run beforePaymentsProvider's, so a cold start deep-linked to/paywallwould otherwise crash with "There is no singleton instance". - Which purchase sheet you see depends on the key. A Test Store key (
test_…) shows RevenueCat's own "Test Store Purchase" dialog - no App Store Connect setup needed, handy in the simulator. The App Store key (appl_…), with the products created in App Store Connect and a sandbox Apple ID on the device, shows Apple's real sheet. Ship withappl_/goog_keys only. Atest_key in a Release build (TestFlight, store, orexpo run:ios --configuration Release) makes the SDK show a "Wrong API Key" alert and close the app on launch - keep Test Store keys to development builds. - The native paywall shows whatever is designed in [Paywalls]. Until you design one it's RevenueCat's sample template, so check it before release - "Compare all plans" opens it on purpose.
- App Review 3.1.2 wants the renewal terms next to the price:
/paywallprints them under the packages (Apple ID + 24-hour cancellation on iOS, Google Play wording on Android). Keep them if you build your own paywall, and keep a way to manage the subscription (manageSubscription()). - The store sheet is in-app purchase, not Apple Pay: it charges the Apple ID's payment method (which may be an Apple Pay card). Apple Pay proper is only allowed for physical goods and services.
- RevenueCat starts with an anonymous app user id;
PaymentsProviderswitches it to the signed-in user's id. What happens to a purchase made anonymously before sign-in is RevenueCat's call - see Identifying customers and the project's restore behavior setting.
Check it works (the Examples steps need a tree set up with --with-examples):
- Set up the dashboard as above: project, iOS app, products, the
proentitlement, and an offering with a monthly package marked current. - Put both keys in
.envand runnpx expo run:ioson a real device signed into a sandbox Apple ID (Settings → App Store → Sandbox Account). - Open Examples → "Paywall & entitlements" and expect "none active".
- Tap "Open paywall": the RevenueCat paywall appears (or
/paywallwhen none is configured) with the package and a localized price. Buy → sandbox sheet → confirm → toast "Purchase complete" and entitlements showpro. - Tap "Open paywall" again: no native paywall this time,
/paywallshows "You are Pro". "Manage subscription" opens the store's subscriptions page. - Delete and reinstall the app, then tap "Restore purchases": entitlements show
proagain. - Repeat on Android with a license-tester Google account and
EXPO_PUBLIC_REVENUECAT_ANDROID_KEYset.
Remove it
While modules/ exists (a tree set up with --keep-modules), setup does all of it:
bun run setup --payments none --yes --keep-modulesIn a finalized tree setup is a stub, so you undo it by hand. Here is everything this module added:
- Delete the files that are still there (the demo screens are gone already unless you set up with
--with-examples):__mocks__/react-native-purchases-ui.ts,__mocks__/react-native-purchases.ts,src/app/examples/paywall.tsx,src/app/paywall/_layout.tsx,src/app/paywall/index.tsx,src/lib/__tests__/payments-revenuecat.test.ts,src/screens/examples/paywall-example-screen.tsx,src/screens/paywall/paywall-screen.tsx. - Replace, don't delete
src/lib/payments.ts: core code imports it, so swap in the no-op version frommodules/payments/none/files/of a fresh clone of your tier repo - same exports, nothing behind them. - Uninstall the dependencies:
bun remove react-native-purchases react-native-purchases-ui. - Unwrap the provider: delete
<PaymentsProvider>and its import fromsrc/providers.tsx. - Remove the env keys
EXPO_PUBLIC_REVENUECAT_IOS_KEY,EXPO_PUBLIC_REVENUECAT_ANDROID_KEYfrom.env,.env.exampleand your EAS environment, andEXPO_PUBLIC_REVENUECAT_IOS_KEY,EXPO_PUBLIC_REVENUECAT_ANDROID_KEYfromsrc/lib/env.ts. - Update the privacy declarations: remove this module's entries from
.readynative.json→modules.app.expo.ios.privacyManifests, then re-runbun run gen:privacyand revise your store privacy answers. - Check it:
bun run typecheckandbun run lintpoint at anything that still imports the removed files;bun run gen:graphrefreshesdocs/ARCHITECTURE.md.
Reference
Everything below is generated from modules/payments/revenuecat/module.json - the same file bun run setup reads, so it is what actually lands in your repo.
Install
bun run setup --payments revenuecatModule id: payments/revenuecat (the default for this category).
Dependencies
| Package | Version | Kind |
|---|---|---|
react-native-purchases | ^10.10.1 | dependency |
react-native-purchases-ui | ^10.10.1 | dependency |
Environment keys
| Key | Required | Server-only | Example | Docs |
|---|---|---|---|---|
EXPO_PUBLIC_REVENUECAT_IOS_KEY | yes | no | appl_xxxxxxxxxxxxxxxxxxxxxxxxxx | dashboard |
EXPO_PUBLIC_REVENUECAT_ANDROID_KEY | yes | no | goog_xxxxxxxxxxxxxxxxxxxxxxxxxx | dashboard |
Keys go in .env (see .env.example). Required keys are checked by bun run doctor; Server-only keys have no EXPO_PUBLIC_ prefix, are read only by API routes and never reach the bundle.
Privacy
Play Data safety draft: collects Purchase history, App user id (anonymous by default), Device info; shared with RevenueCat (processor). Source of truth: vendor disclosure.
Apple privacy manifest data types (composed into ios.privacyManifests by setup):
| Type | Linked to user | Tracking | Purposes |
|---|---|---|---|
PurchaseHistory | yes | no | AppFunctionality |
UserID | yes | no | AppFunctionality |
Providers
Rendered in src/providers.tsx (lower order = outermost):
| Order | Provider | From |
|---|---|---|
| 70 | PaymentsProvider | @/lib/payments |
Doctor checks
- env:
EXPO_PUBLIC_REVENUECAT_IOS_KEY,EXPO_PUBLIC_REVENUECAT_ANDROID_KEY - dev build toolchain (Xcode / Android SDK)
After setup
- RevenueCat: create a project at https://app.revenuecat.com, add an App Store app (bundle id from readynative.config.ts) and a Play Store app (package name).
- RevenueCat: copy the public SDK keys (Project settings → API keys) into .env as EXPO_PUBLIC_REVENUECAT_IOS_KEY / EXPO_PUBLIC_REVENUECAT_ANDROID_KEY.
- RevenueCat: create Products (App Store Connect / Play Console product ids), an Entitlement (e.g.
pro) and an Offering marked as current;/paywalllists the current offering's packages. - react-native-purchases is a native module: make a dev build with
npx expo run:ios/npx expo run:android(oreas build --profile development). Expo Go will not load it.
Files
9 files copied to the project root
__mocks__/react-native-purchases-ui.ts__mocks__/react-native-purchases.tssrc/app/examples/paywall.tsxsrc/app/paywall/_layout.tsxsrc/app/paywall/index.tsxsrc/lib/__tests__/payments-revenuecat.test.tssrc/lib/payments.tssrc/screens/examples/paywall-example-screen.tsxsrc/screens/paywall/paywall-screen.tsx