TL;DR
- Real purchases don't work in Expo Go. RevenueCat only runs a mock Preview API Mode there. Use a development build.
- Configure the SDK once at startup with a per-platform public key.
- Gate features on an entitlement (
pro), never on product IDs. -
purchasePackagethrows when the user cancels. Checke.userCancelledand move on. -
restorePurchasesonly from a button tap, never on launch. - Test with RevenueCat's Test Store first, then Apple/Google sandboxes before review.
1. Install and rebuild
npx expo install react-native-purchases react-native-purchases-ui expo-dev-client
eas build --profile development --platform all
Skip the rebuild and you'll get `new NativeEventEmitter()` requires a non-null argument, which RevenueCat's docs call out directly.
2. Configure once
// app/_layout.tsx
useEffect(() => {
if (__DEV__) Purchases.setLogLevel(LOG_LEVEL.VERBOSE);
Purchases.configure({
apiKey: Platform.OS === 'ios'
? process.env.EXPO_PUBLIC_RC_APPLE_KEY!
: process.env.EXPO_PUBLIC_RC_GOOGLE_KEY!,
});
}, []);
Public SDK keys are fine in EXPO_PUBLIC_ vars. Secret keys never go in the app. If you have your own auth, call Purchases.logIn(userId) after sign-in.
3. Paywall
Lazy option, RevenueCat's prebuilt paywall, only shown if the user isn't already pro:
import RevenueCatUI, { PAYWALL_RESULT } from 'react-native-purchases-ui';
const result = await RevenueCatUI.presentPaywallIfNeeded({
requiredEntitlementIdentifier: 'pro',
});
const hasPro = [
PAYWALL_RESULT.PURCHASED,
PAYWALL_RESULT.RESTORED,
PAYWALL_RESULT.NOT_PRESENTED, // already subscribed
].includes(result);
Custom UI option:
const offerings = await Purchases.getOfferings();
const packages = offerings.current?.availablePackages ?? [];
async function buy(pkg: PurchasesPackage) {
try {
const { customerInfo } = await Purchases.purchasePackage(pkg);
if (customerInfo.entitlements.active['pro']) unlock();
} catch (e: any) {
if (!e.userCancelled) showError(e);
}
}
Render pkg.product.priceString, not a hard-coded "$4.99". Prices are per country.
4. One hook for access
export function useIsPro() {
const [isPro, setIsPro] = useState(false);
useEffect(() => {
const update = (info: CustomerInfo) =>
setIsPro(typeof info.entitlements.active['pro'] !== 'undefined');
Purchases.getCustomerInfo().then(update).catch(() => {});
Purchases.addCustomerInfoUpdateListener(update);
return () => { Purchases.removeCustomerInfoUpdateListener(update); };
}, []);
return isPro;
}
The listener means renewals, expiries and restores update the UI live. Still check on your backend for anything valuable server-side.
If you're still building the screens that sit behind the paywall, RapidNative generates a plain Expo Router + TypeScript project from a prompt that you can export and drop this code into. (I work on it.)
5. Restore button
const info = await Purchases.restorePurchases();
RevenueCat's docs say to call this only from a user action, because it can pop an OS sign-in prompt. Put the button on the paywall and in settings.
Testing
| Stage | Use |
|---|---|
| Building | RevenueCat Test Store key. No store accounts needed. |
| Pre-review | Platform keys + Apple sandbox / Google Play testing |
| Production | Platform keys. Never the Test Store key. |
Android gotcha: products won't load unless a signed build is on a closed testing track, your account is a license tester, and you've opened the opt-in link. Sandbox renewals are fast (monthly every 5 min, yearly every 30 min, max six renewals), which is great for testing expiry.
Debug cheatsheet
| Symptom | Cause |
|---|---|
| Empty offerings | Products not attached to the offering, or not active in the store |
| Nothing happens in Expo Go | Preview API Mode. Use a dev build. |
| Android products missing | Not on a testing track / not a license tester |
| Subscriber sees paywall | Checking product IDs, or missing logIn
|
What's been your biggest subscriptions headache in React Native: store setup, sandbox testing, or syncing access with your backend?
Top comments (3)
On the access hook, watch what a failed refresh does. My Pro screen read the subscription through a query hook, and any time one refresh failed (slow network, app coming back from the background) it swapped a working paywall for "Unable to load subscription details", even though the last good answer was still there.
Now the error only shows when there's no answer at all, with a Try Again button.
One thing in the access hook: useIsPro starts at false, so a paying user sees the paywall until the first getCustomerInfo resolves, and if that first call fails the empty catch keeps them there. A third state (unknown, true, false) lets you show a loading or last-known state instead of the paywall until you actually have an answer. It is the same idea beusebiu describes for failed refreshes, applied to the very first read.
One more for the testing list: the "Manage subscription" button. On a TestFlight build ours did nothing (StoreKit internal error 19 in the device log) while RevenueCat's showManageSubscriptions still resolved, so the catch never ran; worth falling back to opening apps.apple.com/account/subscriptions.