All articles
Mobile DevelopmentJune 30, 2026 7 min read

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

A practical breakdown of wiring IAP and subscriptions into a React Native app in 2026 — what RevenueCat actually saves you, where StoreKit 2 still bites, and the receipt edge cases that cost real revenue.

Subscriptions are the part of a React Native app where the demo looks easy and production looks like a finance audit. The SDK call returns success, the user is charged, and three days later your support inbox fills up with people who paid and got nothing. We've shipped enough IAP flows on Expo and bare RN to have opinions, so here's the honest map of the territory in 2026.

Why IAP is harder than it looks in React Native

The core problem isn't the JavaScript bridge. It's that you're gluing together four systems that all have different ideas about what "the user paid" means:

  • Apple StoreKit 2 — signed JWS transactions, server notifications v2, refund webhooks.
  • Google Play Billing 7+ — purchase tokens, real-time developer notifications, mandatory base plans and offers.
  • Your backend — the only place you should ever trust entitlement state.
  • Your React Native client — which has to reconcile all of the above without flickering the paywall.

The client SDK telling you a purchase succeeded is the weakest signal in that chain. It can lie because the network dropped, because the user backgrounded the app mid-flow, because StoreKit replayed a transaction from another device, or because Play Billing is acknowledging an old token. Treat the client as a UI hint, not a source of truth.

The two paths: roll your own vs. RevenueCat

There are basically two architectures shipping in production React Native apps right now:

  1. Direct integration using react-native-iap or Expo's expo-in-app-purchases successor, plus your own backend receipt validation.
  2. RevenueCat (react-native-purchases) as the entitlement layer, with their servers handling validation and webhooks.

We've done both. For most teams shipping subscriptions, RevenueCat pays for itself in the first refund storm. For one-time purchases on a small catalog, rolling your own is fine.

What RevenueCat actually saves you

The pitch is "subscription infrastructure," which sounds vague until you've debugged a Family Sharing edge case at 2am. The concrete things you stop building:

  • Receipt validation against Apple and Google, including the StoreKit 2 JWS verification with Apple's rotating public keys.
  • Server-to-server notification handling — Apple's ASSN v2 and Google's RTDN both need an HTTPS endpoint that responds correctly within a tight window or they retry and your state desyncs.
  • Cross-platform entitlement model, so the same user on iPhone and Android sees the same pro status without you writing a join table.
  • Sandbox and TestFlight reconciliation, which is where most homegrown systems break first.
  • Refund and chargeback webhooks, including Apple's consumption requests for refund decisions.

What it does not save you from: paywall UX, restore logic, App Review's mood, or thinking carefully about entitlement caching.

A minimal RevenueCat setup in Expo

With Expo's config plugin system this is genuinely close to the marketing-page experience, assuming you're on a development build (not Expo Go):

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

export async function initIAP(appUserId?: string) {
  Purchases.setLogLevel(LOG_LEVEL.WARN);

  const apiKey = Platform.select({
    ios: process.env.EXPO_PUBLIC_RC_IOS_KEY!,
    android: process.env.EXPO_PUBLIC_RC_ANDROID_KEY!,
  })!;

  await Purchases.configure({ apiKey, appUserID: appUserId });
}

export async function getEntitlement() {
  const info = await Purchases.getCustomerInfo();
  return info.entitlements.active['pro'] ?? null;
}

The important detail: pass your own stable appUserID once the user signs in, and call logIn/logOut on auth state changes. Anonymous IDs are fine for trial flows, but the moment you have a real user, link them. Otherwise you'll spend a week reconciling ghost subscribers.

StoreKit 2 and Play Billing 7: what changed under you

If you last touched IAP in 2022, both stores have moved.

StoreKit 2 uses signed JWSRepresentation strings instead of base64 receipt blobs. You verify them with Apple's public keys, and they include the original transaction ID, purchase date, and revocation date in a structured way. Server-side, you should be on App Store Server API v1.10+ and App Store Server Notifications v2. The v1 notifications still exist but are missing fields you'll want for refund logic.

Play Billing Library 7 killed SkuDetails in favor of ProductDetails, and subscriptions now use base plans and offers. This means a single product can have a monthly base plan with a 7-day intro offer and a separate yearly plan, all under one product ID. Your paywall code has to understand offer tokens or you'll silently charge users the wrong price.

If you're on react-native-iap, make sure you're on a 12.x or newer release that exposes the offer token API. Older snippets you'll find on Stack Overflow will compile and run but quietly skip the intro offer.

The receipts-that-lie problem

A real war story. A client's app showed users as pro immediately after purchase based on the client SDK callback. The backend would catch up via webhook within seconds, usually. About 0.5% of the time, the webhook arrived ten minutes later or never. Users who closed the app in that window came back to a paywall, paid again (some of them), and then hit Apple for a refund.

The fix isn't "trust the webhook harder." It's:

  1. On client purchase success, mark the user as provisionally entitled in local state, with a TTL of maybe 15 minutes.
  2. Don't unlock destructive or expensive features (like a heavy generation request that costs you money) during the provisional window — gate those on confirmed server-side entitlement.
  3. Always re-check entitlement on app foreground and after the provisional TTL.
  4. On confirmation failure, don't yank access mid-session — log it and reconcile on next launch with a clear message.

This is the kind of logic RevenueCat's CustomerInfo listener makes easier, but you still have to design the UX around it.

Restore purchases: the feature App Review will reject you for

Apple's guideline 3.1.1 requires a Restore Purchases button on any screen that gates content behind a previous purchase. We've seen rejections in 2025 and 2026 for:

  • Restore button hidden behind a settings menu when the paywall itself blocks access.
  • Restore that only works for the currently signed-in account, with no clear messaging that the user needs to sign in first.
  • "Restore" that silently does nothing when there's nothing to restore, instead of showing confirmation.

Make it loud, make it obvious, and make it show a result either way:

async function handleRestore() {
  try {
    const info = await Purchases.restorePurchases();
    const active = info.entitlements.active['pro'];
    if (active) {
      showToast('Your subscription is active.');
    } else {
      showToast('No purchases found for this account.');
    }
  } catch (e) {
    showToast('Could not restore. Check your connection.');
  }
}

Edge cases that will eat your weekend

A non-exhaustive list of things that have, specifically, eaten our weekends:

  • Family Sharing on iOS: a purchase made by one family member entitles others, but appAccountToken lets you tie purchases to your user ID — use it. Without it, you can't distinguish the purchaser from a sharer.
  • Grace periods and billing retry: a subscription in inBillingRetry should usually keep access for a configured window. Don't lock users out the instant a renewal fails.
  • Upgrades, downgrades, and crossgrades on Android, which use proration modes that affect when the new entitlement starts. Play Billing 7 requires you to pick a ReplacementMode explicitly.
  • Promo offers and win-back offers: both stores now support targeted offers for lapsed subscribers. Worth the integration if subscriptions are >30% of revenue.
  • Sandbox accounts on iOS auto-renew at accelerated rates (a monthly sub renews every 5 minutes, up to 6 times). Plan QA cycles around that.
  • OTA updates and IAP: never push an OTA update that changes product IDs or paywall pricing without a binary update. App Review wants to see the prices they approved.

Where we'd start

If you're greenfield and shipping subscriptions, start with RevenueCat on a development build, get one product live in sandbox end-to-end with a real backend webhook listener, then build the paywall. The paywall is the part everyone wants to design first and it's the part that changes most after launch.

If you're already live with a homegrown setup that's mostly working, don't rip it out. Add a server-side reconciliation job that compares your entitlement table against Apple's and Google's authoritative state weekly, and fix the deltas. That single job has surfaced more revenue leaks for us than any SDK migration.

And whatever stack you pick: log every transaction ID, every webhook, and every entitlement change with a correlation ID. The day a user emails you saying they paid twice, you want one query, not a forensic exercise. If you want a hand designing that pipeline, our mobile development team has done it more times than we'd like to admit.

#React Native#Expo#Monetization#IAP#Subscriptions

Want a team like ours?

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

Start a project