RevenueCat is a subscription-management platform that wraps Apple's StoreKit and Google's Play Billing behind a single cross-platform SDK, validating receipts on its own servers so your app never has to parse store receipts or track renewal dates itself. In an Expo app you integrate it through the react-native-purchases library inside a development build, because in-app purchase code cannot run in Expo Go. The model rests on three concepts: products are the SKUs configured in App Store Connect and Google Play, entitlements represent access levels such as "pro", and offerings group products into paywalls you can reconfigure remotely without shipping an update. Paired with Expo and EAS Build, RevenueCat lets a small team ship a production-grade subscription system in days rather than weeks. This guide covers the full journey: store setup, SDK configuration, paywalls, purchases and restores, entitlement gating, webhooks, sandbox testing, and the pitfalls that catch almost everyone.
This guide walks through a real RevenueCat + Expo integration end to end: the mental model, the native setup that Expo requires, building a paywall, making and restoring purchases, gating features behind entitlements, syncing state to your backend with webhooks, and the pitfalls that catch almost everyone the first time.
Why Use RevenueCat Instead of Raw StoreKit or Play Billing?
Apple's StoreKit and Google's Play Billing are the low-level APIs that actually process payments. They work, but they are two completely different SDKs with different concepts, different edge cases, and different failure modes. If you go raw, you own all of it: purchase flows, receipt/token validation against Apple and Google servers, renewal tracking, billing retry and grace periods, upgrades and downgrades, proration, and reconciling everything into a single source of truth for entitlement state.
What RevenueCat gives you on top of the native stores:
- A single cross-platform SDK and API for iOS and Android
- Server-side receipt validation, so you never trust the client for entitlement truth
- A unified CustomerInfo object describing exactly what the user currently owns
- Subscription lifecycle handling: renewals, expirations, grace periods, billing retries
- Webhooks and integrations to sync purchase state to your backend and analytics
- Charts and cohort analytics (MRR, churn, trials, conversions) out of the box
- A generous free tier that scales with revenue rather than a fixed cost
The core value of RevenueCat is not that it makes a purchase button easier. It is that it becomes the trusted source of truth for what a user is entitled to, on every platform, validated on the server, so your app never has to guess.
What Are Products, Entitlements, and Offerings?
Before touching code, internalize the three concepts that RevenueCat is built around. Getting these right in your head prevents the most common configuration mistakes later.
Products
A product is the actual SKU you create in App Store Connect or Google Play Console, for example pro_monthly or pro_annual. Products have a price, a billing period, and platform-specific identifiers. They are the raw, store-level items users pay for.
Entitlements
An entitlement is a level of access your app cares about, such as "pro". Multiple products can unlock the same entitlement. Your monthly, annual, and lifetime products can all grant the pro entitlement. Your app should never check "does the user own pro_annual"; it should check "does the user have the pro entitlement active". This decoupling is the single most important habit to build.
Offerings
An offering is a set of products packaged for display on your paywall, organized into packages (Monthly, Annual, Lifetime). Offerings are configured remotely in the RevenueCat dashboard, which means you can change pricing, swap products, or A/B test paywalls without shipping an app update. Your code fetches the "current" offering and renders whatever packages it contains.
How Do You Set Up Products in the App Stores?
RevenueCat sits on top of the stores; it does not replace their configuration. You still create products in each store first, then map them in RevenueCat.
App Store Connect (iOS):
- Create your app, then go to Subscriptions and create a Subscription Group
- Add auto-renewable subscriptions inside the group (e.g. pro_monthly, pro_annual)
- Set localized display names, prices, and an optional free trial as an introductory offer
- Generate an In-App Purchase key (App Store Connect API) and a shared secret for RevenueCat
- Complete tax and banking agreements, or products will not load in sandbox
Google Play Console (Android):
- Create subscription products under Monetize > Subscriptions with base plans and offers
- Use a consistent product ID scheme (Play requires a base plan per subscription)
- Create a Google Cloud service account and grant it access so RevenueCat can validate purchases
- Upload at least one build to a testing track; products do not resolve without a published build
- Add license testers so test purchases do not charge real money
Finally, in the RevenueCat dashboard you connect both apps, import each product, create a "pro" entitlement, attach the products to it, and build a "default" offering with your monthly and annual packages. This dashboard mapping is what your client code reads at runtime.
Installing react-native-purchases in an Expo Dev Build
react-native-purchases contains native code, so it cannot run in Expo Go. Expo Go ships a fixed set of native modules and IAP is not one of them. You need a development build (a custom native binary of your app that includes the RevenueCat native module) created with EAS Build or a local prebuild. This is the standard modern Expo workflow and nothing to be afraid of.
# Install the SDK (and optionally the drop-in paywall UI)
npx expo install react-native-purchases react-native-purchases-ui
# You need a dev client to run native modules
npx expo install expo-dev-client
# Build a development build with EAS (recommended)
eas build --profile development --platform ios
eas build --profile development --platform androidThe SDK ships an Expo config plugin, so add it to app.json / app.config.js. The plugin wires up the required native configuration during prebuild, so you never edit ios/ or android/ by hand.
// app.config.ts
export default {
expo: {
name: 'MyApp',
slug: 'my-app',
plugins: [
'expo-dev-client',
'react-native-purchases'
],
ios: {
bundleIdentifier: 'dev.tahakocal.myapp'
},
android: {
package: 'dev.tahakocal.myapp'
}
}
};If you remember one thing about Expo and IAP: Expo Go can never test purchases. Always work in a development build, and test real store behavior on TestFlight and Play internal testing.
Configuring the SDK with API Keys
RevenueCat gives you a separate public API key per platform. Configure the SDK once, as early as possible in the app lifecycle, before you read any customer state. Enable verbose logging in development to see exactly what the SDK is doing.
// lib/purchases.ts
import Purchases, { LOG_LEVEL } from 'react-native-purchases';
import { Platform } from 'react-native';
const API_KEYS = {
ios: process.env.EXPO_PUBLIC_RC_IOS_KEY as string,
android: process.env.EXPO_PUBLIC_RC_ANDROID_KEY as string
};
export function configurePurchases() {
if (__DEV__) {
Purchases.setLogLevel(LOG_LEVEL.VERBOSE);
}
Purchases.configure({
apiKey: Platform.OS === 'ios' ? API_KEYS.ios : API_KEYS.android
});
}Call configurePurchases() at the top of your root component so the SDK is ready before any screen needs entitlement state.
// app/_layout.tsx (Expo Router)
import { useEffect } from 'react';
import { Slot } from 'expo-router';
import { configurePurchases } from '../lib/purchases';
export default function RootLayout() {
useEffect(() => {
configurePurchases();
}, []);
return <Slot />;
}Fetching Offerings and Building a Paywall
To render a paywall you fetch the current offering and map its available packages into UI. Each package exposes a StoreProduct with a localized, currency-correct priceString, so never hardcode prices. Let the store tell you what to display.
// hooks/useOfferings.ts
import { useEffect, useState } from 'react';
import Purchases, { PurchasesOffering } from 'react-native-purchases';
export function useOfferings() {
const [offering, setOffering] = useState<PurchasesOffering | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const offerings = await Purchases.getOfferings();
setOffering(offerings.current);
} catch (e) {
console.warn('Failed to load offerings', e);
} finally {
setLoading(false);
}
})();
}, []);
return { offering, loading };
}// components/Paywall.tsx
import { View, Text, Pressable, ActivityIndicator } from 'react-native';
import { PurchasesPackage } from 'react-native-purchases';
import { useOfferings } from '../hooks/useOfferings';
type Props = { onPurchase: (pkg: PurchasesPackage) => void };
export function Paywall({ onPurchase }: Props) {
const { offering, loading } = useOfferings();
if (loading) return <ActivityIndicator />;
if (!offering) return <Text>No plans available right now.</Text>;
return (
<View>
<Text>Unlock Pro</Text>
{offering.availablePackages.map((pkg) => (
<Pressable key={pkg.identifier} onPress={() => onPurchase(pkg)}>
<Text>{pkg.product.title}</Text>
<Text>{pkg.product.priceString}</Text>
{pkg.product.introPrice ? (
<Text>Includes a free trial</Text>
) : null}
</Pressable>
))}
</View>
);
}If you prefer not to build UI at all, react-native-purchases-ui provides a fully managed paywall you configure in the dashboard. It is a great way to ship fast and iterate on paywall design remotely, but building your own gives you full control over layout and experiments.
Making a Purchase and Reading CustomerInfo
When the user taps a package, call purchasePackage. The result includes a customerInfo object, which is the source of truth for what the user now owns. Check the entitlement, not the product, to decide whether to unlock. Always handle the user-cancelled case explicitly so you do not show an error when someone simply backs out.
// lib/buy.ts
import Purchases, {
PurchasesPackage,
PurchasesError,
PURCHASES_ERROR_CODE
} from 'react-native-purchases';
const PRO = 'pro';
export async function buyPackage(pkg: PurchasesPackage) {
try {
const { customerInfo } = await Purchases.purchasePackage(pkg);
const isPro = typeof customerInfo.entitlements.active[PRO] !== 'undefined';
return { success: true, isPro };
} catch (e) {
const err = e as PurchasesError;
if (err.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
return { success: false, cancelled: true };
}
console.warn('Purchase failed', err.message);
return { success: false, cancelled: false };
}
}The entitlements.active map only contains entitlements that are currently valid. Its presence is the whole check; you do not need to compare dates yourself because RevenueCat has already validated the receipt server-side and accounted for expiration and grace periods.
How Do You Restore Purchases?
Restore is not optional. Apple requires a visible restore option, and users who reinstall or switch devices need it. restorePurchases re-reads the store account and returns fresh CustomerInfo. Wire it to a clearly labeled button on your paywall and settings screen.
import Purchases from 'react-native-purchases';
export async function restore() {
try {
const customerInfo = await Purchases.restorePurchases();
const isPro = typeof customerInfo.entitlements.active['pro'] !== 'undefined';
return { restored: isPro };
} catch (e) {
console.warn('Restore failed', e);
return { restored: false };
}
}Gating Features by Entitlement
Rather than checking entitlements ad hoc across screens, centralize the state. Subscribe to CustomerInfo updates so the whole app reacts instantly when a purchase, renewal, or expiration happens, even if it originates on another device or from a webhook-driven change.
// providers/EntitlementProvider.tsx
import { createContext, useContext, useEffect, useState } from 'react';
import Purchases, { CustomerInfo } from 'react-native-purchases';
type Ctx = { isPro: boolean; loading: boolean };
const EntitlementContext = createContext<Ctx>({ isPro: false, loading: true });
export function EntitlementProvider({ children }: { children: React.ReactNode }) {
const [isPro, setIsPro] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const apply = (info: CustomerInfo) => {
setIsPro(typeof info.entitlements.active['pro'] !== 'undefined');
setLoading(false);
};
Purchases.getCustomerInfo().then(apply).catch(() => setLoading(false));
Purchases.addCustomerInfoUpdateListener(apply);
return () => {
Purchases.removeCustomerInfoUpdateListener(apply);
};
}, []);
return (
<EntitlementContext.Provider value={{ isPro, loading }}>
{children}
</EntitlementContext.Provider>
);
}
export const useEntitlement = () => useContext(EntitlementContext);// Gate a feature anywhere in the tree
import { useEntitlement } from '../providers/EntitlementProvider';
function ExportButton() {
const { isPro } = useEntitlement();
if (!isPro) {
return <UpgradePrompt feature="Unlimited exports" />;
}
return <RealExportButton />;
}Gate features on the entitlement, never on a specific product ID. When you later add a lifetime plan or run a promo, every gate in your app keeps working with zero changes.
How Do Webhooks Keep Your Backend in Sync?
The client SDK is enough for many apps, but if your backend needs to know a user is a subscriber (to unlock server features, send emails, or reconcile access), rely on webhooks rather than trusting the client. RevenueCat sends events like INITIAL_PURCHASE, RENEWAL, CANCELLATION, EXPIRATION, and BILLING_ISSUE to an endpoint you control. This is the authoritative, server-verified channel.
// Express webhook handler
import express from 'express';
const app = express();
app.use(express.json());
app.post('/webhooks/revenuecat', (req, res) => {
// Verify the shared Authorization header you set in the dashboard
if (req.headers.authorization !== process.env.RC_WEBHOOK_SECRET) {
return res.status(401).end();
}
const { event } = req.body;
const appUserId = event.app_user_id as string;
switch (event.type) {
case 'INITIAL_PURCHASE':
case 'RENEWAL':
case 'UNCANCELLATION':
grantProAccess(appUserId, event.expiration_at_ms);
break;
case 'CANCELLATION':
case 'EXPIRATION':
revokeProAccess(appUserId);
break;
case 'BILLING_ISSUE':
flagBillingIssue(appUserId);
break;
}
// Always 200 quickly; RevenueCat retries on non-2xx
res.status(200).end();
});Two practical rules: verify the Authorization header you configured in the dashboard so nobody can forge events, and return 2xx fast. Do heavy work asynchronously, because a slow or failing endpoint triggers retries and can create duplicate processing.
How Do You Test IAP in Sandbox and TestFlight?
You cannot test purchases with real money, and you cannot test them in Expo Go. Use each platform's sandbox tooling against a development build or a TestFlight / internal-testing build.
How to test each platform safely:
- iOS: create a Sandbox Apple ID in App Store Connect and sign into it under Settings > App Store > Sandbox Account
- iOS renewals are accelerated in sandbox (a month renews in minutes), which is perfect for testing lifecycle events
- TestFlight uses the sandbox environment too, so it is the closest thing to production before release
- Android: add license testers in Play Console and install a build from a testing track
- Watch the RevenueCat dashboard "Customer History" in real time to confirm events arrive as expected
Common Pitfalls
Entitlement Mapping Mistakes
The number one bug: you create products but forget to attach them to an entitlement, or you attach them to a different entitlement than the one your code checks. Purchases succeed, but entitlements.active is empty and nothing unlocks. Always confirm the exact entitlement identifier string matches between dashboard and code.
Trusting the Client for Receipt Validation
Never grant server-side access based purely on a value the app posts to your API. The client can be tampered with. Let RevenueCat validate receipts and use webhooks or the REST API as your backend source of truth.
Anonymous vs Identified Users
By default RevenueCat assigns an anonymous app user ID. That is fine for many apps, but if you have your own accounts, call logIn with your stable user ID after authentication so purchases follow the user across devices and reinstalls. Calling logOut reverts to a new anonymous ID. Mismatched IDs are a frequent cause of "my subscription disappeared" reports.
import Purchases from 'react-native-purchases';
// After your own auth completes
await Purchases.logIn(user.id);
// On sign out
await Purchases.logOut();A few more traps worth knowing:
- Products not loading usually means store agreements are incomplete or no build is published to a testing track
- Do not call configure more than once; guard it so hot reloads do not double-configure
- Read priceString and introPrice from the store; hardcoded prices break in other currencies and regions
- Handle PURCHASE_CANCELLED_ERROR as a normal path, not an error to surface to the user
- On Android, upgrades and downgrades need proration handling; test plan switches explicitly
Key Takeaways
What to remember when shipping IAP with RevenueCat and Expo:
- RevenueCat abstracts StoreKit and Play Billing into one validated, cross-platform source of truth
- Think in products, entitlements, and offerings; gate features on entitlements, never on product IDs
- IAP requires an Expo development build; Expo Go can never test purchases
- Configure the SDK once with per-platform keys before reading any customer state
- Fetch the current offering for your paywall and read localized prices from the store
- Check entitlements.active from CustomerInfo to unlock, and subscribe to updates for instant reactivity
- Always provide a restore option, and identify users with logIn so entitlements follow them
- Use webhooks as the authoritative server-side signal, and test everything in sandbox and TestFlight before launch
Done right, subscriptions stop being a scary, bug-prone corner of your app and become a reliable, observable system. RevenueCat and Expo together let a small team ship monetization that behaves correctly across platforms, handles the messy lifecycle edge cases, and gives you the analytics to actually grow revenue. Start with a single entitlement and one offering, get the paywall and restore flow solid, then layer in webhooks and experiments as you scale.
