RevenueCat and Expo Setup, Start to Finish
Wiring subscriptions into an Expo app with RevenueCat takes four moves: install react-native-purchases in a development build (Expo Go cannot run it), configure the SDK with a platform key, map your store entitlements to your tiers, and confirm each purchase server-side with a webhook. This guide walks all four, then tells the empty-packages debugging saga honestly.
We shipped this exact stack in a live React Native and Expo app (SparkQuest, on iOS and Android). The code snippets below are the real patterns we used, sanitized of keys.
Why RevenueCat instead of raw StoreKit or Play Billing
You can call Apple's StoreKit and Google Play Billing directly. For a solo builder, you usually should not. The two store APIs behave differently, receipt validation is fiddly, and cross-platform subscription state (a user who bought on iPhone and opens on Android) is genuinely hard to get right. RevenueCat sits on top of both, gives you one SDK, one entitlement model, and a server that holds the source of truth for who is subscribed.
The trade is a fee once you are earning real money. We cover the exact number below, and the honest comparison against Stripe and Lemon Squeezy lives in Stripe vs RevenueCat vs Lemon Squeezy. For a mobile app selling through the app stores, RevenueCat is the default we would pick again.
Step 1: You need a development build, not Expo Go
react-native-purchases contains native code. Expo Go, the sandbox app, cannot load custom native modules, so the real SDK will not run there. You need an Expo development build:
# one time: create a development build you install on a real device
eas build --profile development --platform ios
eas build --profile development --platform android
There is a useful escape hatch. react-native-purchases has a Preview API Mode: when it detects it is running inside Expo Go, it swaps the native calls for JavaScript mocks. Your paywall screens render, your offering-fetch logic runs, nothing crashes. Real purchases will not go through, but you can build and preview the subscription UI without waiting on a native build. Do your visual work in Expo Go, do your purchase testing in a dev build on a device. This is verified in RevenueCat's Expo docs, July 2026.
Step 2: Configure the SDK with the right key per platform
RevenueCat gives you separate API keys for Apple and Google, plus you likely want a test key for development. The cleanest pattern is a small module that picks the key from the platform and the build mode, then configures once.
import { Platform } from "react-native";
import Purchases from "react-native-purchases";
const isWeb = Platform.OS === "web";
const testKey = process.env.EXPO_PUBLIC_REVENUECAT_TEST_KEY;
const appleKey = process.env.EXPO_PUBLIC_REVENUECAT_APPLE_KEY;
const googleKey = process.env.EXPO_PUBLIC_REVENUECAT_GOOGLE_KEY;
const getApiKey = (): string | undefined => {
if (isWeb) return undefined; // RevenueCat is native stores only
if (__DEV__) return testKey; // dev and test builds
return Platform.OS === "ios" ? appleKey : googleKey; // production
};
const apiKey = getApiKey();
const isEnabled = !!apiKey && !isWeb;
if (isEnabled) {
Purchases.configure({ apiKey: apiKey! });
}
Two things this buys you. First, the __DEV__ switch means your development builds hit the RevenueCat test project and production builds hit the real one, without you juggling keys by hand. Second, the isEnabled flag lets the rest of your app ask "is billing even available here?" instead of assuming it always is. Our web build shares the same codebase and simply has no keys, so billing is off and nothing throws.
Step 3: Wrap the SDK so a missing key never crashes the app
This is the pattern we would tell every solo builder to copy. Instead of calling Purchases directly all over the app, put one guard in front of it that returns a typed result. If RevenueCat is not configured (web, or a build with no key), the call is a clean no-op, not an exception.
type Reason = "web_not_supported" | "not_configured" | "sdk_error";
type Result<T> =
| { ok: true; data: T }
| { ok: false; reason: Reason; error?: unknown };
const guard = async <T>(op: () => Promise<T>): Promise<Result<T>> => {
if (isWeb) return { ok: false, reason: "web_not_supported" };
if (!isEnabled) return { ok: false, reason: "not_configured" };
try {
return { ok: true, data: await op() };
} catch (error) {
return { ok: false, reason: "sdk_error", error };
}
};
export const getOfferings = () => guard(() => Purchases.getOfferings());
export const purchasePackage = (pkg) =>
guard(async () => (await Purchases.purchasePackage(pkg)).customerInfo);
export const getCustomerInfo = () => guard(() => Purchases.getCustomerInfo());
Every screen now handles ok: false gracefully and shows a designed state instead of a white crash. This one decision saved us during testing more than any other, because half of "RevenueCat is broken" turns out to be "RevenueCat is not configured in this build" and the guard tells you exactly that.
Step 4: Map store entitlements to your own tiers
In the RevenueCat dashboard you define entitlements (a stable name like pro, weekly, annual) and attach store products to them. In the app you read the active entitlements and translate to whatever tier system you already use. Set up a listener so the tier updates the moment a purchase or renewal lands.
export const setupRevenueCatListener = (
onTierChange: (tier: "free" | "weekly" | "monthly" | "annual") => void
) => {
if (!isEnabled) return () => {};
const listener = (info) => {
const active = info.entitlements.active;
if (active["annual"] || active["lifetime"]) onTierChange("annual");
else if (active["pro"] || active["monthly"]) onTierChange("monthly");
else if (active["weekly"] || active["starter"]) onTierChange("weekly");
else onTierChange("free");
};
Purchases.addCustomerInfoUpdateListener(listener);
return () => Purchases.removeCustomerInfoUpdateListener(listener);
};
Notice the mapping is deliberately forgiving: we check for more than one entitlement name per tier (pro or monthly, weekly or starter). Store and dashboard naming drifts over a project's life, and a tolerant map means a renamed entitlement degrades to a lower tier instead of dropping a paying user to free.
Step 5: Confirm purchases server-side with a webhook
The device tells your app a purchase happened. Your backend should not take the device's word for it. RevenueCat sends webhook events (INITIAL_PURCHASE, RENEWAL, CANCELLATION, EXPIRATION, BILLING_ISSUE) to an endpoint you own, and that endpoint is where you update the user's real subscription state. We ran ours as a Supabase Edge Function so the app stays independent of any single backend.
The core of it is a product-to-tier map and a switch on the event type:
const PRODUCT_TO_TIER = {
"app_weekly_pass": "weekly",
"app_pro_monthly": "monthly",
"app_pro_annual": "annual",
};
// inside the handler, after verifying the auth header:
const userId = event.app_user_id;
if (userId.startsWith("$RCAnonymousID")) return ok(); // skip anonymous
switch (event.type) {
case "INITIAL_PURCHASE":
case "RENEWAL":
case "UNCANCELLATION": {
const tier = PRODUCT_TO_TIER[event.product_id];
if (tier) await setUserTier(userId, tier, event.expiration_at_ms);
break;
}
case "CANCELLATION":
case "EXPIRATION":
await setUserTier(userId, "free");
break;
case "BILLING_ISSUE":
// log, do not immediately downgrade; the user may fix their card
break;
}
Three details from our real webhook that save you a support ticket later. Skip events where app_user_id starts with $RCAnonymousID, those are users who never signed in and you have nothing to update. Verify the authorization header against a secret you set in both RevenueCat and your function's environment. And on BILLING_ISSUE, log it but do not downgrade the user on the spot, because the grace period often recovers the payment. The full function, including the one-time consumable path we used for a spark pack, is the shape most indie apps need.
Sandbox testing before you ship
Set up a sandbox tester in App Store Connect and a license tester in Google Play. Install a dev or preview build on a device, sign in with the sandbox account, and run a real purchase. It will not charge you. Watch three things: the offering loads with your packages in it, the purchase completes and the entitlement flips on, and the webhook fires (check your function logs for the INITIAL_PURCHASE event). If all three happen in sandbox, production is mostly a matter of the same products moving to Approved.
The empty-packages debugging saga, told straight
Here is the part that cost us days. On the first production build the paywall loaded, but the package list was empty. No crash, no error, just no products to buy. The code was fine. The problem was configuration in three layers, and we peeled them one at a time.
First, the platform API keys were not reaching the build. RevenueCat's Apple and Google keys have to be present in the build environment, not just in a local file. For an EAS build that means putting them in the build profile's env block. We had wired the test key for development but the production keys were absent, so the live SDK configured against nothing. Fixing that got the SDK talking.
Second, the store products themselves were not in a sellable state. Offerings return empty until your in-app purchase products are Approved (or at least Ready to Submit) in App Store Connect, and until the product IDs attached to your RevenueCat offering match the store exactly. A single typo between the store product ID and the RevenueCat product ID returns an empty list with no complaint.
Third, we were partly chasing ghosts because some of our early testing ran in Expo Go, where only the Preview mock answers. Real offerings only appear in a real build on a device.
We shipped a build with verbose RevenueCat diagnostics on purpose, read the logs, confirmed the key was landing and the products were the issue, then shipped a clean build with the diagnostics stripped back out. The lesson we would pass on: when packages are empty, do not touch the SDK code. Confirm the key is present at runtime, then confirm the products are approved and the IDs match. It is configuration, nearly every time.
What RevenueCat actually costs
RevenueCat is free up to $2,500 in monthly tracked revenue. Monthly tracked revenue is what your app processes through the App Store and Google Play before Apple or Google take their commission. Above $2,500, the Pro plan charges 1 percent of monthly tracked revenue, with no per-seat fees, no minimums, and no cap on subscribers. On $10,000 tracked in a month that is $100. Verified on revenuecat.com/pricing, July 2026.
Worth knowing: the 1 percent is charged on gross tracked revenue, so it sits on top of the store commission, not after it. That still nets out cheaper than most indies expect, and the free tier carries you well past your first paying users. If your app is web, not mobile, RevenueCat is the wrong tool and you want the comparison in Stripe vs RevenueCat vs Lemon Squeezy.
Where this fits in the journey
Getting paid is one stage. Deciding the subscription is worth building is subscription models for indie apps, and setting the actual numbers is how to price your app. Once the payments work, the remaining path (review, testing gates, launch) is laid out in the full how to ship an app in 2026 guide, and the build pipeline that gets your binary to the stores is ship an Expo app with EAS Build and Submit.
Every idea on GenerateIdeas.app ships with a suggested monetization model, so you know whether it wants a subscription before you wire one. See the monetization plan for your idea
Questions from the field
- Do I need a development build to use RevenueCat with Expo?
- Yes. react-native-purchases ships native code, so Expo Go cannot run the real SDK. You need an Expo development build (eas build --profile development) or a production build. There is one exception: react-native-purchases includes a Preview API Mode that mocks the native calls inside Expo Go, so your subscription screens still render and your logic still runs. Real purchases only work in a dev or production build on a device.
- What does RevenueCat cost in 2026?
- RevenueCat is free up to $2,500 in monthly tracked revenue (the revenue your app processes through the App Store and Google Play before Apple or Google take their cut). Above that, the Pro plan charges 1 percent of monthly tracked revenue, with no per-seat fees, no minimums, and no subscriber cap. On $10,000 of tracked revenue that is $100 a month. Verified on revenuecat.com/pricing, July 2026.
- How does RevenueCat know which subscription a user bought?
- RevenueCat maps each store product to an entitlement you define in its dashboard, for example an entitlement called pro or weekly. Your app reads customerInfo.entitlements.active to see what is on, and you translate that to your own tier. For your backend to trust the state, you also send RevenueCat webhook events to a server endpoint and update the user record there, so a purchase is confirmed server-side and not just on the device.
- Why are my RevenueCat packages empty?
- Empty offerings almost always mean a configuration gap, not a code bug. The usual causes: the platform API key is missing from your build environment, your products are not yet Approved or Ready to Submit in App Store Connect or Google Play, the product IDs in the store do not match the ones attached to your RevenueCat offering, or you are testing in Expo Go where only the Preview mock runs. Confirm the key is present at runtime first, then check product status in each store.