# How to Add In-App Purchases to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

In-app purchases need five components: a paywall screen, a feature gate that checks entitlements, a purchase handler (Stripe on web, RevenueCat + StoreKit/Google Play on mobile), an entitlements table in Supabase, and an idempotent webhook listener. With Lovable or V0 you ship a working web purchase flow in 4–8 hours. On mobile, RevenueCat handles Apple and Google receipt validation. Store commissions (15–30%) are the dominant cost, not ops — which run $0–80/mo depending on scale.

## What In-App Purchases Actually Are

In-app purchases (IAP) are the mechanism that converts free users into paying customers by gating premium features, content, or consumables behind a payment. On web, IAP is a Stripe Payment Intent or Checkout session — simple and fully under your control. On mobile (iOS and Android), it is a platform-controlled process: Apple and Google collect the payment, validate the receipt, and take a 15–30% commission before passing you the result. RevenueCat sits between your app and both stores, handling receipt validation, cross-platform entitlement sync, and webhook delivery so you do not have to implement Apple's and Google's separate APIs from scratch. The real product decisions are whether you need one-time purchases or subscriptions, how to gate features server-side rather than client-side, and how to handle the edge cases — restore purchases, expired subscriptions, and duplicate webhook delivery — before they become support tickets.

## Anatomy of the Feature

Six components. The entitlements table and webhook handler are where most AI-generated IAP builds fail under real usage — read them carefully before prompting.

- **Paywall and upsell screen** (ui): A modal (shadcn/ui Dialog on web) or dedicated page that shows a preview of the premium feature, pricing tiers with their feature lists, and a primary CTA. Triggered by a feature gate check whenever the user attempts to access a locked feature. Shows the user's current plan for returning visitors.
- **Feature gate middleware** (ui): Client-side check against the user's entitlements — either loaded from the Supabase user_entitlements table on app init or from the RevenueCat SDK's CustomerInfo object. Blocks navigation to premium routes or renders a locked state over premium UI elements until purchase is confirmed.
- **Purchase handler** (backend): Web: a Supabase Edge Function or Next.js API route calls stripe.paymentIntents.create() or stripe.checkout.sessions.create() server-side with the product ID and user metadata. Mobile: RevenueCat SDK calls Purchases.purchasePackage() which wraps Apple StoreKit 2 and Google Play Billing Library 5 — receipt validation is automatic.
- **Entitlements table** (data): Supabase user_entitlements table: user_id, product_id, purchased_at, expires_at (null for lifetime purchases), platform (web/ios/android), receipt_id for deduplication, created_at. RLS restricts SELECT to the row owner. RevenueCat webhooks and Stripe webhooks both write to this table via an Edge Function.
- **RevenueCat webhook listener** (backend): A Supabase Edge Function receives INITIAL_PURCHASE, RENEWAL, EXPIRATION, CANCELLATION, and REFUND events from RevenueCat. Updates user_entitlements accordingly — inserting on purchase, setting expires_at on expiration, and soft-deleting on refund. For web Stripe purchases, the Stripe payment_intent.succeeded webhook does the same work.
- **Restore purchases flow** (ui): A 'Restore Purchases' button in account settings calls RevenueCat's Purchases.restorePurchases() on mobile. RevenueCat re-syncs the user's Apple or Google purchase history and updates entitlements. On web, restoration means re-fetching the Stripe payment history for the user's account. Shows a confirmation toast on success.

## Data model

Two tables cover the full IAP lifecycle. Run this in the Supabase SQL editor:

```sql
create table public.products (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  description text,
  price_cents integer not null check (price_cents >= 50),
  currency text not null default 'usd',
  store_product_id text not null,
  type text not null check (type in ('one_time', 'subscription')),
  created_at timestamptz not null default now()
);

create table public.user_entitlements (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  product_id uuid references public.products(id) not null,
  purchased_at timestamptz not null default now(),
  expires_at timestamptz,
  platform text not null check (platform in ('web', 'ios', 'android')),
  receipt_id text unique not null,
  created_at timestamptz not null default now()
);

alter table public.user_entitlements enable row level security;

create policy "Users can view own entitlements"
  on public.user_entitlements for select
  using (auth.uid() = user_id);

create index entitlements_user_active_idx
  on public.user_entitlements (user_id, expires_at)
  where expires_at is null or expires_at > now();
```

The partial index on user_id + expires_at makes the active entitlement check fast at any user scale. The UNIQUE constraint on receipt_id is the idempotency key — duplicate webhook delivery silently does nothing.

## Build paths

### Lovable — fit 4/10, 4–6 hours

Best AI-tool path for web IAP — Lovable's Stripe connector generates the paywall UI, Edge Function for Payment Intent, and entitlements update on webhook in one prompt.

1. Create a new Lovable project, connect Lovable Cloud (Supabase + auth provisioned automatically)
2. Go to Cloud tab → Connectors → Stripe and connect your Stripe account in test mode
3. Paste the prompt below in Agent Mode — it generates the paywall modal, purchase Edge Function, webhook handler, and entitlements table
4. Click Publish — Stripe payment flows and webhooks require the published URL, not the in-editor preview
5. In Stripe Dashboard, register your published URL as a webhook endpoint and add the signing secret to Lovable Cloud → Secrets as STRIPE_WEBHOOK_SECRET
6. Test with Stripe test card 4242 4242 4242 4242 and confirm the entitlement row appears in Supabase Table Editor before going live

Starter prompt:

```
Build an in-app purchase feature for a web app using Stripe. Create a products table with columns: id, name, description, price_cents, currency, store_product_id, type (one_time or subscription). Create a user_entitlements table with columns: id, user_id, product_id, purchased_at, expires_at (nullable for lifetime), platform ('web'), receipt_id (unique for idempotency), created_at. Enable RLS: users can only SELECT their own entitlements. Create a paywall modal component (shadcn/ui Dialog) that shows the premium feature preview, pricing, and a buy button. The modal should only render for users without an active entitlement — check entitlements on app load and cache in React context. Create a Supabase Edge Function that accepts POST with product_id and user_id, looks up the price_cents from the products table, creates a Stripe Payment Intent server-side (never trust client amount), and returns the clientSecret. Handle the payment with Stripe Elements embedded in the modal — show loading state during confirmation. Create a webhook Edge Function that handles payment_intent.succeeded: verify signature with constructEventAsync() using raw Uint8Array body, check if receipt_id already exists in user_entitlements before inserting (ON CONFLICT DO NOTHING), insert the entitlement row, and return 200. On payment_intent.payment_failed, show a toast with the specific decline reason and a retry button. Include a RestorePurchases component in account settings that re-fetches the user's entitlements. Include server-side entitlement check in any API route that returns premium content — never trust client-side gate alone. Test with Stripe card 4242 4242 4242 4242 for success and 4000000000000002 for decline.
```

Limitations:

- Mobile IAP via StoreKit or Google Play Billing is not supported — this path is web-only via Stripe
- RevenueCat integration for cross-platform entitlement sync requires manual Edge Function wiring beyond the default Stripe connector
- Stripe Checkout and webhook testing require the published Lovable URL — preview iframe blocks payment flows
- Subscription IAP (recurring billing) requires Stripe Billing in addition to Payment Intents — prompt separately for subscriptions

### V0 — fit 4/10, 4–7 hours

Excellent for web IAP within a Next.js project — V0 generates clean paywall components with shadcn/ui and Next.js API routes for Stripe Checkout or Payment Intent, with Server Actions for entitlement verification.

1. Prompt V0 with the spec below to generate the paywall component and Stripe API routes
2. In the Vercel dashboard, add STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, NEXT_PUBLIC_SUPABASE_URL, and NEXT_PUBLIC_SUPABASE_ANON_KEY as environment variables
3. Run the SQL schema from this page in the Supabase SQL editor
4. Deploy to Vercel — the Stripe webhook endpoint must be your Vercel production domain
5. Register the webhook in Stripe Dashboard and verify the signing secret matches your Vercel environment variable
6. Test the complete purchase flow with Stripe test cards before switching to live keys

Starter prompt:

```
Build an in-app purchase feature for a Next.js 14 App Router project using Stripe and Supabase. Component: Paywall modal (shadcn/ui Dialog) that shows the premium feature name, description, price, and a 'Unlock now' CTA. The modal wraps around any premium feature and renders a locked overlay for users without entitlements. It should NOT show to users who already have an active entitlement — load entitlements in a Server Component and pass as prop. API route app/api/purchase/route.ts: POST handler that validates product_id, looks up price_cents from Supabase products table, creates a Stripe Payment Intent server-side with amount, currency 'usd', and metadata (user_id, product_id, receipt_id using crypto.randomUUID()), returns {clientSecret}. Embed Stripe Elements in the paywall modal — show card field with Apple Pay and Google Pay via PaymentRequestButton; show loading spinner during payment confirmation; show success state with 'Feature unlocked!' message; show decline reason with retry button on failure. API route app/api/webhooks/stripe/route.ts: read raw body as Buffer for signature verification, call stripe.webhooks.constructEvent(), on payment_intent.succeeded check if receipt_id exists in user_entitlements, INSERT with ON CONFLICT DO NOTHING, return 200. Server Action checkEntitlement(productId): SELECT from user_entitlements WHERE user_id = auth.uid() AND product_id = $1 AND (expires_at IS NULL OR expires_at > now()) — call this in every Server Component that renders premium content. Include a purchase history page.
```

Limitations:

- No auto-provisioning — Stripe and Supabase credentials must be added manually to Vercel Environment Variables
- Mobile IAP (StoreKit/Google Play) is not in scope for V0 — web-only Stripe path
- SSR entitlement check requires careful handling to avoid hydration mismatch if subscription status is also read from a cookie
- V0 may generate paywall UI that stores entitlement state in localStorage — explicitly require server-side entitlement verification in your prompt

### Flutterflow — fit 3/10, 1–2 days

Native mobile IAP path — FlutterFlow can scaffold the paywall UI and App State for entitlements; RevenueCat SDK via custom code blocks handles StoreKit and Google Play receipt validation.

1. Design the paywall page in FlutterFlow: premium feature preview widget, pricing card with price and feature list, 'Unlock' button, and a 'Restore Purchases' text button
2. Add an App State variable currentEntitlements (list of strings) populated on app launch from RevenueCat's CustomerInfo object via a custom code block
3. Add a Custom Action PurchaseProduct that calls Purchases.purchasePackage() from the RevenueCat Flutter SDK with the product identifier matching your App Store and Play Store configuration
4. Add a Custom Action RestorePurchases that calls Purchases.restorePurchases() and refreshes the App State entitlements list
5. Wire a Supabase insert action on successful purchase to record the entitlement in user_entitlements for cross-platform consistency
6. In FlutterFlow Settings → Permissions, add no additional camera permissions — IAP uses StoreKit/Google Play directly

Limitations:

- RevenueCat Flutter SDK requires a custom code block — no visual RevenueCat widget exists in FlutterFlow
- FlutterFlow Pro or Teams plan required for custom code export; custom code does not run in the in-browser preview
- Apple App Review requires a tested restore-purchases flow and a valid sandbox test account — set up both before submission
- Sandbox testing on iOS requires a TestFlight build or Xcode device run — the FlutterFlow mobile preview app cannot test real IAP flows

### Custom — fit 5/10, 1–3 weeks

Full control over multi-platform IAP — web via Stripe, iOS via StoreKit 2, Android via Google Play Billing Library 5, unified via RevenueCat with custom receipt validation and analytics.

1. RevenueCat SDK integrated on all three platforms (web, iOS, Android) with a shared entitlement identifier so one purchase unlocks across platforms
2. Custom receipt validation without RevenueCat for compliance requirements that prohibit third-party processing of purchase receipts
3. Conversion funnel analytics: track paywall views, purchase initiations, completions, and abandonment rates in a custom analytics table
4. Family sharing and gifting IAP flows via StoreKit 2's Transaction.updates listener

Limitations:

- Apple and Google App Review adds 1–7 days to launch timeline; factor this into your schedule
- 2–3 weeks minimum for a production cross-platform IAP implementation including store configuration, sandbox testing, and webhook reliability testing

## Gotchas

- **Feature unlocked before webhook confirms payment — users who close tab mid-flow lose access** — AI tools commonly unlock the premium feature immediately when the browser returns from the Stripe Checkout success URL — reading a ?payment_intent_status=succeeded query parameter or checking localStorage. If the user closes the tab before the success URL loads, or if the redirect takes more than a few seconds, the entitlement is never written to the database. The user paid but has no access. Fix: Never grant entitlements from the client-side redirect URL or any client-readable parameter. Only update entitlements inside the payment_intent.succeeded webhook handler. On the success URL, show a 'Payment confirmed — unlocking your feature...' screen that polls a lightweight GET /api/entitlements endpoint (with 2-second intervals and a 30-second timeout) until the entitlement appears. If it does not appear within 30 seconds, show a 'Contact support' message with the payment intent ID.
- **Duplicate webhook events unlock entitlement twice — or crash the handler** — Stripe retries webhook delivery if your handler returns a 5xx status or times out. RevenueCat does the same. If your INSERT into user_entitlements does not handle the case where the receipt_id already exists, the second delivery either inserts a duplicate row (two entitlements for one purchase) or throws a unique constraint violation that crashes the handler and triggers infinite retries. Fix: Always use INSERT INTO user_entitlements (...) VALUES (...) ON CONFLICT (receipt_id) DO NOTHING. This is a single SQL statement — there is no race condition between the check and the insert. Return 200 from the webhook handler even when the conflict fires; returning 5xx on a duplicate causes Stripe to retry forever.
- **Apple sandbox receipt rejected in production — 21007 error** — Apple's receipt validation has two endpoints: the sandbox endpoint and the production endpoint. Receipts generated in the TestFlight or Xcode sandbox environment are only valid against the sandbox endpoint. AI tools that implement manual receipt validation without RevenueCat use only the production endpoint, causing all sandbox receipts to return a 21007 error and block your QA process entirely. Fix: Use RevenueCat — it handles this automatically by detecting the receipt environment. If you must implement manual validation, follow Apple's documented pattern: first POST to the production endpoint; if you receive a 21007 status, re-POST to the sandbox endpoint. Never hardcode either endpoint; let Apple's response tell you which environment the receipt belongs to.
- **Feature gate only in the UI — API routes still serve premium content** — AI tools typically add the feature gate in React — hiding a button or disabling a route in the client-side router. A user who discovers the API endpoint directly (or inspects network requests) can fetch premium content without a valid entitlement. This is not a theoretical attack; it is how determined users bypass paywalls. Fix: Add an entitlement check in every Server Action and API route that returns premium content. In Next.js: const entitlement = await supabase.from('user_entitlements').select().eq('user_id', session.user.id).eq('product_id', productId).single(); if (!entitlement.data) return new Response('Unauthorized', { status: 403 }). The UI gate is UX; the server check is security.
- **Paywall renders for already-subscribed users on page load** — AI tools often load the paywall component unconditionally and check entitlements asynchronously — resulting in a flash of the paywall UI before the check resolves for paying customers. This is jarring and erodes trust, especially for premium-tier users who expect seamless access. Fix: Load entitlements before rendering any gated UI: in Next.js, fetch entitlements in a Server Component and pass them as props; in FlutterFlow, populate App State on launch via a Custom Action that calls RevenueCat's Purchases.getCustomerInfo() before navigating to the main screen. Use a skeleton loader during the check; never render the paywall while the entitlement check is pending.

## Best practices

- Check entitlements server-side in every API route or Server Action that returns premium content — the client-side gate is UX, not access control
- Make your webhook handler idempotent using INSERT ... ON CONFLICT (receipt_id) DO NOTHING — this handles duplicate delivery without crashing or double-granting
- Never unlock features from a URL redirect parameter or client-side flag — always wait for the webhook to confirm payment before writing the entitlement
- Show the paywall only to users who genuinely lack entitlements — check on app launch and cache in context; showing a paywall to a paying customer destroys trust
- Test the restore-purchases flow before App Store submission — Apple rejects apps that lack a visible restore mechanism for non-consumable IAP
- Store the store_product_id in your products table and validate it server-side before creating a Payment Intent — never trust a price passed from the client
- Display a human-readable feature list on the paywall, not just a price — users who understand what they are buying convert at 2–3x the rate of price-only paywalls
- Implement graceful downgrade on subscription expiry: revoke access to premium features but preserve the user's data; never delete content when a subscription lapses

## Frequently asked questions

### Do I need to use Apple or Google payment for in-app purchases on mobile?

For digital content, features, and subscriptions sold within iOS and Android apps: yes, Apple and Google mandate their own payment systems. You cannot use Stripe directly for IAP in a mobile app without violating App Store and Play Store policies. The exception is physical goods and services consumed outside the app (like Uber rides or Amazon orders) where Apple and Google explicitly allow alternative payment methods.

### What's the difference between a consumable and non-consumable IAP?

A consumable IAP (like 100 coins or 5 extra lives) is used up and can be re-purchased. A non-consumable (like removing ads or unlocking a premium tier) is purchased once and kept forever — it must be restorable via the restore-purchases flow. Subscriptions are a third type: they recur until cancelled. Each type has different handling in StoreKit, Google Play Billing, and RevenueCat — specify which type you need in your prompt.

### How do I test purchases without real money?

On iOS: create a Sandbox Tester account in App Store Connect, sign out of your real Apple ID on the test device, sign in with the sandbox account, and make purchases — no real money moves. On Android: add test accounts in Google Play Console and your test device's email must match. Stripe test mode uses card 4242 4242 4242 4242 for web purchases. RevenueCat provides a sandbox environment that mirrors these behaviors.

### What percentage does Apple or Google take?

Apple takes 30% by default, reduced to 15% under the App Store Small Business Program for developers who earn under $1M per year in the previous calendar year. You must apply annually. Google Play takes 15% on your first $1M in revenue per year, and 30% above that threshold. Both percentages apply to the net transaction amount after taxes in some regions.

### How do I handle refunds for in-app purchases?

Apple processes refund requests directly through Apple Support — you have no control over the refund decision. When Apple issues a refund, RevenueCat receives a REFUND event and sends it to your webhook; update the entitlement expires_at to the current time to revoke access. Google Play allows you to issue refunds from the Play Console within 48 hours. For web Stripe purchases, you initiate refunds via stripe.refunds.create() — the user loses access when your webhook handles the charge.refunded event.

### Can I sell the same content on web and mobile with one purchase?

Only if you use RevenueCat to sync entitlements across platforms. When a user purchases on iOS, RevenueCat webhooks to your server and writes the entitlement. When they log in on web, your server reads the same entitlement. However, Apple and Google do not allow cross-platform purchases directly — a purchase made on web via Stripe cannot be recognized by Apple or Google as a completed IAP. You typically offer separate but linked purchases for each platform.

### What is RevenueCat and do I need it?

RevenueCat is a subscription and IAP management layer that wraps Apple StoreKit and Google Play Billing. It handles receipt validation (so you do not have to call Apple or Google's APIs directly), provides a unified CustomerInfo object with all entitlements, sends webhooks to your server on purchase events, and shows analytics dashboards. You do not strictly need it — you can call StoreKit and Google Play APIs directly — but doing so correctly takes 3–5 additional weeks of development. For most apps, RevenueCat's free tier covers early-stage volume and is well worth the integration.

### How do I prevent users from bypassing the paywall?

Add a server-side entitlement check in every API route or Server Action that returns premium content. The UI-only gate (hiding a button or disabling a route) is trivially bypassed by anyone who can read network requests. In Next.js, use a Server Action that queries Supabase for a valid entitlement row before returning premium data. In FlutterFlow, call your Supabase Edge Function from the API call action and check the response before rendering premium content.

---

Source: https://www.rapidevelopers.com/app-features/in-app-purchases
© RapidDev — https://www.rapidevelopers.com/app-features/in-app-purchases
