In-App Purchases in React Native 2026: RevenueCat, StoreKit 2, and the Receipts That Lie
IAP looks simple until refunds, family sharing, and grace periods hit production. Here's how we wire React Native purchases in 2026 without trusting the client.

Every mobile team hits the same wall eventually: the free tier stops paying the bills and someone files a ticket titled "add subscriptions." Two weeks later you're arguing about grace periods, family sharing, and why a refunded user still has premium in your database. This is the article we wish we'd had before the third rewrite.
Why IAP is harder than it looks
In-app purchase is not a payment problem. Stripe solved payments. IAP is a state reconciliation problem across three systems that don't fully trust each other: the store (Apple or Google), your backend, and the device. Any of them can be wrong, offline, or lying at any given moment.
A few facts that catch teams off guard:
- A user can buy on iOS, refund via Apple support, and your app never gets a client-side event.
- Family Sharing on iOS means the purchaser and the entitled user are different Apple IDs.
- Google Play can put a subscription into a grace period or on hold state that looks active in some APIs and inactive in others.
- StoreKit 2 signed transactions are JWS — you're expected to verify the signature, not just trust the payload.
- Sandbox receipts on iOS renew on accelerated timers (a monthly sub renews every ~5 minutes). Your logic must handle that without spamming users.
If your source of truth is the device, you will ship bugs. Full stop.
The 2026 stack: what we actually reach for
For a React Native app shipping subscriptions today, we default to this shape:
- RevenueCat for entitlements, offerings, and webhooks
- StoreKit 2 under the hood on iOS (via RevenueCat or
react-native-iapv13+) - Google Play Billing Library 7+ on Android
- Expo with a custom dev client — the managed workflow no longer blocks IAP the way it did in 2022
- Your own backend as the authoritative entitlement store, synced from RevenueCat webhooks
Why RevenueCat and not roll-your-own? Because writing your own receipt validator is a six-month project that never really ends. Apple's server notifications v2, Google's Real-Time Developer Notifications, StoreKit 2 JWS verification, offer codes, promotional offers, introductory pricing eligibility — each of these has its own edge cases, and the store APIs change every WWDC and every I/O.
We still recommend RevenueCat in 2026 because the abstraction has held up. But — and this matters — you still need your own backend record. Don't call RevenueCat from your API on every request. Cache entitlements server-side and update them from webhooks.
When native SDKs make sense instead
Skip RevenueCat only if:
- You have exactly one non-renewing product and no subscriptions.
- You already have a payments team that owns receipt validation.
- Your compliance requirements forbid the third-party data flow.
Otherwise, the $8k/yr threshold pricing is cheaper than one engineer-week of chasing a subscriptionGroupIdentifier bug.
Wiring it up in Expo
Assuming a config plugin setup with expo-dev-client, the install looks roughly like this:
npx expo install react-native-purchases
npx expo prebuild --clean
eas build --profile development --platform ios
Then a minimal bootstrap:
import Purchases, { LOG_LEVEL } from 'react-native-purchases';
import { Platform } from 'react-native';
export async function initIAP(appUserId: string) {
Purchases.setLogLevel(LOG_LEVEL.WARN);
await Purchases.configure({
apiKey: Platform.select({
ios: process.env.EXPO_PUBLIC_RC_IOS_KEY!,
android: process.env.EXPO_PUBLIC_RC_ANDROID_KEY!,
})!,
appUserID: appUserId, // your stable user id, never the device id
});
// Prime the cache so the paywall renders instantly
await Purchases.getOfferings();
}
Two things people get wrong here:
appUserIDmust be your user id, notnulland not the device id. If you let RevenueCat generate an anonymous id, you'll spend a week trying to migrate purchases when the user signs in on a second device.- Configure once, at app start, not lazily when the paywall opens. The first
getOfferings()call warms product metadata from the store, which is slow on cold networks.
The purchase flow itself
async function buy(pkg: PurchasesPackage) {
try {
const { customerInfo } = await Purchases.purchasePackage(pkg);
const entitled = customerInfo.entitlements.active['pro'] != null;
if (entitled) await syncEntitlementFromServer();
return entitled;
} catch (e: any) {
if (e.userCancelled) return false;
// Log but don't show raw error strings — Apple review hates that
reportError(e);
throw new Error('purchase_failed');
}
}
Note syncEntitlementFromServer(). Never grant premium features from the client purchase result alone. Fire a request to your backend, which should verify via RevenueCat's REST API (or its webhook cache) and return the authoritative entitlement.
The receipts that lie
This is the section we wish someone had written for us.
1. The "refunded but still active" ghost
Apple refunds don't fire a client-side event. If you gate features on customerInfo cached at login, a refunded user keeps premium until the next app launch. The fix: subscribe to RevenueCat's REFUND webhook on your backend and revoke server-side. Then have the client re-fetch entitlements on foreground.
2. Google's grace period ambiguity
On Android, a failed renewal doesn't immediately kill a subscription. It enters grace period (up to 30 days). The PurchasesEntitlementInfo.isActive flag stays true, but willRenew flips to false. If your product decision is "turn off features when the card fails," you'll turn off features for paying customers whose card just needs an update. We usually keep entitlements on during grace and show a soft banner asking them to update payment.
3. Family Sharing on iOS
If your product is "one seat per Apple ID," Family Sharing will surprise you. The purchaser's Apple ID and the entitled family member's Apple ID are different. RevenueCat exposes this via ownershipType. If you don't want family members to inherit, you have to explicitly disable Family Sharing on the product in App Store Connect before you ship — you can't change it later without creating a new product.
4. Sandbox renewal storms
During QA, a monthly sub renews every 5 minutes and auto-cancels after 6 renewals. If your analytics fire a subscription_renewed event every time, you'll pollute funnels. Gate analytics on environment === 'production' from the receipt.
5. Restore purchases must exist and must work
Apple review will reject apps without a visible Restore Purchases button on any paywall. Not a hidden menu. Not a settings screen three taps deep. On the paywall. This is the single most common IAP rejection we still see in 2026.
async function restore() {
const info = await Purchases.restorePurchases();
return info.entitlements.active['pro'] != null;
}
App Store review pitfalls specific to IAP
A short, unglamorous checklist that has saved us multiple resubmissions:
- Show price, duration, and renewal terms on the paywall itself. Not on a linked page. Auto-renewable subscriptions have specific wording requirements.
- Link to your Terms of Use (EULA) and Privacy Policy from the paywall. Both. Every time.
- Don't use the word "free trial" if you're actually offering an introductory price of $0. Use "free trial" only when it's a StoreKit intro offer configured as a free trial.
- If you offer any purchase outside the store (web, promo codes redeemed on your site), be very careful about how the app references it. Since the 2024 rulings, external purchase links are allowed in some regions with specific entitlements, but the app cannot steer users to them in ways that violate the guidelines in your target markets.
- Test with a sandbox account signed into Settings, not just via Xcode. The behavior differs.
When native beats React Native for IAP
Be honest: for the paywall UI and purchase flow, React Native is fine. For anything involving StoreKit 2 message signing, App Store Server API calls, or offer code redemption sheets, native Swift is still less painful. If your app is 90% RN but you have a complex promotional offers engine, consider a thin Swift module that exposes just the offer eligibility calculation and let JS handle the rest.
On Android, Play Billing 7 is well-covered by react-native-purchases and react-native-iap. We haven't needed to drop to Kotlin for billing since 2023.
Where we'd start
If you're adding IAP to a React Native app this quarter, do these in order:
- Model entitlements on your backend first. Decide what
promeans before you touch StoreKit. - Wire up RevenueCat with your real user id and a single
proentitlement. - Build the paywall with restore, terms, privacy, and clear renewal language from day one.
- Handle webhooks server-side:
INITIAL_PURCHASE,RENEWAL,CANCELLATION,EXPIRATION,BILLING_ISSUE,REFUND. - Test refund and grace-period paths in sandbox before you test the happy path a hundred times.
Skip step 4 and you'll ship. Skip step 1 and you'll ship, then rewrite it. If you'd rather have us do the plumbing, that's roughly the shape of a mobile engagement we run.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
App Size Bloat in React Native: Where the Megabytes Actually Come From in 2026
A field guide to shrinking React Native app binaries in 2026: Hermes bytecode, native modules, image assets, and the App Store Connect numbers that don't match what you built locally.

The New Architecture in React Native: What Actually Breaks When You Flip It On in 2026
Fabric and TurboModules are the default now, but flipping the switch on a real app is still a minefield. Here's what breaks, what to test first, and how to stage the rollout without shipping a broken build.

Deep Links in React Native 2026: Universal Links, App Links, and the Cold-Start Traps
Deep linking in React Native looks trivial until a marketing team ships a campaign and half the users land on the home screen. Here's what actually breaks in 2026 — and how we route around it.
