Recipe · checked November 7, 2026

Phone app with in-app subscriptions

An iOS and Android app with a paid subscription, built once in React Native with Expo. The stores' own in-app purchase handles payment (Apple requires it), RevenueCat wraps both stores, and Supabase is the backend: database, sign-in and storage behind row-level security.

Tool bills at 1,000 usersAbout $19/month
Setup steps10
Tools in the stack7

Fits when

  • The product is an app installed from the App Store and Google Play.
  • Users pay you for digital features or content: a subscription, a premium tier, or one-time unlocks.
  • You know JavaScript or React and want one codebase for both platforms.

Not for

  • Apps where users pay each other for real-world services (rides, walks, cleaning): those are paid outside in-app purchase. Use the marketplace recipe.
  • Games with heavy 3D graphics: use a game engine instead of React Native.
  • Apps that must work fully offline with large local data: plan local storage and sync before picking a hosted backend.

The stack

CategoryPickWhyPick something else when
Mobile buildsExpo (EAS)React Native with cloud builds for both stores, store submission, and over-the-air updates for JavaScript fixes. No Mac needed to build for iOS.
  • Xcode and Android Studio: Your team already writes Swift and Kotlin, or the app leans on platform features React Native doesn't wrap.
DatabaseSupabasePostgres with an API the app can call directly, protected by row-level security tied to the signed-in user, plus file storage and edge functions in the same project.
  • Firebase: You want an offline-first document database with Google's mobile SDKs.
AuthSupabase AuthSame project and client library as the database, so row-level security can use the signed-in user's id directly. Supports Sign in with Apple, which Apple requires if you offer Google sign-in.
  • Clerk: You want prebuilt sign-in screens from its Expo SDK and don't mind connecting a second vendor to Supabase.
PaymentsRevenueCatOne SDK and one dashboard over Apple's and Google's in-app purchase, with entitlements, receipt checks, paywalls and webhooks to your backend. Free until $2,500 in monthly tracked revenue.
  • Stripe: Only for purchases on your website, for example a US storefront link-out (App Review Guideline 3.1.1(a)). Never as checkout inside the app.
EmailoptionalResendSign-in codes and receipts-style notices from your own domain once Supabase's built-in sender is too limited for production.
  • Postmark: Transactional deliverability and long retention matter most.
MonitoringSentryCrash and error reports from both platforms with readable stack traces, including errors from over-the-air updates.
  • Better Stack: You also want uptime checks on your backend functions.
AnalyticsoptionalPostHogA React Native SDK for product analytics and funnels, so you can see where people drop off before the paywall.—

About $19/month at 1,000 users in tool bills: Expo (EAS) $19; $0 for Supabase, Supabase Auth, RevenueCat, Sentry. Usage profile: 1,000 monthly active users with light use: about 100,000 requests, 3,000 emails, 200,000 analytics events and 5 GB of stored files a month, and a database under 1 GB. AI apps add 50 questions per user a month at about 4,000 input and 500 output tokens each (200M input and 25M output tokens). Internal tools are priced for 10 staff instead.

Catches that change the plan

Setup steps

  1. Open the store accounts and create the app

    Store accounts take days to approve, so start them first. Then create one Expo project that builds for both platforms.

    • Enroll in the Apple Developer Program ($99 a year) and open a Google Play Console account ($25 once). Organizations need a D-U-N-S number for Apple; individuals don't.
    • If you qualify (under $1M in App Store proceeds last calendar year, or new to the store), apply for Apple's App Store Small Business Program for the 15% rate before your first sale.
    • Pick one bundle identifier (for example com.yourco.habits) and use it on both platforms.
    • Create the app with Expo and link it to EAS. You need a development build, not Expo Go, because in-app purchases use native code.
    • Commit an app.config.ts that reads environment variables, and keep secrets out of the app: anything shipped in the app binary is public.
    npx create-expo-app@latest habit-app
    cd habit-app && npm i -g eas-cli && eas login && eas init
    eas build:configure
    npx expo install expo-dev-client
    eas build --profile development --platform all

    Done when: A development build installs on a real iPhone and Android phone and loads your code from the dev server.

    Docs: docs.expo.dev, revenuecat.com, developer.apple.com, developer.apple.com

  2. Set up the backend with row-level security

    The app talks to Supabase directly with the public anon key, so every table must have row-level security on, with policies keyed to the signed-in user.

    • Create a Supabase project. Keep the free plan while building; it pauses after a week with no activity, so move to Pro ($25/month) before launch if traffic might go quiet.
    • Create a profiles table keyed by auth.users id, your app tables, and an entitlements table that only the server writes.
    • Turn on row-level security on every table. Users may read and write only their own rows; entitlements are read-only to users.
    • Store the Supabase URL and anon key in the app config. Never put the service role key in the app.
    • Write migrations with the Supabase CLI and apply them from CI, not from the dashboard.
    npm i -g supabase
    supabase init
    supabase migration new init
    npx expo install @supabase/supabase-js @react-native-async-storage/async-storage
    supabase/migrations/0001_init.sql
    create table profiles (
      id uuid primary key references auth.users(id) on delete cascade,
      display_name text,
      created_at timestamptz not null default now()
    );
    
    create table habits (
      id uuid primary key default gen_random_uuid(),
      user_id uuid not null references auth.users(id) on delete cascade,
      name text not null,
      created_at timestamptz not null default now()
    );
    create index on habits (user_id);
    
    -- Written only by the RevenueCat webhook (service role). The app reads it.
    create table entitlements (
      user_id uuid primary key references auth.users(id) on delete cascade,
      entitlement text not null,          -- e.g. 'pro'
      active boolean not null,
      expires_at timestamptz,
      store text,                         -- 'app_store' | 'play_store' | 'stripe'
      updated_at timestamptz not null default now()
    );
    
    create table revenuecat_events (       -- webhook idempotency
      id text primary key,
      received_at timestamptz not null default now()
    );
    
    alter table profiles enable row level security;
    alter table habits enable row level security;
    alter table entitlements enable row level security;
    alter table revenuecat_events enable row level security; -- no policies: server only
    
    create policy "own profile" on profiles for all using (auth.uid() = id) with check (auth.uid() = id);
    create policy "own habits" on habits for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
    create policy "read own entitlement" on entitlements for select using (auth.uid() = user_id);
    

    Done when: Signed in as user A, a query for user B's habits returns nothing, and an insert into entitlements from the app is refused.

    Docs: supabase.com, supabase.com

  3. Add sign-in, Sign in with Apple and account deletion

    Both stores require in-app account deletion when the app lets people create accounts, and Apple requires an equivalent private login option if you offer Google or another social login.

    • Turn on email sign-in with one-time codes, and Sign in with Apple. Add Google sign-in only alongside Apple (App Review Guideline 4.8).
    • Use the native Apple sign-in sheet on iOS and pass its identity token to Supabase.
    • Persist the session with AsyncStorage (or SecureStore for the refresh token) so users stay signed in.
    • Let people use the free part of the app before an account if you can; Apple reviewers reject forced sign-up for features that don't need an account.
    • Build Delete account in Settings: an edge function, called with the user's token, deletes their rows and the auth user with the service role key. Also publish a web page that explains how to request deletion, for Google Play's form.
    • Deleting the account doesn't cancel a store subscription. Tell the user to cancel in their App Store or Google Play settings, and link there.
    npx expo install expo-apple-authentication expo-secure-store
    supabase/functions/delete-account/index.ts
    import { createClient } from "npm:@supabase/supabase-js@2";
    
    Deno.serve(async (req) => {
      const jwt = req.headers.get("Authorization")?.replace("Bearer ", "") ?? "";
      const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
      const { data, error } = await admin.auth.getUser(jwt);
      if (error || !data.user) return new Response("unauthorized", { status: 401 });
      // Rows that reference auth.users with on delete cascade go with the user.
      const { error: delErr } = await admin.auth.admin.deleteUser(data.user.id);
      if (delErr) return new Response("failed", { status: 500 });
      return new Response("deleted");
    });
    

    Done when: A test account can sign in with Apple, delete itself from Settings, and can no longer sign in; its rows are gone.

    Docs: supabase.com, developer.apple.com, support.google.com

  4. Create the subscription in both stores and in RevenueCat

    Apple and Google each hold the store products. RevenueCat maps them to one entitlement (for example pro) that your code checks.

    • In App Store Connect, create a subscription group and an auto-renewable subscription (for example pro_monthly at $7.99). Subscription periods must be at least seven days (Guideline 3.1.2).
    • In Google Play Console, create a subscription with the same product id and a base plan at the same price.
    • Have the Account Holder accept the Paid Apps Agreement in App Store Connect, and add tax and banking details; the agreement must be active before in-app purchases work, even in sandbox testing.
    • In RevenueCat, add both apps, connect the App Store Connect API key and the Google Play service account, create an entitlement pro, attach both products, and put them in an offering.
    • Install the RevenueCat SDK and configure it after sign-in with the Supabase user id as the app user id, so purchases follow the account across devices.
    npx expo install react-native-purchases react-native-purchases-ui
    src/purchases.ts
    import { Platform } from "react-native";
    import Purchases from "react-native-purchases";
    
    // Public SDK keys from the RevenueCat dashboard (safe to ship in the app).
    const KEYS = { ios: process.env.EXPO_PUBLIC_RC_IOS_KEY!, android: process.env.EXPO_PUBLIC_RC_ANDROID_KEY! };
    
    export function configurePurchases(userId: string) {
      Purchases.configure({ apiKey: Platform.OS === "ios" ? KEYS.ios : KEYS.android, appUserID: userId });
    }
    
    export async function isPro(): Promise<boolean> {
      const info = await Purchases.getCustomerInfo();
      return info.entitlements.active["pro"] !== undefined;
    }
    
    export async function restore(): Promise<boolean> {
      const info = await Purchases.restorePurchases();
      return info.entitlements.active["pro"] !== undefined;
    }
    

    Done when: In a development build, the offering loads with the store's localized price on both platforms.

    Docs: revenuecat.com, developer.apple.com, developer.apple.com

  5. Build a paywall the reviewers will pass

    Most subscription rejections are about the paywall's wording, not the code.

    • Say clearly what the subscriber gets, the price and the billing period before they tap subscribe (Guideline 3.1.2).
    • Show the store's localized price string from the offering; never hard-code $7.99.
    • Include a Restore Purchases button and links to your terms and privacy policy on the paywall.
    • If you offer a free trial, say when it converts and what it will cost.
    • Use RevenueCat's paywall component or your own screen; either way, unlock features only from the entitlement, never from a local flag.

    Done when: A tester who reads only the paywall can say what they'd pay, how often, and how to restore a purchase.

    Docs: developer.apple.com

  6. Mirror entitlements to the backend with a webhook

    The app checks RevenueCat for the paywall, but anything the server does for paying users (sync, AI calls, exports) must check the entitlement your backend stored from the webhook.

    • Add a RevenueCat webhook pointing at a Supabase edge function, with a long random Authorization header value.
    • In the function, reject requests without that header, skip event ids already stored, and upsert the user's entitlement.
    • Handle at least INITIAL_PURCHASE, RENEWAL, CANCELLATION (still active until expiry), EXPIRATION, BILLING_ISSUE and PRODUCT_CHANGE.
    • On any event, you can re-fetch the subscriber from RevenueCat's API instead of trusting the payload's order.
    • Gate server features on entitlements.active and expires_at.
    supabase/functions/revenuecat-webhook/index.ts
    import { createClient } from "npm:@supabase/supabase-js@2";
    
    const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
    
    Deno.serve(async (req) => {
      if (req.headers.get("Authorization") !== `Bearer ${Deno.env.get("RC_WEBHOOK_SECRET")}`) {
        return new Response("unauthorized", { status: 401 });
      }
      const { event } = await req.json();
      const { error: dup } = await admin.from("revenuecat_events").insert({ id: event.id });
      if (dup) return new Response("ok"); // already processed
    
      const active = !["EXPIRATION"].includes(event.type);
      await admin.from("entitlements").upsert({
        user_id: event.app_user_id, // the Supabase user id you passed to Purchases.configure
        entitlement: "pro",
        active,
        expires_at: event.expiration_at_ms ? new Date(event.expiration_at_ms).toISOString() : null,
        store: String(event.store ?? "").toLowerCase(),
        updated_at: new Date().toISOString(),
      });
      return new Response("ok");
    });
    

    Done when: A sandbox purchase sets the user's entitlement to active within a minute, and its expiry clears it.

    Docs: revenuecat.com

  7. Test purchases end to end

    Both stores have test accounts that renew subscriptions quickly, so you can see a whole lifecycle in an hour.

    • Create Sandbox Apple Accounts in App Store Connect and license testers in Play Console.
    • Test buy, renew, cancel, expire, restore on a second device, and a failed payment (billing issue).
    • Test signing in on a new phone: the subscription must follow the account through restore.
    • Upload a build to TestFlight and Play internal testing and buy there too; that's closest to what reviewers see.
    eas build --profile preview --platform all

    Done when: Every lifecycle event shows up in RevenueCat and in your entitlements table, on both platforms.

    Docs: developer.apple.com, developer.android.com

  8. Add crash reporting and analytics

    Know about crashes before reviews mention them, and see where people drop off before the paywall.

    • Install Sentry with its wizard and upload source maps from EAS builds and updates.
    • Install PostHog and track a short funnel: opened, signed up, saw paywall, subscribed.
    • Identify users by the Supabase user id only; don't send emails or names to analytics.
    • List every SDK that collects data in the App Store privacy details and Google Play's Data safety form.
    npx @sentry/wizard@latest -i reactNative
    npx expo install posthog-react-native

    Done when: A deliberate test crash appears in Sentry with a readable stack trace, and the paywall funnel shows in PostHog.

    Docs: docs.sentry.io, posthog.com

  9. Submit to both stores

    Build production binaries in the cloud and submit them from the command line.

    • Build production binaries for both platforms and submit them with EAS Submit.
    • Give App Review a demo account and a note on where the subscription is and how to test it.
    • Fill in App Store privacy details, Google Play's Data safety form, content rating and the account deletion web link.
    • Personal Google Play accounts created after November 13, 2023 must run a closed test with at least 12 testers opted in for 14 days in a row before applying for production access, so start it early.
    • Ship JavaScript-only fixes with EAS Update; anything that changes native code or what the app does needs a new store build and review.
    eas build --profile production --platform all
    eas submit --platform ios
    eas submit --platform android

    Done when: Both apps are approved, and a real purchase in production unlocks pro and appears in RevenueCat.

    Docs: docs.expo.dev, docs.expo.dev, support.google.com

  10. Optional: sell on the web too

    Web purchases avoid store fees but come with rules. Do this after the in-app subscription works.

    • On the US storefront, apps may include a button or link to buy on the web (Guideline 3.1.1(a)); elsewhere, don't point users from the app to outside purchase.
    • Anything sold on the web that unlocks the app must also be available as an in-app purchase (Guideline 3.1.3(b)).
    • Sell with Stripe Checkout on your site, grant the same pro entitlement from the Stripe webhook, and keep one entitlements table as the source of truth.
    • On Android, external links and alternative billing need enrollment in Google's programs for eligible regions; Google Play billing stays the default.

    Done when: A web purchase unlocks pro in the app on next launch, and the in-app purchase still works.

    Docs: developer.apple.com, support.google.com

Best practices

  • Make the store purchase the source of truth through RevenueCat, and mirror it to your backend from webhooks.
  • Use the backend user id as RevenueCat's app user id so a subscription follows the account, not the phone.
  • Turn on row-level security on every table before the first build ships; the anon key in the app is public.
  • Keep the free part of the app usable without an account when you can; ask for sign-in when it adds value.
  • Show localized prices from the store and never hard-code amounts.
  • Plan for store review time in every release; use over-the-air updates only for JavaScript fixes.

Pitfalls

  • Adding Stripe checkout inside the iOS app for a digital subscription: Apple rejects it (Guideline 3.1.1).
  • Testing purchases in Expo Go, where native purchase code doesn't run; use a development build.
  • Offering Google sign-in on iOS without Sign in with Apple or another equivalent private option (Guideline 4.8).
  • Shipping without in-app account deletion; both stores require it when the app creates accounts.
  • Unlocking features from a value saved on the phone; reinstalling or tampering bypasses it.
  • Forgetting the Paid Apps Agreement in App Store Connect, so products never load in sandbox testing.
  • Shipping the Supabase service role key inside the app.

Costs and obligations

These point to obligations to check; they aren't legal or tax advice.