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
- Open App Store Connect → Apps → your app → Monetization → Subscriptions.
- Create Subscription Group: name it
Pro. Subscription groups bundle related tiers; users can only have one active subscription per group. - Add Subscription:
| Reference name | Product ID | Duration | Price |
|---|---|---|---|
| Pro Monthly | com.acme.pro.monthly | 1 month | $7.99 |
| Pro Annual | com.acme.pro.annual | 1 year | $49.99 |
- For Pro Monthly: add Introductory Offer → Free Trial → 7 days, eligibility: New Customers.
- Submit each subscription. Status will be “Ready to Submit” — that’s enough for sandbox testing.
Step 2 — App Store Connect: create the lifetime IAP
- In-App Purchases → Create IAP → Non-Consumable.
| Reference | Product ID | Price |
|---|---|---|
| Pro Lifetime | com.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
- Sign up at app.revenuecat.com.
- Create a New App → iOS. Paste your bundle ID.
- App Store Connect API Key: in RevenueCat → Project Settings → upload your
.p8key, Key ID, and Issuer ID. - Products: RevenueCat auto-discovers from App Store Connect. Confirm
com.acme.pro.monthly,com.acme.pro.annual,com.acme.pro.lifetimeappear. - Entitlements: create one entitlement called
pro. Attach all three products to it. - Offerings: create one called
default. Add three Packages:$rc_monthly→com.acme.pro.monthly$rc_annual→com.acme.pro.annual$rc_lifetime→com.acme.pro.lifetime
- Mark the
defaultoffering as Current. - 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
- Build and run on a physical device (Simulator works partially but sandbox purchase flow is more reliable on device).
- Settings → App Store → Sandbox Account → sign in with your sandbox tester.
- Open your app → tap Upgrade → pick a tier → Continue → enter sandbox password.
- Watch the console: RevenueCat logs every step.
- 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.