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.
| Feature | Sandbox | Production |
|---|---|---|
| Payment | No actual charge | Real charge |
| 1 week subscription | Renews every 3 minutes | Renews after 1 week |
| 1 month subscription | Renews every 5 minutes | Renews after 1 month |
| 1 year subscription | Renews every 15 minutes | Renews after 1 year |
| Maximum renewals | 6 times, then stops | Unlimited |
| Cancellation | Immediate | End 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_monthly→proentitlementpro_yearly→proentitlement
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:
- Go to App Store Connect > Your App > Subscriptions
- Create a Subscription Group (e.g., “Premium Access”)
- Add subscriptions:
- Product ID:
pro_monthly - Reference Name: Pro Monthly
- Subscription Duration: 1 Month
- Price: $9.99
- Product ID:
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:
- Go to Google Play Console > Your App > Monetize > Subscriptions
- Create subscriptions:
- Product ID:
pro_monthly - Name: Pro Monthly
- Billing period: Monthly
- Price: $9.99
- Product ID:
Again, Product IDs must match RevenueCat.
Implementing in React Native
Now that you understand the concepts, here’s the code.
Installation
npm install react-native-purchasesOr with Expo:
npx expo install react-native-purchasesFor Expo, you’ll need a development build:
npx expo prebuildnpx expo run:iosnpx expo run:androidRevenueCat requires native modules, so Expo Go won’t work.
Configuration
Create a configuration file:
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:
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:
import Purchases from 'react-native-purchases';
export async function identifyUser(userId: string): Promise<void> { await Purchases.logIn(userId);}
export async function logoutUser(): Promise<void> { await Purchases.logOut();}Call identifyUser after your user signs in:
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:
import Purchases, { Offering } from 'react-native-purchases';
export async function getOfferings(): Promise<Offering | null> { const offerings = await Purchases.getOfferings(); return offerings.current; // Returns the "default" offering}Checking Entitlements
Check if a user has access to premium features:
import Purchases, { CustomerInfo } from 'react-native-purchases';
export async function hasEntitlement( entitlementId: string): Promise<boolean> { const customerInfo = await Purchases.getCustomerInfo(); return customerInfo.entitlements.active[entitlementId] !== undefined;}
export async function getCustomerInfo(): Promise<CustomerInfo> { return await Purchases.getCustomerInfo();}Use this to gate features:
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:
- Get the product from the offering
- Present the native payment sheet
- Handle success or failure
import Purchases, { PurchaseResult } from 'react-native-purchases';
export async function purchasePackage( packageIdentifier: string): Promise<PurchaseResult> { 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:
import Purchases, { PURCHASES_ERROR_CODE, PurchasesError,} from 'react-native-purchases';
export async function safePurchase( packageIdentifier: string): Promise<{ success: boolean; error?: string }> { 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:
export async function restorePurchases(): Promise<void> { 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:
import { useEffect, useState } from 'react';import Purchases, { CustomerInfo } from 'react-native-purchases';
export function useCustomerInfo(): CustomerInfo | null { const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(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:
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
-
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)
-
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
-
Make a purchase:
- Run your app in development
- Trigger a purchase
- Sign in with your sandbox tester credentials when prompted
Android Sandbox Testing
-
Create test users in Google Play Console:
- Setup > License testers
- Add email addresses
-
Add your test track:
- Release your app to Internal Testing track
- Add testers
-
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:
// Wrongif (customerInfo.activeSubscriptions.includes('pro_monthly')) { ... }
// Rightif (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 UIThis 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