Want to monetize your iOS app with subscriptions? This guide shows you how to add RevenueCat to SwiftUI, explains StoreKit 2 vs RevenueCat, and includes ready-to-use SwiftUI paywall templates. I have shipped 4 apps with RevenueCat, and this is the exact integration pattern I use every time.
Why RevenueCat Over Raw StoreKit 2
Let me be direct: StoreKit 2 is a massive improvement over StoreKit 1. The async/await API is genuinely nice to work with. But "nice API" and "production-ready subscription infrastructure" are two very different things. Here are the specific pain points StoreKit 2 forces you to handle on your own:
- Server-side receipt validation — StoreKit 2 gives you JWS-signed transactions on-device, but you still need a server to validate them if you want to grant access across devices or on the web. That means building and maintaining a backend endpoint, handling certificate pinning, and parsing Apple's signed payloads.
- Subscription status across devices — If a user subscribes on their iPhone and opens your app on their iPad, you need to sync that state yourself. StoreKit 2's
Transaction.currentEntitlementshelps, but only if the user is signed in with the same Apple ID, and you still need to handle edge cases like family sharing. - Grace periods and billing retry — When a credit card fails, Apple retries billing for up to 60 days. During that window, should the user keep access? StoreKit 2 gives you a
revocationDateandexpirationDate, but the logic for grace periods, billing retry states, and voluntary vs. involuntary churn is entirely on you. - Analytics and churn tracking — App Store Connect gives you subscriber counts, but no cohort analysis, no MRR charts, no churn breakdown by cancellation reason. If you want to know why users cancel, you are building that yourself.
- Price testing — Want to test $4.99/month vs $9.99/month? With StoreKit 2, you create separate products in App Store Connect, submit them for review, and manage the logic yourself. RevenueCat lets you do this from a dashboard in minutes.
- Promotional offers and win-back flows — StoreKit 2 supports promotional offers, but generating the signature requires a server. RevenueCat handles the signature generation, targeting, and redemption automatically.
I spent about 3 weeks building raw StoreKit 2 infrastructure for my first app. For my second app, I switched to RevenueCat and had subscriptions working in an afternoon. The difference is not close.
How RevenueCat Sits on Top of StoreKit 2
This is important to understand: RevenueCat does not replace StoreKit. It wraps it. When a user taps "Subscribe" in your app, here is what actually happens:
- Your SwiftUI view calls
Purchases.shared.purchase(package:) - RevenueCat's SDK calls StoreKit 2's native
Product.purchase()under the hood - Apple's payment sheet appears — this is 100% native, handled by iOS itself
- The user authenticates with Face ID / Touch ID / password
- Apple processes the payment and returns a signed transaction to the device
- RevenueCat intercepts the transaction, sends it to their servers for validation
- RevenueCat's servers verify the JWS signature, record the purchase, and return
CustomerInfo - Your app receives the updated
CustomerInfowith active entitlements
The purchase itself is always native Apple. RevenueCat adds the server-side validation, cross-device sync, analytics, and subscription management on top. Your users never interact with RevenueCat directly — they just see the standard Apple payment flow.
StoreKit 2 vs RevenueCat: The Full Comparison
Here is the detailed breakdown I wish I had when I started:
| Feature | StoreKit 2 (Native) | RevenueCat |
|---|---|---|
| Server-side receipt validation | Manual (build your own server) | Automatic |
| Cross-platform support | iOS / macOS only | iOS, Android, Web, Stripe |
| Analytics dashboard | App Store Connect (limited) | Rich dashboard with MRR, churn, cohorts |
| A/B testing paywalls | Not supported | Built-in with Offerings |
| Promotional offers | Requires server for signing | Dashboard + automatic signing |
| Grace period handling | Manual logic required | Automatic |
| Webhooks for events | App Store Server Notifications (v2) | Unified webhooks + integrations |
| Price | Free | Free up to $2.5K MTR |
| Setup time | 2-4 weeks (with server) | 1-2 hours |
| Sandbox testing | Xcode StoreKit config required | Works with sandbox + debug logs |
The verdict: Use RevenueCat on top of StoreKit 2. You get native Apple purchases with RevenueCat's subscription management, analytics, and server-side validation. That is exactly how The Swift Kit is set up, and it is the pattern I recommend for every indie iOS developer.
How to Add RevenueCat to SwiftUI — Step by Step
Step 1: Install the SDK via SPM
Open your Xcode project, go to File → Add Package Dependencies, and paste this URL:
https://github.com/RevenueCat/purchases-ios.gitSet the dependency rule to Up to Next Major Version and choose 5.0.0 as the minimum. Add the RevenueCat library to your app target. If you also want RevenueCat's pre-built paywall UI, add RevenueCatUI as well.
If you are using a Package.swift for a multi-module project, add the dependency like this:
// Package.swift
dependencies: [
.package(
url: "https://github.com/RevenueCat/purchases-ios.git",
from: "5.0.0"
)
],
targets: [
.target(
name: "MyApp",
dependencies: [
.product(name: "RevenueCat", package: "purchases-ios"),
.product(name: "RevenueCatUI", package: "purchases-ios"),
]
)
]Step 2: Configure on App Launch
RevenueCat must be configured before any purchase-related calls. The best place is your App struct'sinit(). Here is the full setup with proper configuration options and error handling:
// App.swift
import SwiftUI
import RevenueCat
@main
struct MyApp: App {
init() {
Purchases.logLevel = .debug // Remove in production
Purchases.configure(
with: .builder(withAPIKey: "appl_YOUR_REVENUECAT_API_KEY")
.with(usesStoreKit2IfAvailable: true)
.with(appUserID: nil) // nil = anonymous, RevenueCat generates an ID
.build()
)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}Important: Use your Apple API key from the RevenueCat dashboard (starts with appl_), not the Google or Amazon key. This is the number one mistake I see developers make. The key is safe to ship in your binary — it can only read offerings and initiate purchases, not modify your account.
Step 3: Create a PurchasesService Protocol
Wrapping RevenueCat in a protocol makes your code testable and decoupled. If you ever need to swap out RevenueCat (unlikely, but possible), you only change one file. Here is the pattern I use in every app:
// PurchasesService.swift
import RevenueCat
protocol PurchasesServiceProtocol {
func fetchOfferings() async throws -> Offerings
func purchase(_ package: Package) async throws -> CustomerInfo
func restorePurchases() async throws -> CustomerInfo
func customerInfo() async throws -> CustomerInfo
func checkEntitlement(_ id: String) async -> Bool
}
final class RevenueCatPurchasesService: PurchasesServiceProtocol {
func fetchOfferings() async throws -> Offerings {
let offerings = try await Purchases.shared.offerings()
guard let _ = offerings.current else {
throw PurchaseError.noOfferingsAvailable
}
return offerings
}
func purchase(_ package: Package) async throws -> CustomerInfo {
let (_, customerInfo, userCancelled) = try await Purchases.shared.purchase(package: package)
if userCancelled {
throw PurchaseError.userCancelled
}
return customerInfo
}
func restorePurchases() async throws -> CustomerInfo {
return try await Purchases.shared.restorePurchases()
}
func customerInfo() async throws -> CustomerInfo {
return try await Purchases.shared.customerInfo()
}
func checkEntitlement(_ id: String) async -> Bool {
guard let info = try? await Purchases.shared.customerInfo() else {
return false
}
return info.entitlements[id]?.isActive == true
}
}
enum PurchaseError: LocalizedError {
case noOfferingsAvailable
case userCancelled
case purchaseFailed(String)
var errorDescription: String? {
switch self {
case .noOfferingsAvailable: return "No subscription plans available."
case .userCancelled: return "Purchase was cancelled."
case .purchaseFailed(let msg): return "Purchase failed: \(msg)"
}
}
}Step 4: Fetch and Display Offerings
Offerings are the products you have configured in the RevenueCat dashboard. Each offering contains one or more packages (monthly, annual, lifetime, etc.). Here is a complete SwiftUI view that fetches and displays them:
// PaywallView.swift
import SwiftUI
import RevenueCat
struct PaywallView: View {
@State private var offerings: Offerings?
@State private var selectedPackage: Package?
@State private var isPurchasing = false
@State private var errorMessage: String?
private let purchasesService: PurchasesServiceProtocol
init(purchasesService: PurchasesServiceProtocol = RevenueCatPurchasesService()) {
self.purchasesService = purchasesService
}
var body: some View {
VStack(spacing: 24) {
Text("Upgrade to Pro")
.font(.largeTitle.bold())
if let packages = offerings?.current?.availablePackages {
ForEach(packages) { package in
PackageRow(
package: package,
isSelected: selectedPackage?.identifier == package.identifier
)
.onTapGesture { selectedPackage = package }
}
} else {
ProgressView("Loading plans...")
}
if let error = errorMessage {
Text(error)
.foregroundStyle(.red)
.font(.caption)
}
Button {
Task { await handlePurchase() }
} label: {
Group {
if isPurchasing {
ProgressView()
} else {
Text("Subscribe Now")
}
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(selectedPackage == nil || isPurchasing)
Button("Restore Purchases") {
Task { await handleRestore() }
}
.font(.footnote)
}
.padding()
.task { await loadOfferings() }
}
private func loadOfferings() async {
do {
offerings = try await purchasesService.fetchOfferings()
selectedPackage = offerings?.current?.availablePackages.first
} catch {
errorMessage = error.localizedDescription
}
}
private func handlePurchase() async {
guard let package = selectedPackage else { return }
isPurchasing = true
errorMessage = nil
do {
let info = try await purchasesService.purchase(package)
if info.entitlements["pro"]?.isActive == true {
// Dismiss paywall or navigate to premium content
}
} catch {
errorMessage = error.localizedDescription
}
isPurchasing = false
}
private func handleRestore() async {
do {
let info = try await purchasesService.restorePurchases()
if info.entitlements["pro"]?.isActive == true {
// Dismiss paywall
} else {
errorMessage = "No active subscription found."
}
} catch {
errorMessage = error.localizedDescription
}
}
}Step 5: Handle Purchases with Full Error Handling
The purchase flow above handles the happy path, but production apps need to account for several edge cases. Here is the full purchase handler I use:
func performPurchase(package: Package) async -> PurchaseResult {
do {
let (transaction, customerInfo, userCancelled) =
try await Purchases.shared.purchase(package: package)
if userCancelled {
return .cancelled
}
guard let transaction = transaction else {
return .error("Transaction was nil.")
}
// Check if this was a pending purchase (Ask to Buy, etc.)
if transaction.transactionState == .deferred {
return .pending
}
let isActive = customerInfo.entitlements["pro"]?.isActive == true
return isActive ? .success : .error("Entitlement not active after purchase.")
} catch let error as RevenueCat.ErrorCode {
switch error {
case .purchaseNotAllowedError:
return .error("Purchases are not allowed on this device.")
case .purchaseInvalidError:
return .error("This purchase is invalid.")
case .networkError:
return .error("Network error. Please check your connection.")
case .storeProblemError:
return .error("App Store error. Try again later.")
default:
return .error(error.localizedDescription)
}
} catch {
return .error(error.localizedDescription)
}
}
enum PurchaseResult {
case success
case cancelled
case pending
case error(String)
}Step 6: Gate Premium Features with Entitlements
Checking entitlements is how you control access to premium features. I recommend a simple environment object that the rest of your app can observe:
// PremiumManager.swift
import SwiftUI
import RevenueCat
@MainActor
final class PremiumManager: ObservableObject {
@Published var isPro = false
func checkStatus() async {
guard let info = try? await Purchases.shared.customerInfo() else {
isPro = false
return
}
isPro = info.entitlements["pro"]?.isActive == true
}
}
// Usage in any view:
struct FeatureView: View {
@EnvironmentObject var premium: PremiumManager
var body: some View {
if premium.isPro {
PremiumFeatureContent()
} else {
LockedFeatureView()
}
}
}Step 7: Restore Purchases
Apple requires that every app with subscriptions includes a "Restore Purchases" button. If you skip this, your app will be rejected during review. Here is the implementation:
// RestorePurchasesButton.swift
import SwiftUI
import RevenueCat
struct RestorePurchasesButton: View {
@State private var isRestoring = false
@State private var showAlert = false
@State private var alertMessage = ""
var body: some View {
Button {
Task {
isRestoring = true
do {
let info = try await Purchases.shared.restorePurchases()
if info.entitlements["pro"]?.isActive == true {
alertMessage = "Your subscription has been restored."
} else {
alertMessage = "No active subscription found for this account."
}
} catch {
alertMessage = "Restore failed: \(error.localizedDescription)"
}
isRestoring = false
showAlert = true
}
} label: {
if isRestoring {
ProgressView()
} else {
Text("Restore Purchases")
}
}
.alert("Restore Purchases", isPresented: $showAlert) {
Button("OK") {}
} message: {
Text(alertMessage)
}
}
}Step 8: Listen for Subscription Status Changes
Subscriptions can change at any time — a user might cancel, get a refund, or renew after a lapse. RevenueCat provides a delegate to listen for these changes in real time:
// AppDelegate or App init
import RevenueCat
final class PurchasesDelegate: NSObject, PurchasesDelegate {
let premiumManager: PremiumManager
init(premiumManager: PremiumManager) {
self.premiumManager = premiumManager
}
func purchases(
_ purchases: Purchases,
receivedUpdated customerInfo: CustomerInfo
) {
Task { @MainActor in
premiumManager.isPro =
customerInfo.entitlements["pro"]?.isActive == true
}
}
}
// In your App.init():
let delegate = PurchasesDelegate(premiumManager: premiumManager)
Purchases.shared.delegate = delegateThis delegate fires whenever RevenueCat detects a change — including when the app comes back to the foreground, when a renewal succeeds, or when a subscription is revoked. Combined with the PremiumManager above, your entire UI updates automatically through SwiftUI's reactive bindings.
RevenueCat Pricing Tiers
RevenueCat has a generous free tier that covers most indie developers. Here is the current breakdown:
| Plan | Price | MTR Limit | Key Features |
|---|---|---|---|
| Free | $0/month | $2,500 MTR | Core SDK, Charts, Experiments (limited), Paywalls |
| Starter | $35/month | $10,000 MTR | Everything in Free + Integrations, Targeting |
| Pro | $100/month | $50,000 MTR | Everything in Starter + Unlimited Experiments, Charts filters |
| Scale | $500/month | $200,000 MTR | Everything in Pro + Priority support, custom contracts |
| Enterprise | Custom | Unlimited | Everything in Scale + SLA, dedicated support, SSO |
MTR stands for Monthly Tracked Revenue — it is the revenue from users that RevenueCat tracks, not what you pay RevenueCat. For most indie apps making under $2,500/month, the free tier is all you need.
Common RevenueCat Events and What They Mean
When you set up webhooks or check the RevenueCat dashboard, you will see these events. Understanding them is critical for debugging subscription issues:
| Event | What It Means | Action Required |
|---|---|---|
| INITIAL_PURCHASE | User subscribed for the first time | Grant access, send welcome email |
| RENEWAL | Subscription auto-renewed successfully | None (access continues) |
| CANCELLATION | User turned off auto-renew | Access continues until period ends |
| EXPIRATION | Subscription period ended without renewal | Revoke access |
| BILLING_ISSUE | Payment failed, Apple is retrying | Show in-app message, keep access during grace period |
| PRODUCT_CHANGE | User switched plans (e.g., monthly to annual) | Update UI to reflect new plan |
| TRANSFER | Subscription transferred to a different app user ID | Update your backend user mapping |
Sandbox Testing Guide
You should never test in-app purchases with real money during development. Apple provides a sandbox environment specifically for this. Here is how to set it up:
- Create a Sandbox Apple ID — Go to App Store Connect → Users and Access → Sandbox → Testers. Create a new tester with a unique email address. This does not need to be a real email.
- Sign out of your real Apple ID — On your test device, go to Settings → App Store → sign out. Do not sign in with the sandbox account here — you will be prompted when you attempt a purchase in your app.
- Set RevenueCat to debug mode — Use
Purchases.logLevel = .debugso you can see every API call in the Xcode console. - Subscription time is accelerated — In sandbox, a 1-month subscription renews every 5 minutes, a 1-week subscription renews every 3 minutes, and a 1-year subscription renews every 1 hour. Subscriptions auto-renew up to 6 times, then expire.
- Check the RevenueCat dashboard — Navigate to Customers and search for your sandbox user. You will see the full transaction history, entitlements, and any errors.
Pro tip: Use Xcode's StoreKit Configuration file for unit testing and the sandbox environment for integration testing. They serve different purposes. The StoreKit config file lets you test without any network calls, while sandbox tests the full Apple + RevenueCat pipeline.
Common Mistakes to Avoid
I have seen these mistakes in almost every RevenueCat-related question on Stack Overflow and the Apple developer forums:
- Using the wrong API key type — RevenueCat generates separate keys for Apple, Google, and Amazon. If you use a Google key on iOS, nothing will work and the error message is not obvious.
- Not handling pending purchases — "Ask to Buy" (for child accounts) creates deferred transactions. If you do not handle this state, the child sees a blank screen after requesting the purchase.
- Not testing restore — Apple reviewers will test "Restore Purchases" during review. If it does not work, you get rejected. Test it in sandbox before every submission.
- Forgetting to set up products in both App Store Connect and RevenueCat — Products must exist in App Store Connect first, then be mapped to Entitlements and Offerings in the RevenueCat dashboard. Missing either step means no products show up.
- Not handling network errors gracefully — RevenueCat requires a network connection to validate purchases. Show a clear error and let the user retry instead of failing silently.
- Hardcoding product IDs — Use Offerings from the RevenueCat dashboard instead. This lets you change products, add new tiers, or run A/B tests without shipping an app update.
- Not setting the app user ID — If you have your own auth system (like Supabase), call
Purchases.shared.logIn(appUserID:)after authentication. Otherwise, you cannot track subscriptions across devices for the same user.
Production Checklist Before Going Live
Before you submit your app to the App Store, run through every item on this list. I have been bitten by each one at least once:
- Remove debug logging — Set
Purchases.logLevel = .warnor.errorin production builds. Debug logging exposes API keys and transaction details in the console. - Verify your API key is the production Apple key — Not sandbox, not Google, not a revoked key.
- Test all purchase flows in sandbox — New purchase, restore, upgrade, downgrade, cancellation, and re-subscribe.
- Ensure "Restore Purchases" button exists and works — Required by Apple. Test it from a clean install.
- Handle the "no offerings" state — If RevenueCat returns no offerings (network error, misconfiguration), show a helpful error, not a blank screen.
- Verify entitlement names match — The entitlement ID in your code must exactly match the one in the RevenueCat dashboard. This is case-sensitive.
- Set up App Store Server Notifications — In App Store Connect, enter RevenueCat's server notification URL. This ensures RevenueCat gets real-time updates about renewals, cancellations, and refunds.
- Test on a real device — StoreKit in the simulator has limitations. Always do a final round of testing on a physical iPhone.
- Verify your paywall shows correct prices — Prices vary by region. RevenueCat localizes them automatically, but verify by changing your sandbox account's storefront.
- Check your paywall design — Apple has guidelines about subscription marketing. You must clearly display the price, duration, and free trial terms.
Skip the Manual Setup — Use The Swift Kit
Everything I covered above — the PurchasesService protocol, the paywall view, entitlement checking, restore purchases, subscription status listeners, error handling — is already built and tested in The Swift Kit. You get three pre-built SwiftUI paywall templates, a complete subscription management layer, trial flow handling, and a production-ready architecture. Just paste your RevenueCat API key in the config file and you are shipping.
Check out the full feature list or see the pricing plans to get started today. If you are serious about monetizing your iOS app, do not waste weeks on subscription infrastructure. Build your actual product instead.