Skip to content

How to Integrate RevenueCat Subscriptions in React Native: Complete Guide with Expo, App Store Connect & Sandbox Testing

I spent two weeks wrestling with in-app subscriptions. The Apple documentation was confusing. Google Play Console felt like a maze. Every time I thought I had it working, something broke in production.

Then I found RevenueCat. But even with RevenueCat, I made a critical mistake: I jumped into code before understanding the concepts.

Let me save you that headache.

The Problem with In-App Subscriptions

Implementing subscriptions is genuinely one of the harder integrations in mobile development. Here’s why:

Apple and Google have completely different billing systems. You need to implement StoreKit for iOS and Google Play Billing for Android. They handle receipts, renewals, and grace periods differently.

Subscription states are complicated. A user can be in trial, active, past due, in grace period, or expired. They might have multiple subscriptions. They might be sharing across Family Sharing (iOS) or Google Family.

Receipt validation must be server-side. You cannot trust client-side validation. Users can manipulate their devices to fake purchases.

Sandbox testing is weird. A one-week subscription renews every 3 minutes in sandbox. This catches many developers off guard.

Edge cases everywhere. What happens when a payment fails? What if the user cancels mid-trial? What about proration when upgrading tiers?

I tried building this myself first. After a week of fighting StoreKit and Google Play Billing separately, I realized I was building infrastructure, not features.

The Solution: RevenueCat

RevenueCat provides:

  • Unified SDK: One API works for both iOS and Android
  • Server-side receipt validation: Secure, tamper-proof verification
  • Real-time subscription updates: Webhooks notify your backend
  • Customer dashboard: Users can manage subscriptions without contacting support
  • Analytics: Revenue metrics, churn, conversion rates

But RevenueCat isn’t magic. You still need to understand the concepts.

This is the insight that saved me: Before writing any code, I needed to understand entitlements, products, offerings, and how sandbox differs from production.

Core Concepts (Understand These First)

Entitlements

An entitlement is what the user has access to, not what they paid for.

Think of it this way:

  • A product is what the user buys
  • An entitlement is what features they unlock

For example, if you have a “Premium” subscription, that’s one entitlement. Whether the user pays monthly or yearly, they both unlock the same “Premium” entitlement.

Products:
- premium_monthly ($9.99/month)
- premium_yearly ($99.99/year)
Both map to:
- Entitlement: "premium"

This abstraction is powerful. You can change your pricing, add new tiers, or run promotions without changing your app code. Your app only checks: “Does this user have the ‘premium’ entitlement?”

Products

Products are the actual SKUs (Stock Keeping Units) in the app stores:

  • iOS: Product IDs configured in App Store Connect
  • Android: Product IDs configured in Google Play Console

RevenueCat syncs these automatically when you connect your apps.

Offerings

Offerings are how you present products to users.

You might have:

  • A “default” offering with monthly and yearly options
  • A “summer_sale” offering with a discounted annual plan
  • A “win_back” offering for churned users

Offerings let you change your paywall without app updates. RevenueCat’s dashboard lets you configure which offering each user segment sees.

CustomerInfo

This object contains everything about a user’s subscription status:

  • Active entitlements
  • Subscription expiration dates
  • Trial information
  • Purchase history

You check CustomerInfo to determine what features to show.

Sandbox vs Production: Critical Differences

This caught me off guard. Sandbox behaves very differently from production.

FeatureSandboxProduction
PaymentNo actual chargeReal charge
1 week subscriptionRenews every 3 minutesRenews after 1 week
1 month subscriptionRenews every 5 minutesRenews after 1 month
1 year subscriptionRenews every 15 minutesRenews after 1 year
Maximum renewals6 times, then stopsUnlimited
CancellationImmediateEnd of billing period

In sandbox, I watched my test subscriptions renewing every few minutes and panicked. “Why is it renewing so fast?!” I thought I had a bug.

This is normal. Sandbox accelerates time so you can test renewal logic without waiting weeks.

Setting Up RevenueCat Dashboard

Before writing code, set up the RevenueCat dashboard:

1. Create a Project

Go to RevenueCat, create a new project. Note your API keys (you’ll need them later).

2. Create an App

Add an iOS app and/or Android app to your project.

For iOS:

  • You’ll need your App Store Connect App ID
  • Enable in-app purchases in App Store Connect first
  • RevenueCat will guide you through connecting

For Android:

  • Link your Google Play Console project
  • Enable Google Play Billing

3. Create Entitlements

Create entitlements that match your app’s feature access:

Entitlements:
- pro (maps to Pro subscription features)
- team (maps to Team subscription features)

4. Create Products

Add products that match your App Store Connect / Google Play Console SKUs:

Products:
- pro_monthly ($9.99/month)
- pro_yearly ($79.99/year)
- team_monthly ($29.99/month)
- team_yearly ($249.99/year)

Attach each product to its entitlement:

  • pro_monthlypro entitlement
  • pro_yearlypro entitlement

5. Create an Offering

Create a “default” offering and add your products:

Offering: default
- Package: $monthly (pro_monthly)
- Package: $annual (pro_yearly)

The $monthly and $annual are package identifiers. RevenueCat provides standard identifiers like $monthly, $annual, $lifetime for common patterns.

Setting Up App Store Connect

For iOS, you need to configure in-app purchases:

  1. Go to App Store Connect > Your App > Subscriptions
  2. Create a Subscription Group (e.g., “Premium Access”)
  3. Add subscriptions:
    • Product ID: pro_monthly
    • Reference Name: Pro Monthly
    • Subscription Duration: 1 Month
    • Price: $9.99

Repeat for annual and any other tiers.

Important: The Product IDs in App Store Connect must match exactly what you configure in RevenueCat.

Setting Up Google Play Console

For Android:

  1. Go to Google Play Console > Your App > Monetize > Subscriptions
  2. Create subscriptions:
    • Product ID: pro_monthly
    • Name: Pro Monthly
    • Billing period: Monthly
    • Price: $9.99

Again, Product IDs must match RevenueCat.

Implementing in React Native

Now that you understand the concepts, here’s the code.

Installation

terminal
npm install react-native-purchases

Or with Expo:

terminal
npx expo install react-native-purchases

For Expo, you’ll need a development build:

terminal
npx expo prebuild
npx expo run:ios
npx expo run:android

RevenueCat requires native modules, so Expo Go won’t work.

Configuration

Create a configuration file:

src/config/purchases.ts
import Purchases from 'react-native-purchases';
const API_KEYS = {
ios: process.env.EXPO_PUBLIC_REVENUECAT_IOS_KEY || '',
android: process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_KEY || '',
};
export async function initializePurchases(): Promise<void> {
const apiKey = Platform.select({
ios: API_KEYS.ios,
android: API_KEYS.android,
});
if (!apiKey) {
console.error('RevenueCat API key not configured');
return;
}
await Purchases.configure({ apiKey });
}

Call this in your app entry point:

App.tsx
import { initializePurchases } from './src/config/purchases';
export default function App() {
useEffect(() => {
initializePurchases();
}, []);
return <YourApp />;
}

Identifying Users

RevenueCat needs to identify users to track their subscriptions:

src/services/subscription.ts
import Purchases from 'react-native-purchases';
export async function identifyUser(userId: string): Promise&lt;void&gt; {
await Purchases.logIn(userId);
}
export async function logoutUser(): Promise&lt;void&gt; {
await Purchases.logOut();
}

Call identifyUser after your user signs in:

src/screens/LoginScreen.tsx
import { identifyUser } from '../services/subscription';
async function handleLogin(email: string, password: string) {
const user = await signIn(email, password);
await identifyUser(user.id);
}

Fetching Offerings

Get your paywall products:

src/services/subscription.ts
import Purchases, { Offering } from 'react-native-purchases';
export async function getOfferings(): Promise&lt;Offering | null&gt; {
const offerings = await Purchases.getOfferings();
return offerings.current; // Returns the "default" offering
}

Checking Entitlements

Check if a user has access to premium features:

src/services/subscription.ts
import Purchases, { CustomerInfo } from 'react-native-purchases';
export async function hasEntitlement(
entitlementId: string
): Promise&lt;boolean&gt; {
const customerInfo = await Purchases.getCustomerInfo();
return customerInfo.entitlements.active[entitlementId] !== undefined;
}
export async function getCustomerInfo(): Promise&lt;CustomerInfo&gt; {
return await Purchases.getCustomerInfo();
}

Use this to gate features:

src/screens/HomeScreen.tsx
import { hasEntitlement } from '../services/subscription';
function PremiumFeature() {
const [isPremium, setIsPremium] = useState(false);
useEffect(() => {
hasEntitlement('pro').then(setIsPremium);
}, []);
if (!isPremium) {
return <UpsellPrompt />;
}
return <PremiumContent />;
}

Implementing the Purchase Flow

The purchase flow has three steps:

  1. Get the product from the offering
  2. Present the native payment sheet
  3. Handle success or failure
src/services/subscription.ts
import Purchases, { PurchaseResult } from 'react-native-purchases';
export async function purchasePackage(
packageIdentifier: string
): Promise&lt;PurchaseResult&gt; {
const offerings = await Purchases.getOfferings();
const pkg = offerings.current?.availablePackages.find(
(p) => p.identifier === packageIdentifier
);
if (!pkg) {
throw new Error(`Package not found: ${packageIdentifier}`);
}
return await Purchases.purchasePackage(pkg);
}

Handling Errors

In-app purchases fail often. Network issues, cancelled payments, already-subscribed errors:

src/services/subscription.ts
import Purchases, {
PURCHASES_ERROR_CODE,
PurchasesError,
} from 'react-native-purchases';
export async function safePurchase(
packageIdentifier: string
): Promise&lt;{ success: boolean; error?: string }&gt; {
try {
const result = await purchasePackage(packageIdentifier);
// Verify the entitlement was granted
const customerInfo = await Purchases.getCustomerInfo();
const hasPro = customerInfo.entitlements.active.pro !== undefined;
return { success: hasPro };
} catch (error) {
const purchasesError = error as PurchasesError;
// User cancelled - not an error, just aborted
if (purchasesError.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
return { success: false };
}
// Already subscribed
if (purchasesError.code === PURCHASES_ERROR_CODE.PRODUCT_ALREADY_PURCHASED_ERROR) {
return { success: true };
}
// Network or store issues
console.error('Purchase failed:', purchasesError.message);
return { success: false, error: purchasesError.message };
}
}

Restoring Purchases

Users reinstall apps. They expect their subscriptions to work:

src/services/subscription.ts
export async function restorePurchases(): Promise&lt;void&gt; {
await Purchases.restorePurchases();
}

This syncs the user’s purchases with the stores and updates their entitlements.

Listening for Updates

Subscription status can change: renewals, cancellations, billing issues. Listen for updates:

src/hooks/useCustomerInfo.ts
import { useEffect, useState } from 'react';
import Purchases, { CustomerInfo } from 'react-native-purchases';
export function useCustomerInfo(): CustomerInfo | null {
const [customerInfo, setCustomerInfo] = useState&lt;CustomerInfo | null&gt;(null);
useEffect(() => {
// Fetch initial state
Purchases.getCustomerInfo().then(setCustomerInfo);
// Listen for changes
const listener = (info: CustomerInfo) => {
setCustomerInfo(info);
};
Purchases.addCustomerInfoUpdateListener(listener);
return () => {
Purchases.removeCustomerInfoUpdateListener(listener);
};
}, []);
return customerInfo;
}

Use this hook throughout your app:

src/screens/ProfileScreen.tsx
import { useCustomerInfo } from '../hooks/useCustomerInfo';
function ProfileScreen() {
const customerInfo = useCustomerInfo();
const isPremium = customerInfo?.entitlements.active.pro !== undefined;
const expirationDate = customerInfo?.entitlements.active.pro?.expirationDate;
return (
<View>
<Text>Status: {isPremium ? 'Premium' : 'Free'}</Text>
{isPremium && (
<Text>Renews: {new Date(expirationDate!).toLocaleDateString()}</Text>
)}
</View>
);
}

Testing in Sandbox

iOS Sandbox Testing

  1. Create a Sandbox Tester in App Store Connect:

    • Users and Access > Sandbox > Testers
    • Add a tester (use a real email, but it doesn’t need to match an Apple ID)
  2. On your test device:

    • Sign out of your main Apple ID in App Store (Settings > App Store)
    • Don’t sign in with the sandbox tester yet
  3. Make a purchase:

    • Run your app in development
    • Trigger a purchase
    • Sign in with your sandbox tester credentials when prompted

Android Sandbox Testing

  1. Create test users in Google Play Console:

    • Setup > License testers
    • Add email addresses
  2. Add your test track:

    • Release your app to Internal Testing track
    • Add testers
  3. Test purchases:

    • Install from the test track
    • Make purchases (no actual charge)

RevenueCat Sandbox Mode

RevenueCat automatically detects sandbox vs production. You don’t need separate API keys.

Check the RevenueCat dashboard to see sandbox transactions. They’ll be marked with a “Sandbox” badge.

Common Pitfalls

Pitfall 1: Not Handling “Already Purchased”

If a user tries to buy a subscription they already have, the store returns an error. Handle it gracefully:

if (error.code === PURCHASES_ERROR_CODE.PRODUCT_ALREADY_PURCHASED_ERROR) {
// This isn't an error - they already have access
return { success: true };
}

Pitfall 2: Forgetting to Restore

Users who reinstall expect their subscriptions to work. Always have a “Restore Purchases” button:

<Button onPress={restorePurchases} title="Restore Purchases" />

Pitfall 3: Not Listening for Updates

Subscription status changes in the background: renewals, billing failures, family sharing. Use addCustomerInfoUpdateListener to stay in sync.

Pitfall 4: Checking Products Instead of Entitlements

Don’t check if a user has purchased a specific product. Check if they have the entitlement:

// Wrong
if (customerInfo.activeSubscriptions.includes('pro_monthly')) { ... }
// Right
if (customerInfo.entitlements.active.pro) { ... }

The user might have purchased pro_yearly instead of pro_monthly. Both grant the same entitlement.

Architecture Recommendation

Here’s how I structure my subscription code:

src/
services/
purchases.ts # RevenueCat initialization
subscription.ts # Purchase, restore, check entitlements
hooks/
useCustomerInfo.ts # Real-time subscription status
useOfferings.ts # Fetch paywall products
screens/
PaywallScreen.tsx # Subscription purchase UI

This separation makes testing easier and keeps your purchase logic in one place.

When to Use RevenueCat vs. Native

RevenueCat adds value when:

  • You need both iOS and Android support
  • You want server-side receipt validation
  • You need webhooks for backend notifications
  • You want analytics and customer management

You might skip RevenueCat if:

  • iOS only
  • Simple one-time purchases (not subscriptions)
  • You want to build and maintain your own server-side validation

For subscriptions, I strongly recommend RevenueCat. The time saved on edge cases alone is worth it.

<FinalWords reflinks=[object Object][object Object][object Object][object Object] currentPostId=How to Integrate RevenueCat Subscriptions in React Native: Complete Guide with Expo, App Store Connect & Sandbox Testing currentPostTags=mobilereacttestingcareer currentPostSeries= manualRelations= excludeList= maxRelated=5 />

Comments