ReadyNative

RevenueCat

Set up RevenueCat for payments in an Expo app with ReadyNative: in-app subscriptions + paywall · dev build required (no Expo Go)

Pro

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
  1. 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.ts app.ios.bundleId (plus .dev for the dev variant) and the matching Android package.
  2. Open [Project settings] → [API keys]. Copy the App Store key (appl_…) into .env as EXPO_PUBLIC_REVENUECAT_IOS_KEY and the Play Store key (goog_…) as EXPO_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 /paywall shows "Configure RevenueCat".
  3. Create the products in [App Store Connect] → [Subscriptions] and [Play Console] → [Monetize] → [Subscriptions], then add them in RevenueCat under [Products].
  4. Open [Entitlements] and create one with the identifier pro (PRO_ENTITLEMENT in src/lib/payments.ts - change the constant if you pick another id), then attach your products to it.
  5. Open [Offerings], create an offering with a monthly package, and mark it current. The bundled paywall lists offerings.current.availablePackages.
  6. 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 /paywall route 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.
  7. 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.
  8. Make a dev build - npx expo run:ios, npx expo run:android, or eas 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.

  1. Run bun run doctor - every row for this module should be green.

The keys, in one place:

KeyWhere to get it
EXPO_PUBLIC_REVENUECAT_IOS_KEYRevenueCat → Project settings → API keys → App Store app (appl_…)
EXPO_PUBLIC_REVENUECAT_ANDROID_KEYRevenueCat → 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 cancels

Restore, 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 page

Show 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.
  • required is false in the module's env[] on purpose: src/lib/env.ts would throw at startup for a missing required key. bun run doctor still reports the keys.
  • react-native-purchases 10.x has no JS implementation under jest. The root __mocks__ files are picked up automatically - don't add jest.mock() for them.
  • presentPaywall() returns when the sheet closes; entitlements arrive a moment later through the customer-info listener.
  • Every function in @/lib/payments configures the SDK on first use. Screens' effects run before PaymentsProvider's, so a cold start deep-linked to /paywall would 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 with appl_ / goog_ keys only. A test_ key in a Release build (TestFlight, store, or expo 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: /paywall prints 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; PaymentsProvider switches 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):

  1. Set up the dashboard as above: project, iOS app, products, the pro entitlement, and an offering with a monthly package marked current.
  2. Put both keys in .env and run npx expo run:ios on a real device signed into a sandbox Apple ID (Settings → App Store → Sandbox Account).
  3. Open Examples → "Paywall & entitlements" and expect "none active".
  4. Tap "Open paywall": the RevenueCat paywall appears (or /paywall when none is configured) with the package and a localized price. Buy → sandbox sheet → confirm → toast "Purchase complete" and entitlements show pro.
  5. Tap "Open paywall" again: no native paywall this time, /paywall shows "You are Pro". "Manage subscription" opens the store's subscriptions page.
  6. Delete and reinstall the app, then tap "Restore purchases": entitlements show pro again.
  7. Repeat on Android with a license-tester Google account and EXPO_PUBLIC_REVENUECAT_ANDROID_KEY set.

Remove it

While modules/ exists (a tree set up with --keep-modules), setup does all of it:

bun run setup --payments none --yes --keep-modules

In a finalized tree setup is a stub, so you undo it by hand. Here is everything this module added:

  1. 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.
  2. Replace, don't delete src/lib/payments.ts: core code imports it, so swap in the no-op version from modules/payments/none/files/ of a fresh clone of your tier repo - same exports, nothing behind them.
  3. Uninstall the dependencies: bun remove react-native-purchases react-native-purchases-ui.
  4. Unwrap the provider: delete <PaymentsProvider> and its import from src/providers.tsx.
  5. Remove the env keys EXPO_PUBLIC_REVENUECAT_IOS_KEY, EXPO_PUBLIC_REVENUECAT_ANDROID_KEY from .env, .env.example and your EAS environment, and EXPO_PUBLIC_REVENUECAT_IOS_KEY, EXPO_PUBLIC_REVENUECAT_ANDROID_KEY from src/lib/env.ts.
  6. Update the privacy declarations: remove this module's entries from .readynative.json → modules.app.expo.ios.privacyManifests, then re-run bun run gen:privacy and revise your store privacy answers.
  7. Check it: bun run typecheck and bun run lint point at anything that still imports the removed files; bun run gen:graph refreshes docs/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 revenuecat

Module id: payments/revenuecat (the default for this category).

Dependencies

PackageVersionKind
react-native-purchases^10.10.1dependency
react-native-purchases-ui^10.10.1dependency

Environment keys

KeyRequiredServer-onlyExampleDocs
EXPO_PUBLIC_REVENUECAT_IOS_KEYyesnoappl_xxxxxxxxxxxxxxxxxxxxxxxxxxdashboard
EXPO_PUBLIC_REVENUECAT_ANDROID_KEYyesnogoog_xxxxxxxxxxxxxxxxxxxxxxxxxxdashboard

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):

TypeLinked to userTrackingPurposes
PurchaseHistoryyesnoAppFunctionality
UserIDyesnoAppFunctionality

Providers

Rendered in src/providers.tsx (lower order = outermost):

OrderProviderFrom
70PaymentsProvider@/lib/payments

Doctor checks

  • env: EXPO_PUBLIC_REVENUECAT_IOS_KEY, EXPO_PUBLIC_REVENUECAT_ANDROID_KEY
  • dev build toolchain (Xcode / Android SDK)

After setup

  1. 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).
  2. RevenueCat: copy the public SDK keys (Project settings → API keys) into .env as EXPO_PUBLIC_REVENUECAT_IOS_KEY / EXPO_PUBLIC_REVENUECAT_ANDROID_KEY.
  3. RevenueCat: create Products (App Store Connect / Play Console product ids), an Entitlement (e.g. pro) and an Offering marked as current; /paywall lists the current offering's packages.
  4. react-native-purchases is a native module: make a dev build with npx expo run:ios / npx expo run:android (or eas 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.ts
  • src/app/examples/paywall.tsx
  • src/app/paywall/_layout.tsx
  • src/app/paywall/index.tsx
  • src/lib/__tests__/payments-revenuecat.test.ts
  • src/lib/payments.ts
  • src/screens/examples/paywall-example-screen.tsx
  • src/screens/paywall/paywall-screen.tsx

On this page

Get ReadyNative