ReadyNative
PaymentsPro
BackendPro

Add web checkout next to the App Store

Run RevenueCat and Stripe Checkout side by side - one entitlement, two ways to pay, App Review-safe on iOS.

Pro

Setup picks one payments option. This page is for when you want two: in-app purchase through RevenueCat (StoreKit / Play Billing, Apple's sheet, Face ID) and Stripe Checkout on the web (cards, Apple Pay and Google Pay on Stripe's page) - both unlocking the same pro.

The finished version lives in examples/snap-recipe. Copy from there; this page explains the pieces and the rules. It assumes a tree set up with payments=revenuecat, backend=api-routes and an auth option, with RevenueCat working as in Add a paywall.

The example was set up the other way round - payments=stripe, with RevenueCat added by hand as examples/snap-recipe/src/lib/store-purchases.ts - so its files split what your tree has in one place. In a RevenueCat tree, your src/lib/payments.ts is the RevenueCat rail: move it aside and take the example's payments.ts and store-purchases.ts together. Picked payments=stripe? You already have the Stripe rail; add store-purchases.ts, react-native-purchases and react-native-purchases-ui, and the example's paywall. Picked payments=adapty? The example has no Adapty rail: keep the Stripe files from the table, and write your own store-purchases.ts with the same exports on top of the Adapty calls in your current src/lib/payments.ts (Adapty's access levels play the part of RevenueCat's entitlements).

File in examples/snap-recipeWhat it does
examples/snap-recipe/src/lib/store-purchases.tsThe RevenueCat rail: lazy configure, logIn as the signed-in user, native paywall, storefront
src/lib/payments.tsThe Stripe rail + the payments contract: entitlements are the union of both rails
src/screens/paywall/paywall-screen.tsxApp Store plans first, "Or pay by card through Stripe" below - only where allowed
src/app/api/stripe/*, src/server/stripe.tsCheckout session, entitlements lookup, webhook

1. Start from RevenueCat, add the Stripe server

Pick RevenueCat, API routes and an auth option when you run setup - Stripe needs a signed-in user:

bun run setup --payments revenuecat --backend api-routes --auth supabase

Then bring the server half of Stripe over by hand. modules/ is gone after setup, but examples/snap-recipe carries the same files: copy src/server/stripe.ts and src/app/api/stripe/ into your tree (they read the signed-in user through the src/server/session.ts your auth option wrote), add the package with bunx expo install stripe, and set STRIPE_SECRET_KEY, STRIPE_PRICE_ID and STRIPE_WEBHOOK_SECRET as on the Stripe page.

2. One user id on both rails

Both dashboards must know the buyer by the same id, or a Stripe subscription and an App Store subscription belong to two strangers:

  • Stripe: the checkout route stores metadata.app_user_id = user.id on the subscription and the entitlements route searches by it.
  • RevenueCat: call Purchases.logIn(user.id) when the session user changes and Purchases.logOut() on sign-out (syncStoreUser() in the example). A purchase made while signed out moves to the account on the next logIn.

3. Merge the entitlements

payments.useEntitlements() returns the union, so every existing active.includes("pro") check keeps working:

const store = useStoreEntitlements();
const active = useMemo(
  () => [...new Set([...stripeActive, ...store.active])].sort(),
  [stripeActive, store.active]
);

If your RevenueCat entitlement isn't literally pro, map it once (REVENUECAT_PRO_ENTITLEMENT in the example) instead of teaching every screen a second name. Make restorePurchases() do both: Purchases.restorePurchases() and a fresh Stripe lookup.

4. Only show Stripe where App Review allows it

Under App Review guideline 3.1.1, digital subscriptions sold inside an iOS app go through in-app purchase. A link-out to web checkout is allowed on the US storefront; elsewhere it can get the build rejected. Ask StoreKit which storefront the user is on:

export function canOfferExternalPayments(): Promise<boolean> {
  if (Platform.OS !== "ios" || !configureStore()) return Promise.resolve(true);
  return Purchases.getStorefront()
    .then((s) => ["US", "USA"].includes(s?.countryCode.toUpperCase() ?? ""))
    .catch(() => false);
}

The paywall hides the Stripe card when this is false. Android and web always show it (check Google Play's payments policy for your markets).

5. Test both

  • Store rail: a RevenueCat Test Store key (test_…) shows RevenueCat's simulated purchase dialog; the appl_… key shows Apple's sheet for a sandbox Apple ID on a device.
  • Stripe rail: in test mode, the hosted page shows Apple Pay once you enable it under Stripe → Settings → Payment methods. The Apple Pay sheet itself needs a Wallet card, so use a real iPhone.

Things to know

  • Two subscriptions for one person. Nothing stops someone who pays on the web from also buying in the store on another device. The paywall hides both once pro is active on the current one; if that isn't enough, check server-side before creating a checkout session.
  • Refunds and cancellations arrive through RevenueCat's customer-info listener and the Stripe webhook - both rails drop the entitlement on their own.
  • Fees differ. Apple and Google keep 15-30% of store sales; Stripe takes its card fee. Price the store product accordingly.
  • The store sheet is not Apple Pay. It charges the Apple ID's payment method (which may be an Apple Pay card). A native Apple Pay button is allowed only for physical goods and services, never for unlocking app features.

On this page

Get ReadyNative