NewAppLander — App landing pages in 60s$69$39
The Swift Kit logoThe Swift Kit
Tutorial

How to Add RevenueCat to SwiftUI: Complete StoreKit 2 Integration Guide

Step-by-step guide to integrating RevenueCat with StoreKit 2 in your SwiftUI app.

Ahmed GaganAhmed Gagan
8 min read

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.currentEntitlements helps, 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 revocationDate and expirationDate, 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:

  1. Your SwiftUI view calls Purchases.shared.purchase(package:)
  2. RevenueCat's SDK calls StoreKit 2's native Product.purchase() under the hood
  3. Apple's payment sheet appears — this is 100% native, handled by iOS itself
  4. The user authenticates with Face ID / Touch ID / password
  5. Apple processes the payment and returns a signed transaction to the device
  6. RevenueCat intercepts the transaction, sends it to their servers for validation
  7. RevenueCat's servers verify the JWS signature, record the purchase, and return CustomerInfo
  8. Your app receives the updated CustomerInfo with 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:

FeatureStoreKit 2 (Native)RevenueCat
Server-side receipt validationManual (build your own server)Automatic
Cross-platform supportiOS / macOS onlyiOS, Android, Web, Stripe
Analytics dashboardApp Store Connect (limited)Rich dashboard with MRR, churn, cohorts
A/B testing paywallsNot supportedBuilt-in with Offerings
Promotional offersRequires server for signingDashboard + automatic signing
Grace period handlingManual logic requiredAutomatic
Webhooks for eventsApp Store Server Notifications (v2)Unified webhooks + integrations
PriceFreeFree up to $2.5K MTR
Setup time2-4 weeks (with server)1-2 hours
Sandbox testingXcode StoreKit config requiredWorks 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.git

Set 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 = delegate

This 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:

PlanPriceMTR LimitKey Features
Free$0/month$2,500 MTRCore SDK, Charts, Experiments (limited), Paywalls
Starter$35/month$10,000 MTREverything in Free + Integrations, Targeting
Pro$100/month$50,000 MTREverything in Starter + Unlimited Experiments, Charts filters
Scale$500/month$200,000 MTREverything in Pro + Priority support, custom contracts
EnterpriseCustomUnlimitedEverything 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:

EventWhat It MeansAction Required
INITIAL_PURCHASEUser subscribed for the first timeGrant access, send welcome email
RENEWALSubscription auto-renewed successfullyNone (access continues)
CANCELLATIONUser turned off auto-renewAccess continues until period ends
EXPIRATIONSubscription period ended without renewalRevoke access
BILLING_ISSUEPayment failed, Apple is retryingShow in-app message, keep access during grace period
PRODUCT_CHANGEUser switched plans (e.g., monthly to annual)Update UI to reflect new plan
TRANSFERSubscription transferred to a different app user IDUpdate 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:

  1. 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.
  2. 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.
  3. Set RevenueCat to debug mode — Use Purchases.logLevel = .debug so you can see every API call in the Xcode console.
  4. 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.
  5. 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:

  1. Remove debug logging — Set Purchases.logLevel = .warn or .error in production builds. Debug logging exposes API keys and transaction details in the console.
  2. Verify your API key is the production Apple key — Not sandbox, not Google, not a revoked key.
  3. Test all purchase flows in sandbox — New purchase, restore, upgrade, downgrade, cancellation, and re-subscribe.
  4. Ensure "Restore Purchases" button exists and works — Required by Apple. Test it from a clean install.
  5. Handle the "no offerings" state — If RevenueCat returns no offerings (network error, misconfiguration), show a helpful error, not a blank screen.
  6. Verify entitlement names match — The entitlement ID in your code must exactly match the one in the RevenueCat dashboard. This is case-sensitive.
  7. 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.
  8. Test on a real device — StoreKit in the simulator has limitations. Always do a final round of testing on a physical iPhone.
  9. Verify your paywall shows correct prices — Prices vary by region. RevenueCat localizes them automatically, but verify by changing your sandbox account's storefront.
  10. 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.

Share this article

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Swift Kit