All articles
Mobile DevelopmentMay 12, 2026 6 min read

Surviving Apple's In-App Purchase Review with Expo and RevenueCat

A field guide to shipping subscriptions in an Expo app without getting bounced by App Review. Restore flows, paywall copy, sandbox testing, and the rejection patterns we keep seeing in 2026.

Surviving Apple's In-App Purchase Review with Expo and RevenueCat

Apple does not reject your app because your subscription code is broken. It rejects it because your Restore button is one tap too deep, your paywall doesn't say "auto-renewable", or your sandbox tester saw a blank screen for 800ms. We've shipped enough Expo apps with paid tiers to know the difference between a code problem and a review problem — and the review problem is almost always what burns the week.

This is the playbook we hand to teams who are about to wire up subscriptions in an Expo + React Native app using RevenueCat. It assumes you're on a recent Expo SDK with EAS Build, and that you've already decided you don't want to hand-roll StoreKit 2 bridges.

Why RevenueCat, and why not roll your own

Apple's StoreKit 2 is genuinely good now. If you were building a pure Swift app, you could skip a paywall SDK and live happily on Product.products(for:) and Transaction.updates. In React Native, the calculus is different. You need a bridge, you need receipt validation on a server you trust, you need entitlement state synced across iOS and Android, and you need it to survive Expo SDK upgrades.

The two realistic options in 2026:

  • react-native-iap (community, lower-level, you own the server validation)
  • RevenueCat's react-native-purchases (managed receipt validation, entitlement API, dashboards)

For most product teams shipping a subscription, RevenueCat wins on time-to-paid-user. You're not paying them to make IAP work — you're paying them so you don't have to write the receipt-validation cron job that wakes you up at 3am when Apple rotates a certificate.

What you still own

RevenueCat does not get you through App Review. It gets you through the engineering of IAP. The review surface — paywall copy, restore flow, account deletion, the price displayed before the system sheet — is still entirely on you. We've seen teams assume the SDK handles compliance. It does not.

Wiring it up in Expo without ejecting

react-native-purchases needs native code, which used to mean prebuild gymnastics. With the config plugin it's now boring:

npx expo install react-native-purchases

Then in app.config.ts:

export default {
  expo: {
    plugins: [
      'react-native-purchases'
    ],
    ios: {
      bundleIdentifier: 'com.yourco.app',
      usesAppleSignIn: true
    }
  }
};

You'll need an EAS Build — this won't run in Expo Go, and it never will. Plan your dev loop around development builds early; teams that try to keep Expo Go alive through IAP integration always lose a day to it.

Initialization happens once, as early as you can:

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

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

  await Purchases.configure({
    apiKey: Platform.select({
      ios: process.env.EXPO_PUBLIC_RC_IOS_KEY!,
      android: process.env.EXPO_PUBLIC_RC_ANDROID_KEY!
    })!,
    appUserID: appUserId ?? null
  });
}

Pass an appUserID only if you have a stable, non-PII identifier (your own user ID from your auth system). Don't pass email. Don't pass the Apple IDFV. RevenueCat will generate an anonymous ID otherwise, and you can logIn() later when the user signs up.

The paywall: where reviews actually fail

Here is the bit we get hired to fix after a rejection. The code is fine. The screen is not.

Apple's guideline 3.1.2 has been stable for years, but enforcement has tightened. In 2026, on an auto-renewing subscription paywall, you need every one of these visible before the user taps Subscribe — not behind a tooltip, not in the EULA, on the paywall itself:

  • Title of the subscription (e.g. "Pro Monthly")
  • Length of the subscription period
  • Price per period, in the user's currency
  • The phrase "auto-renewable" or equivalent
  • Functional links to your Terms of Use (EULA) and Privacy Policy
  • A visible Restore Purchases control

The Restore button is the single most common rejection trigger we see. Reviewers tap it. If it's hidden in a settings screen three taps deep, or if it silently does nothing for a tester with no prior purchase, you get rejected. Put it on the paywall.

A paywall structure that passes

<View>
  <Text>Pro Monthly</Text>
  <Text>{offering.monthly?.product.priceString} / month</Text>
  <Text>Auto-renewable. Cancel anytime in App Store settings.</Text>

  <Button title="Subscribe" onPress={handlePurchase} />

  <Pressable onPress={handleRestore}>
    <Text>Restore Purchases</Text>
  </Pressable>

  <Pressable onPress={() => Linking.openURL(TERMS_URL)}>
    <Text>Terms of Use</Text>
  </Pressable>
  <Pressable onPress={() => Linking.openURL(PRIVACY_URL)}>
    <Text>Privacy Policy</Text>
  </Pressable>
</View>

Use offering.monthly?.product.priceString — never hardcode prices. Apple wants the localized, currency-correct price from StoreKit itself, and hardcoded strings drift the moment you launch in a new region.

The restore flow that actually works

The rejection pattern: reviewer taps Restore on a fresh sandbox account, your handler returns no entitlement, and you show nothing. Or worse, you show an error. Reviewer marks it as broken.

A restore that passes review needs three states:

async function handleRestore() {
  try {
    const customerInfo = await Purchases.restorePurchases();
    const isPro = typeof customerInfo.entitlements.active['pro'] !== 'undefined';

    if (isPro) {
      Alert.alert('Restored', 'Your Pro subscription is active.');
    } else {
      Alert.alert('Nothing to restore', 'No prior purchases were found on this Apple ID.');
    }
  } catch (e: any) {
    Alert.alert('Restore failed', e?.message ?? 'Please try again.');
  }
}

The "nothing to restore" branch is the one teams skip. It is also the one reviewers hit. Add it.

Sandbox testing without losing your mind

Sandbox in 2026 is better than it was, but still has edges. Things we do on every project:

  • Create dedicated Sandbox Apple IDs per developer in App Store Connect. Never use your real Apple ID.
  • On the device, sign out of the production App Store account in Settings, but do not sign into the sandbox account there. You sign into sandbox only when the purchase sheet prompts you. Signing into sandbox at the OS level breaks things in ways that take an afternoon to debug.
  • Sandbox renewals are accelerated: a 1-month subscription renews every 5 minutes, up to 6 times, then stops. Plan test scenarios around that, not real time.
  • Use RevenueCat's sandbox dashboard to confirm transactions land. If they don't, the problem is almost always your StoreKit configuration in App Store Connect (agreements not signed, tax forms incomplete, product not in "Ready to Submit" state).

OTA updates and IAP: do not push paywall code over the air

Expo's expo-updates is one of the best reasons to use the platform. You can ship a bugfix in an hour instead of a week. But there's a line we don't cross: we do not push paywall logic, pricing display, or entitlement gating changes over the air without a corresponding store submission.

Apple's guideline 3.3.1 on executable code is the one to read carefully. Cosmetic and bugfix updates via Expo Updates are fine and explicitly supported. Changing what the user sees on a regulated screen like a paywall, or changing what content unlocks for which entitlement, is the kind of thing that turns a routine OTA into a developer-account problem if it ever gets flagged.

Our rule: paywall changes ship in a versioned binary. Everything else can OTA.

Account deletion and the receipt that won't die

Since guideline 5.1.1(v) came into force, any app with account creation must offer in-app account deletion. If you sell subscriptions, the deletion flow needs to tell the user, clearly, that deleting their account does not cancel their subscription — Apple manages that, and they need to cancel it in their App Store settings.

This copy alone has saved us two rejections:

Deleting your account does not cancel your subscription. To cancel, go to Settings → Apple ID → Subscriptions on your device.

Link directly with Linking.openURL('https://apps.apple.com/account/subscriptions') for bonus reviewer-happiness points.

Where we'd start

If you're wiring this up next week: build the paywall screen first, with hardcoded mock data, and submit a TestFlight build for internal review before you've written a single line of purchase logic. Get the layout, copy, and restore button approved as a UI in your own head against the 3.1.2 checklist. Then plug in RevenueCat, then plug in real products.

Most IAP rejections we've debugged in the last two years were not engineering failures. They were product-copy and flow failures shipped by engineers who treated the paywall as a normal screen. It isn't. It's the most regulated 400 pixels of your app — design it that way, and the SDK work becomes the easy part.

If you'd like a second pair of eyes before submission, our team at 72Technologies reviews release candidates for exactly this kind of thing.

#React Native#Expo#In-App Purchase#iOS#App Store Review

Want a team like ours?

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

Start a project