All articles
Mobile DevelopmentOctober 15, 2026 6 min read

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

A field guide to shipping subscriptions in React Native in 2026 — what RevenueCat gets right, where StoreKit 2 still bites, and the receipt edge cases that quietly refund your MRR.

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

Subscriptions look simple in the pitch deck and brutal in production. The SDK call succeeds, the receipt validates, the user is charged — and three days later your dashboard shows a churn spike that is actually a billing retry loop you never instrumented. This is a field guide to shipping in-app purchases in React Native in 2026, based on what we keep seeing in client codebases.

Why IAP is still the hardest surface in a mobile app

Most React Native features fail loudly. IAP fails quietly. A broken push token logs a 400. A broken purchase flow silently drops a renewal, and the only signal is a support ticket from a user who swears they paid.

Three things make it uniquely painful:

  • Two stores, two state machines. StoreKit 2 and Google Play Billing 7+ have converged on vocabulary but still disagree on timing — pending transactions, grace periods, and refund webhooks fire differently.
  • The client is not the source of truth. The device tells you a purchase happened. Only the store (and your server) can tell you if it's still valid.
  • Review teams test the sad paths. Restore purchases, network loss mid-transaction, and family sharing are where rejections live.

If you only remember one thing: the receipt is a hint, the entitlement is the truth.

The 2026 stack we actually ship

For almost every client project we take on now, the default is:

  • RevenueCat as the entitlement layer and server-side source of truth
  • react-native-purchases (RevenueCat's SDK) in the app
  • Expo with a dev client — not Expo Go, which has never supported real IAP
  • StoreKit 2 on iOS 17+ and Play Billing Library 7 on Android under the hood
  • A thin backend endpoint that mirrors RevenueCat's webhook into our own user DB

We have shipped direct StoreKit 2 integrations. They work. We stopped recommending them for anything with more than one subscription tier, more than one platform, or any plan to run promotions. The engineering cost of maintaining your own receipt validation, grace period logic, and refund handling across both stores is not zero, and it compounds.

When to skip RevenueCat

There are real cases:

  • One-time consumables only, single platform, no restore story (rare)
  • Enterprise apps where billing happens outside the store
  • Apps already deep into Adapty, Qonversion, or Glassfy — switching costs are real

For everything else in 2026, the time you save on webhooks alone pays the subscription fee.

Wiring it up in an Expo project

Assuming an Expo SDK 52+ project with a dev client, the install is boring in a good way:

npx expo install react-native-purchases
npx expo prebuild --clean

Add the config plugin in app.json so the iOS entitlement and Android billing permission are generated:

{
  "expo": {
    "plugins": [
      [
        "react-native-purchases",
        { "androidPaymentFlow": "play" }
      ]
    ]
  }
}

Initialize once, at app start, after you know the user's ID:

import Purchases, { LOG_LEVEL } from 'react-native-purchases';

export async function initBilling(appUserId: string) {
  if (__DEV__) Purchases.setLogLevel(LOG_LEVEL.DEBUG);

  await Purchases.configure({
    apiKey: Platform.select({
      ios: process.env.EXPO_PUBLIC_RC_IOS!,
      android: process.env.EXPO_PUBLIC_RC_ANDROID!,
    })!,
    appUserID: appUserId,
  });
}

Two things people get wrong here:

  1. Do not call configure with an anonymous ID and then "log in" later with logIn() once you have a real user — unless you understand how RevenueCat aliases anonymous IDs. It works, but it also silently merges entitlements across devices in ways that will surprise you during QA.
  2. Never ship with LOG_LEVEL.DEBUG on in production. It's noisy and leaks internal state to logs.

The purchase flow, honestly

Here is the shape we use. Note what is and is not awaited.

async function buy(packageId: string) {
  const offerings = await Purchases.getOfferings();
  const pkg = offerings.current?.availablePackages
    .find(p => p.identifier === packageId);
  if (!pkg) throw new Error('package_unavailable');

  try {
    const { customerInfo } = await Purchases.purchasePackage(pkg);
    const pro = customerInfo.entitlements.active['pro'];
    return { ok: true, expiresAt: pro?.expirationDate };
  } catch (e: any) {
    if (e.userCancelled) return { ok: false, reason: 'cancelled' };
    if (e.code === 'PAYMENT_PENDING') return { ok: false, reason: 'pending' };
    throw e;
  }
}

The PAYMENT_PENDING branch is the one most apps forget. On Android, parents approving a child's purchase, or users paying via carrier billing or cash vouchers in some regions, land here. The purchase is not failed — it is parked. Your UI must reflect "we're waiting on the store" and not "try again," because trying again creates a duplicate pending transaction and a very annoyed user.

Entitlements beat product IDs

Model your app around entitlements (pro, team, lifetime), not SKUs. SKUs change. You will add an annual tier, a regional discount, a lifetime unlock, a win-back offer. If your gating code says if (hasProduct('pro_monthly_v2')), every pricing experiment becomes a release. If it says if (entitlements.active.pro), nothing in the client changes.

Receipts, webhooks, and the lies they tell

StoreKit 2 gave us signed JWS transactions, which is a genuine improvement over the base64 blob era. But "signed" does not mean "current." A transaction signed last Tuesday tells you the user bought something last Tuesday. It does not tell you whether they refunded on Wednesday.

The only reliable pattern we have found:

  • Treat the client-reported entitlement as optimistic. Unlock the UI immediately for UX, but mark it as unverified.
  • Server receives a RevenueCat webhook on INITIAL_PURCHASE, RENEWAL, CANCELLATION, BILLING_ISSUE, EXPIRATION, PRODUCT_CHANGE, and critically REFUND.
  • The server flips the authoritative flag in your DB. The client reads that flag on next launch or via a push.

The refund case is where unprepared apps lose money. Apple's refund rate in some categories sits in the low single digits, but we have seen specific apps — anything with a one-time high-ticket unlock — hit much higher. If you only check entitlements at purchase time, every refund is a user who keeps the content for free.

The sandbox is not production

Sandbox renewals happen on compressed clocks (a monthly sub renews in minutes). This is useful for testing, misleading for confidence. Specifically:

  • Sandbox grace periods behave differently than production
  • Family Sharing in sandbox is flaky; test on real TestFlight builds with real family accounts
  • Android's License Testers in Play Console do not exercise every billing path — specifically, deferred payments

Budget a week of real-money testing on a staging product before launch. Yes, real money. Refund yourself after.

App review pitfalls that still get apps rejected

From rejections we have personally handled in the last 18 months:

  • No visible Restore Purchases button. Required on iOS. Must be reachable without a login wall for apps that allow anonymous use.
  • Price shown only in USD when the StoreKit price object has a localized string. Use pkg.product.priceString.
  • Marketing a feature as "free" when it requires a subscription to be useful. Reviewers read the screenshots.
  • Linking out to a web paywall on iOS for digital goods. External purchase links are allowed in narrow categories under specific entitlements in 2026, but if you have not applied for them, do not add the link.
  • Blocking app functionality during the purchase sheet. If the user dismisses, you should return them to a sensible state, not a spinner.

Android is more forgiving at review time and less forgiving at refund time. Google's auto-refund policies are generous to users, which is good for them and a reason to lean on your webhook pipeline.

What we'd do

If you are starting a new subscription app in React Native tomorrow:

  1. Set up RevenueCat before you design the paywall. Model entitlements first, SKUs second.
  2. Build the webhook listener on day one, even if it just writes to a log table. You will need the history.
  3. Instrument four events end-to-end before launch: purchase success, pending payment, refund, and expiration. If any of these are silent in your analytics, you are flying blind.
  4. Test Family Sharing and Restore Purchases on real devices with real accounts. These are the two flows that get rejected and the two flows users complain about most.
  5. Keep the client dumb. Entitlement state lives on your server, mirrored from RevenueCat, read by the app.

If you want a second set of eyes on an existing IAP implementation — especially one where the dashboard numbers do not match your bank deposits — that is exactly the kind of thing our mobile team spends its weeks on.

#react-native#expo#in-app-purchase#revenuecat#storekit#mobile

Want a team like ours?

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

Start a project