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.
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-recipe | What it does |
|---|---|
examples/snap-recipe/src/lib/store-purchases.ts | The RevenueCat rail: lazy configure, logIn as the signed-in user, native paywall, storefront |
src/lib/payments.ts | The Stripe rail + the payments contract: entitlements are the union of both rails |
src/screens/paywall/paywall-screen.tsx | App Store plans first, "Or pay by card through Stripe" below - only where allowed |
src/app/api/stripe/*, src/server/stripe.ts | Checkout 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 supabaseThen 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.idon the subscription and the entitlements route searches by it. - RevenueCat: call
Purchases.logIn(user.id)when the session user changes andPurchases.logOut()on sign-out (syncStoreUser()in the example). A purchase made while signed out moves to the account on the nextlogIn.
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; theappl_…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
prois 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.