DEV Community

Cover image for Expo + RevenueCat: Subscriptions in 5 Steps (and How to Test Them)
Famitha M A
Famitha M A

Posted on Originally published at fami-blog.hashnode.dev

Expo + RevenueCat: Subscriptions in 5 Steps (and How to Test Them)

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.
  • purchasePackage throws when the user cancels. Check e.userCancelled and move on.
  • restorePurchases only 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
Enter fullscreen mode Exit fullscreen mode

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!,
  });
}, []);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
beusebiu profile image
Eusebiu Balan •

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.

Collapse
 
davekurian profile image
Dave Kurian •

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.

Collapse
 
oddeggs profile image
Odd Eggs Studio •

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.