Lab 11.1 — Subscription Paywall with RevenueCat

Goal

Ship a production-ready 3-tier subscription paywall — monthly, annual (with discount badge), lifetime — with free trial, restore purchases, and entitlement-gated content. Powered by RevenueCat, backed by StoreKit 2.

Time

90–120 minutes

Prereqs

  • Xcode 16+
  • Free Apple Developer account (paid not required for sandbox testing)
  • Free RevenueCat account (app.revenuecat.com)
  • An app already configured in App Store Connect (you can use any existing app’s bundle ID)

Setup

Step 1 — App Store Connect: create the subscription group

  1. Open App Store Connect → Apps → your app → Monetization → Subscriptions.
  2. Create Subscription Group: name it Pro. Subscription groups bundle related tiers; users can only have one active subscription per group.
  3. Add Subscription:
Reference nameProduct IDDurationPrice
Pro Monthlycom.acme.pro.monthly1 month$7.99
Pro Annualcom.acme.pro.annual1 year$49.99
  1. For Pro Monthly: add Introductory Offer → Free Trial → 7 days, eligibility: New Customers.
  2. Submit each subscription. Status will be “Ready to Submit” — that’s enough for sandbox testing.

Step 2 — App Store Connect: create the lifetime IAP

  1. In-App Purchases → Create IAP → Non-Consumable.
ReferenceProduct IDPrice
Pro Lifetimecom.acme.pro.lifetime$99.99

Step 3 — Create sandbox tester

App Store Connect → Users and Access → Sandbox Testers → Add. Use a unique email (Apple won’t take your real one). Note the password.

Step 4 — RevenueCat configuration

  1. Sign up at app.revenuecat.com.
  2. Create a New App → iOS. Paste your bundle ID.
  3. App Store Connect API Key: in RevenueCat → Project Settings → upload your .p8 key, Key ID, and Issuer ID.
  4. Products: RevenueCat auto-discovers from App Store Connect. Confirm com.acme.pro.monthly, com.acme.pro.annual, com.acme.pro.lifetime appear.
  5. Entitlements: create one entitlement called pro. Attach all three products to it.
  6. Offerings: create one called default. Add three Packages:
    • $rc_monthlycom.acme.pro.monthly
    • $rc_annualcom.acme.pro.annual
    • $rc_lifetimecom.acme.pro.lifetime
  7. Mark the default offering as Current.
  8. API Keys: copy the public iOS SDK key (starts with appl_).

Step 5 — Xcode project

# In your project root
xcrun swift package init --type executable     # or use an existing project

Open Package.swift (or your Xcode project’s package dependencies) and add RevenueCat:

.package(url: "https://github.com/RevenueCat/purchases-ios.git", from: "5.0.0"),

In target:

.product(name: "RevenueCat", package: "purchases-ios"),

In your scheme: Edit Scheme → Run → Options → set StoreKit Configuration to your .storekit file (optional, useful for offline iteration).

Build

File: App.swift

import SwiftUI
import RevenueCat

@main
struct AcmeApp: App {
    @State private var entitlement = EntitlementStore()

    init() {
        Purchases.logLevel = .info
        Purchases.configure(withAPIKey: "appl_XXXXXXXXXXXXXXXX")
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(entitlement)
                .task {
                    await entitlement.refresh()
                }
        }
    }
}

File: EntitlementStore.swift

import Foundation
import RevenueCat
import Observation

@Observable
final class EntitlementStore {
    var isPro = false
    var customerInfo: CustomerInfo?

    func refresh() async {
        do {
            customerInfo = try await Purchases.shared.customerInfo()
            isPro = customerInfo?.entitlements["pro"]?.isActive == true
        } catch {
            print("Entitlement refresh failed: \(error)")
        }
    }
}

File: PaywallViewModel.swift

import Foundation
import RevenueCat
import Observation

@Observable
@MainActor
final class PaywallViewModel {
    enum Cadence { case monthly, annual, lifetime }

    var offering: Offering?
    var selectedCadence: Cadence = .annual
    var purchasing = false
    var error: String?

    func load() async {
        do {
            offering = try await Purchases.shared.offerings().current
        } catch {
            self.error = error.localizedDescription
        }
    }

    func selectedPackage() -> Package? {
        guard let offering else { return nil }
        switch selectedCadence {
        case .monthly:  return offering.monthly
        case .annual:   return offering.annual
        case .lifetime: return offering.lifetime
        }
    }

    func purchase() async -> Bool {
        guard let pkg = selectedPackage() else { return false }
        purchasing = true; defer { purchasing = false }
        do {
            let result = try await Purchases.shared.purchase(package: pkg)
            return !result.userCancelled && result.customerInfo.entitlements["pro"]?.isActive == true
        } catch {
            self.error = error.localizedDescription
            return false
        }
    }

    func restore() async -> Bool {
        do {
            let info = try await Purchases.shared.restorePurchases()
            return info.entitlements["pro"]?.isActive == true
        } catch {
            self.error = error.localizedDescription
            return false
        }
    }
}

File: PaywallView.swift

import SwiftUI
import RevenueCat

struct PaywallView: View {
    @State private var vm = PaywallViewModel()
    @Environment(EntitlementStore.self) private var entitlement
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        ScrollView {
            VStack(spacing: 24) {
                header
                if let offering = vm.offering {
                    tierSelector(offering: offering)
                    purchaseButton
                    restoreButton
                    legalFooter
                } else if vm.error != nil {
                    Text(vm.error ?? "Couldn't load offerings")
                        .foregroundStyle(.red)
                } else {
                    ProgressView()
                }
            }
            .padding()
        }
        .task { await vm.load() }
    }

    private var header: some View {
        VStack(spacing: 8) {
            Image(systemName: "sparkles")
                .font(.system(size: 56))
                .foregroundStyle(.tint)
            Text("Acme Pro")
                .font(.largeTitle.bold())
            Text("Unlimited notes, sync across devices, dark themes, priority support.")
                .multilineTextAlignment(.center)
                .foregroundStyle(.secondary)
        }
    }

    @ViewBuilder
    private func tierSelector(offering: Offering) -> some View {
        VStack(spacing: 12) {
            if let monthly = offering.monthly {
                tierRow(title: "Monthly",
                        price: monthly.localizedPriceString + "/mo",
                        badge: monthly.storeProduct.introductoryDiscount?.paymentMode == .freeTrial ? "7-day free trial" : nil,
                        selected: vm.selectedCadence == .monthly,
                        tap: { vm.selectedCadence = .monthly })
            }
            if let annual = offering.annual, let monthly = offering.monthly {
                let savings = computeSavings(annual: annual, monthly: monthly)
                tierRow(title: "Annual",
                        price: annual.localizedPriceString + "/yr",
                        badge: "Save \(savings)%",
                        selected: vm.selectedCadence == .annual,
                        tap: { vm.selectedCadence = .annual })
            }
            if let lifetime = offering.lifetime {
                tierRow(title: "Lifetime",
                        price: lifetime.localizedPriceString,
                        badge: "One-time",
                        selected: vm.selectedCadence == .lifetime,
                        tap: { vm.selectedCadence = .lifetime })
            }
        }
    }

    private func tierRow(title: String, price: String, badge: String?, selected: Bool, tap: @escaping () -> Void) -> some View {
        Button(action: tap) {
            HStack {
                VStack(alignment: .leading) {
                    Text(title).font(.headline)
                    if let badge { Text(badge).font(.caption).foregroundStyle(.tint) }
                }
                Spacer()
                Text(price).font(.body.bold())
                Image(systemName: selected ? "largecircle.fill.circle" : "circle")
                    .foregroundStyle(selected ? .tint : .secondary)
            }
            .padding()
            .background(RoundedRectangle(cornerRadius: 12).strokeBorder(selected ? .tint : .secondary.opacity(0.3), lineWidth: 2))
        }
        .buttonStyle(.plain)
    }

    private var purchaseButton: some View {
        Button {
            Task {
                if await vm.purchase() {
                    await entitlement.refresh()
                    if entitlement.isPro { dismiss() }
                }
            }
        } label: {
            HStack {
                if vm.purchasing { ProgressView() } else { Text("Continue").bold() }
            }
            .frame(maxWidth: .infinity)
            .padding()
            .background(Color.accentColor)
            .foregroundStyle(.white)
            .clipShape(RoundedRectangle(cornerRadius: 12))
        }
        .disabled(vm.purchasing || vm.selectedPackage() == nil)
    }

    private var restoreButton: some View {
        Button("Restore purchases") {
            Task {
                if await vm.restore() {
                    await entitlement.refresh()
                    if entitlement.isPro { dismiss() }
                }
            }
        }
        .font(.footnote)
        .foregroundStyle(.secondary)
    }

    private var legalFooter: some View {
        VStack(spacing: 4) {
            Text("Auto-renews. Cancel anytime in Settings.")
            HStack {
                Link("Terms", destination: URL(string: "https://acme.com/terms")!)
                Text("·")
                Link("Privacy", destination: URL(string: "https://acme.com/privacy")!)
            }
        }
        .font(.caption2)
        .foregroundStyle(.secondary)
    }

    private func computeSavings(annual: Package, monthly: Package) -> Int {
        let annualCost = NSDecimalNumber(decimal: annual.storeProduct.price).doubleValue
        let monthlyAsAnnual = NSDecimalNumber(decimal: monthly.storeProduct.price).doubleValue * 12
        let savings = (1 - (annualCost / monthlyAsAnnual)) * 100
        return Int(savings.rounded())
    }
}

File: ContentView.swift

import SwiftUI

struct ContentView: View {
    @Environment(EntitlementStore.self) private var entitlement
    @State private var showPaywall = false

    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                if entitlement.isPro {
                    Label("Pro unlocked", systemImage: "checkmark.seal.fill")
                        .font(.title.bold())
                        .foregroundStyle(.green)
                    Text("All features available.")
                } else {
                    Text("Free tier")
                        .font(.title.bold())
                    Button("Upgrade to Pro") { showPaywall = true }
                        .buttonStyle(.borderedProminent)
                }
            }
            .padding()
            .sheet(isPresented: $showPaywall) {
                PaywallView()
            }
        }
    }
}

Running with sandbox

  1. Build and run on a physical device (Simulator works partially but sandbox purchase flow is more reliable on device).
  2. Settings → App Store → Sandbox Account → sign in with your sandbox tester.
  3. Open your app → tap Upgrade → pick a tier → Continue → enter sandbox password.
  4. Watch the console: RevenueCat logs every step.
  5. Tap Restore Purchases to verify entitlement re-hydrates on reinstall.

Stretch

  • A/B test paywalls via RevenueCat Experiments: create a second Offering with different prices/cadence, run an experiment, observe variant in vm.offering.
  • Add a “Manage subscription” button that opens .manageSubscriptionsSheet(isPresented:) for active subscribers.
  • Wire RevenueCat webhooks to a tiny FastAPI endpoint that logs events to a sqlite DB — instant subscription analytics.
  • Add ATT pre-prompt explaining attribution benefit before triggering the system prompt — see chapter 11.9.
  • Test a refund flow: subscribe in sandbox, then in RevenueCat dashboard issue a test refund; confirm webhook fires and entitlement is revoked within seconds.

Notes

  • Sandbox subscriptions accelerate: a “monthly” subscription renews every 5 minutes in sandbox. Plan testing accordingly.
  • RevenueCat free tier covers $2.5k MTR; you’ll easily build and ship under that limit.
  • Always test restore-purchases on a fresh install — many production bugs only surface there.
  • Always set Purchases.shared.attribution.setAttributes(...) with your internal user ID after login, so cross-system join works.

Next: Lab 11.2 — Automated Pricing Script