RevenueCat + Expo: Subscriptions, Lifetime Purchases, and Restore
How to add in-app purchases to an Expo app with RevenueCat: entitlements over product IDs, why restore is mandatory, and why the client is the wrong place to trust a purchase.
In-app purchases are one of the few areas of mobile development where a subtle mistake costs real money, either yours or a user's. The APIs are not especially complicated, but the state model is: purchases can be pending, deferred, restored on a new device, refunded, shared through Family Sharing, or cancelled and still active until the end of a billing period.
RevenueCat exists to normalize that state across App Store and Play Store. This guide covers how I integrate it into Expo apps, and more importantly, the design decisions that determine whether your monetization is maintainable.
RevenueCat is not required to sell in-app purchases. Current alternatives include Expo's supported IAP tooling or native StoreKit and Google Play Billing integrations. RevenueCat is a recommendation, not a requirement, and it earns its place mainly through cross-platform receipt validation and subscription state that you would otherwise have to build and maintain yourself.
Why Not Just Use StoreKit Directly
The honest case for a layer like RevenueCat comes down to what you would have to build otherwise:
- Receipt validation. Validating an App Store receipt correctly requires a server, careful handling of the sandbox versus production environments, and dealing with Apple's renewal notifications.
- Cross-platform state. iOS and Android model subscriptions differently. Reconciling them yourself is meaningful ongoing work.
- Subscription lifecycle. Grace periods, billing retry, upgrades, downgrades, and crossgrades each have distinct semantics.
The cost is a third-party dependency in your revenue path, a pricing tier that scales with revenue, and one more service that can have an outage.
For a solo developer or small team, that trade clearly favours using the service. If you are at a scale where a percentage fee exceeds the cost of engineers maintaining billing infrastructure, the calculation changes.
Setup
Real StoreKit and Google Play purchase flows require a development or other native build. In Expo Go, RevenueCat uses its Preview API Mode: it can mock the purchase APIs so you can preview subscription UI and integration flow, but it cannot validate real purchases or production behavior. Use a native build before testing a real purchase flow.
npx expo install react-native-purchases
eas build --profile development --platform iosConfigure once, as early in app startup as you can:
import Purchases, { LOG_LEVEL } from "react-native-purchases";
import { Platform } from "react-native";
const apiKey = Platform.select({
ios: process.env.EXPO_PUBLIC_RC_IOS_KEY,
android: process.env.EXPO_PUBLIC_RC_ANDROID_KEY,
});
export function initPurchases() {
if (__DEV__) {
Purchases.setLogLevel(LOG_LEVEL.DEBUG);
}
Purchases.configure({ apiKey });
}The RevenueCat public SDK key is safe to ship in your app. The secret key is not, and must never appear in client code or in a public environment variable. Anything prefixed EXPO_PUBLIC_ is embedded in your bundle and readable by anyone who downloads the app. Secret keys belong on a server only.
Entitlements, Not Product IDs
This is the single most important design decision, and getting it wrong creates work forever.
Your app should never ask "did the user buy com.example.pro_monthly?" It should ask "does the user have the pro entitlement?"
An entitlement is the capability you are selling. Products are the specific SKUs that grant it. One entitlement is typically granted by many products:
pro_monthlysubscriptionpro_annualsubscriptionpro_lifetimeone-time purchase- a promotional or grandfathered legacy product
If your feature gates check product IDs, then every price experiment, every new region-specific SKU, and every lifetime offer requires touching gating logic across the app. If they check entitlements, you add a product in the RevenueCat dashboard and existing code keeps working.
import Purchases, { CustomerInfo } from "react-native-purchases";
const PRO_ENTITLEMENT = "pro";
export function hasPro(customerInfo: CustomerInfo): boolean {
return customerInfo.entitlements.active[PRO_ENTITLEMENT] !== undefined;
}entitlements.active already accounts for expiry, billing grace periods, and refunds. Do not reimplement that logic by comparing expiry dates yourself.
Reading and Observing Purchase State
Purchase state changes outside your app: a subscription renews, a user refunds through Apple, a family member shares a purchase. Polling on mount is not enough.
import { useEffect, useState } from "react";
import Purchases, { CustomerInfo } from "react-native-purchases";
export function useEntitlement(entitlementId: string) {
const [isActive, setIsActive] = useState<boolean | null>(null);
useEffect(() => {
let cancelled = false;
const apply = (info: CustomerInfo) => {
if (!cancelled) {
setIsActive(info.entitlements.active[entitlementId] !== undefined);
}
};
Purchases.getCustomerInfo().then(apply).catch(() => {
// Network failure: leave the previous known value rather than
// downgrading the user on a transient error.
if (!cancelled) setIsActive((previous) => previous);
});
Purchases.addCustomerInfoUpdateListener(apply);
return () => {
cancelled = true;
Purchases.removeCustomerInfoUpdateListener(apply);
};
}, [entitlementId]);
return isActive;
}The null initial state matters. There are three distinct conditions, and collapsing them into a boolean produces bad UX:
- unknown — still loading, show neither paywall nor pro features
- false — confirmed not entitled, show the paywall
- true — entitled
Rendering a paywall to a paying subscriber during a one-second load is a real and avoidable complaint.
Do not revoke access on a network error. If getCustomerInfo fails offline, RevenueCat can serve a cached value, but if you treat every failure as "not subscribed", paying users lose features on a flaky connection. Fail toward the last known good state.
CustomerInfo listeners are not a push stream for every event on RevenueCat's servers. They run when the SDK receives updated CustomerInfo, typically after SDK activity such as a fetch, purchase, restore, or lifecycle refresh. Use RevenueCat webhooks or server-side verification when a backend needs reliable subscription state.
Presenting Offerings
Fetch products from RevenueCat rather than hardcoding them, so pricing and packaging can change without a release.
import Purchases, { PurchasesOffering } from "react-native-purchases";
export async function loadOffering(): Promise<PurchasesOffering | null> {
const offerings = await Purchases.getOfferings();
return offerings.current ?? null;
}Always display the localized price string from the store. Never format prices yourself.
// Correct: store-provided, correct currency and locale
<Text>{pkg.product.priceString}</Text>
// Wrong: assumes currency, ignores regional pricing
<Text>{"quot; + pkg.product.price}</Text>Apple sets regional prices independently across dozens of storefronts. Hardcoding a currency symbol shows wrong prices to most of the world and is a plausible review rejection.
Making a Purchase
import Purchases, { PURCHASES_ERROR_CODE, PurchasesPackage } from "react-native-purchases";
export async function purchase(pkg: PurchasesPackage): Promise<"success" | "cancelled" | "error"> {
try {
const { customerInfo } = await Purchases.purchasePackage(pkg);
return customerInfo.entitlements.active["pro"] ? "success" : "error";
} catch (error: any) {
// User cancellation is an expected outcome, not a failure to report.
if (error.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
return "cancelled";
}
return "error";
}
}Treating cancellation as an error is a common mistake that produces an alarming error alert when the user simply changed their mind. Check the cancellation flag first and stay silent.
Also expect deferred purchases. Under Ask to Buy, a child initiates a purchase that a parent approves later, potentially hours later. The purchase call resolves without granting the entitlement, and the entitlement arrives later through the update listener. This is exactly why the listener above exists.
Restore Purchases Is Not Optional
Apple requires a visible restore mechanism for non-consumable products and subscriptions. Missing it is a straightforward rejection.
export async function restore(): Promise<boolean> {
const customerInfo = await Purchases.restorePurchases();
return customerInfo.entitlements.active["pro"] !== undefined;
}Beyond compliance, restore is genuinely needed: users get new devices, reinstall apps, and sign in on a second device. Without restore, a user who paid you has no way to get back what they bought, which produces refund requests and one-star reviews.
Two implementation details worth care:
- Report the outcome honestly. If restore finds nothing, say so plainly ("No previous purchases found for this Apple ID") rather than showing a generic error. The usual cause is a different Apple ID, and telling the user that lets them fix it.
- Put it somewhere findable. On the paywall and in settings. A reviewer who cannot find the restore button will reject the build.
The Client Cannot Be Trusted
This is the part that gets skipped most often.
Everything above runs on the user's device. A determined user can modify a client build, and jailbroken devices can intercept StoreKit responses. If unlocking a premium feature is purely a client-side boolean, it can be flipped.
How much this matters depends entirely on what you are protecting:
- Cosmetic or local features — client-side checks are usually fine. The realistic loss is small and the engineering cost of server validation is not justified.
- Server-backed resources — anything that costs you money per use, such as AI inference, storage, or third-party API calls, must be validated server-side. Otherwise a modified client can run up your bill directly.
- Competitive state in a game — anything affecting leaderboards or shared state must be server-authoritative regardless of purchase status.
The correct pattern for the second and third cases is to make the server the arbiter:
// Client attaches identity, never entitlement claims
const response = await fetch(`${API_URL}/premium-action`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${sessionToken}`,
},
body: JSON.stringify({ action: "generate" }),
});The server then checks entitlement itself, either through the RevenueCat REST API or against subscription state kept current by RevenueCat webhooks. It must never accept an isPremium flag sent by the client, because that is not evidence of anything.
Log in the user with a stable application user ID via Purchases.logIn(userId) when you have your own accounts. This ties entitlements to your identity system rather than to an anonymous device ID, which is what makes cross-platform and cross-device entitlement work at all.
Testing
Sandbox testing on iOS uses a dedicated sandbox Apple ID created in App Store Connect. Sign into it in the device's App Store settings, not in your app.
Sandbox subscription durations are heavily accelerated: a one-month subscription typically renews in minutes, so you can observe renewal and expiry within a single session. Renewals are also capped, after which the subscription expires, which is a useful way to test your expiry handling.
TestFlight builds use the sandbox environment, not production. Purchases there are not real charges. This surprises people who assume TestFlight behaves like production.
Cases genuinely worth testing before shipping:
- Purchase, then delete and reinstall the app, then restore
- Purchase on one device, check entitlement on a second device
- Cancel a subscription and confirm access persists until period end
- Airplane mode with a previously entitled user, confirming access is not revoked
- A user with no purchase tapping restore
That fourth case is the one that produces angry emails when it is wrong.
Summary
Model what you sell as entitlements, not products, and the rest of the integration stays flexible. Read state through entitlements.active rather than reconstructing expiry logic. Treat unknown, entitled, and not-entitled as three separate states, and never downgrade a user because of a network error.
Restore is mandatory both for review and for users, and it needs an honest, findable implementation. Above all, decide deliberately how much you trust the client: for cosmetic unlocks a local check is proportionate, but for anything that costs you money per request or affects shared state, entitlement must be verified by a server that does not take the client's word for it.
Used In
A circular puzzle game that rethinks falling-block gameplay across a radial 2D board and a fully rotatable 3D globe, with handcrafted levels, progression, online services, and a cosmetic economy.
A focused mobile workout tracker for daily rep-based challenges, animated exercise progress, streaks, and simple bodyweight consistency.
Related Tutorials
The full path from a working Expo project to an approved App Store release: build profiles, credentials, versioning, TestFlight, and the review rejections that are easiest to avoid.
Validating every input at the edge, rate limiting without a database, and using signed state so a client can hold data it cannot forge.