All articles
Mobile DevelopmentJuly 29, 2026 7 min read

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.

In-App Purchases in React Native 2026: RevenueCat, StoreKit 2, and the Receipts That Lie

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-iap v13+)
  • 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:

  1. appUserID must be your user id, not null and 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.
  2. 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:

  1. Model entitlements on your backend first. Decide what pro means before you touch StoreKit.
  2. Wire up RevenueCat with your real user id and a single pro entitlement.
  3. Build the paywall with restore, terms, privacy, and clear renewal language from day one.
  4. Handle webhooks server-side: INITIAL_PURCHASE, RENEWAL, CANCELLATION, EXPIRATION, BILLING_ISSUE, REFUND.
  5. 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.

#React Native#Expo#IAP#RevenueCat#StoreKit 2#Play Billing

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project
React Native In-App Purchases in 2026: A Field Guide · 72Technologies