You've shipped an app. It handles subscriptions with raw StoreKit. It mostly works — until it doesn't. Receipt validation logic sprawled across three files. Sandbox purchases that randomly fail. No clean way to check whether a user is actually subscribed right now, across devices. And the moment a user emails support saying "I already paid for this," you start dreading the audit trail you have to untangle.

This guide walks you through replacing that raw StoreKit plumbing with RevenueCat — step by step, with before/after Swift code throughout. If you're already familiar with RevenueCat architecture and want advanced StoreKit 2 patterns, the companion article StoreKit 2 Migration Patterns with RevenueCat covers that ground. This guide is your end-to-end migration path from zero.


1. Why Migrate Away from Raw StoreKit?

Before touching a single line of code, it's worth naming the specific pain points that make raw StoreKit costly to maintain:

Receipt validation complexity. StoreKit 1 gives you a local appStoreReceiptURL blob. Validating it properly means either running your own server-side /verifyReceipt calls (with all the key-management overhead) or trusting client-side validation — which Apple explicitly discourages. RevenueCat handles all receipt validation server-side, automatically, for every transaction. Source

No cross-platform story. If you ever ship on Android or want web billing, StoreKit is iOS-only. RevenueCat provides a unified SDK across iOS, Android, and Amazon with a single CustomerInfo model.

Subscription status is hard. Determining whether a user is right now subscribed — accounting for grace periods, billing retries, cancellations, and family sharing — requires parsing a receipt with dozens of fields. RevenueCat collapses this into a single isActive check on an EntitlementInfo object. Source

Sandbox testing is painful. Apple's sandbox environment has known metadata inconsistencies — inaccurate prices, mismatched product descriptions — that make it unreliable for early development. RevenueCat ships a Test Store that provisions automatically with every new project and requires no App Store Connect setup. Source

No analytics out of the box. Raw StoreKit tells you a transaction happened. It doesn't tell you LTV, trial conversion rates, or churn. RevenueCat captures all of this automatically.


2. Pre-Migration Checklist: Dashboard Setup

Before adding a single dependency, configure the RevenueCat dashboard. Doing this first means you'll have a working backend to test against as soon as the SDK is installed.

Step 1 — Create a Project and Connect Your App

Sign up at app.revenuecat.com and create a project. Under Project Settings > API Keys, grab your public iOS API key — this is the only key you'll use in client code. Source

Step 2 — Configure Products

Navigate to Product Catalog in your project dashboard. Add your existing App Store product identifiers (e.g., com.yourapp.pro.monthly). RevenueCat needs these IDs to validate receipts and map purchases to subscribers. Source

Step 3 — Create Entitlements

An Entitlement is a named level of access — e.g., "pro". Most apps need just one.

Navigate to Product Catalog → Entitlements → + New Entitlement. Give it an identifier like pro. Then click Attach to link your existing product IDs to it.

This is the critical mapping step: when a user purchases com.yourapp.pro.monthly, RevenueCat will automatically mark the pro entitlement as active for them. Source

⚠️ Common mistake: Forgetting to attach products to entitlements. If a product isn't attached, purchases complete but no entitlement is granted — your users pay and nothing unlocks.

Step 4 — Create an Offering

Group your products into an Offering — a logical paywall configuration. Set one as your Default Offering. This is what getOfferings() returns at runtime. You can update it remotely without shipping a new app version. Source


3. SDK Installation

RevenueCat for iOS supports Swift Package Manager, CocoaPods, and Carthage. Source

In Xcode: File → Add Package Dependencies…

Use the SPM mirror for faster resolution:

https://github.com/RevenueCat/purchases-ios-spm.git

Set the Dependency Rule to Up to Next Major, version 5.0.0 < 6.0.0. When prompted, select the RevenueCat product (add RevenueCatUI too if you plan to use RevenueCat's pre-built paywalls).

CocoaPods

Add to your Podfile:

pod 'RevenueCat', '~> 5.0'

Then run:

pod install

Enable In-App Purchase Capability

In Xcode: Project Target → Capabilities → In-App Purchase — toggle it on. Without this, StoreKit calls will silently fail. Source


4. SDK Initialization: Replacing SKPaymentQueue

Before — Raw StoreKit Setup

// AppDelegate.swift (StoreKit 1)
import StoreKit

class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Manually add a transaction observer to the payment queue
        SKPaymentQueue.default().add(self)
        return true
    }
}

extension AppDelegate: SKPaymentTransactionObserver {
    func paymentQueue(_ queue: SKPaymentQueue,
                      updatedTransactions transactions: [SKPaymentTransaction]) {
        for transaction in transactions {
            switch transaction.transactionState {
            case .purchased:
                // Validate receipt, unlock content, finish transaction
                validateReceipt(transaction)
                SKPaymentQueue.default().finishTransaction(transaction)
            case .failed:
                SKPaymentQueue.default().finishTransaction(transaction)
            case .restored:
                SKPaymentQueue.default().finishTransaction(transaction)
            default:
                break
            }
        }
    }
}

After — RevenueCat Initialization

// AppDelegate.swift (RevenueCat)
import RevenueCat

class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        // Enable debug logs BEFORE configure — essential during development
        Purchases.logLevel = .debug

        Purchases.configure(withAPIKey: "appl_xxxxxxxxxxxxxxxx")

        // If you have a logged-in user at launch:
        // Purchases.configure(withAPIKey: "appl_xxx", appUserID: currentUser.id)

        return true
    }
}

SKPaymentQueue is gone. RevenueCat handles transaction observation, receipt finishing, and server validation automatically. Source

⚠️ Configure only once. Call Purchases.configure once, early in the app lifecycle. Elsewhere in the app, access the singleton via Purchases.shared. Source


5. Replacing Core StoreKit Calls

5a. Fetching Products: SKProductsRequestgetOfferings

Before

// StoreKit 1 — fetch products by hard-coded ID list
class ProductFetcher: NSObject, SKProductsRequestDelegate {
    var products: [SKProduct] = []

    func fetchProducts() {
        let productIDs: Set<String> = ["com.yourapp.pro.monthly", "com.yourapp.pro.annual"]
        let request = SKProductsRequest(productIdentifiers: productIDs)
        request.delegate = self
        request.start()
    }

    func productsRequest(_ request: SKProductsRequest,
                         didReceive response: SKProductsResponse) {
        self.products = response.products
        // Manually build UI from response.products
    }
}

After

// RevenueCat — fetch the current Offering from the dashboard
Purchases.shared.getOfferings { offerings, error in
    if let error {
        print("Error fetching offerings: \(error.localizedDescription)")
        return
    }

    guard let current = offerings?.current else {
        print("No current offering configured")
        return
    }

    // Access packages by type — no hardcoded product IDs
    if let monthly = current.monthly {
        let product = monthly.storeProduct
        print("Monthly: \(product.localizedPriceString)")
    }

    if let annual = current.annual {
        let product = annual.storeProduct
        print("Annual: \(product.localizedPriceString)")
    }

    // Or iterate all available packages
    for package in current.availablePackages {
        print("\(package.identifier): \(package.storeProduct.localizedPriceString)")
    }
}

Offerings are pre-fetched and cached on app launch, so this call is nearly always instant. Source Your paywall no longer needs a hardcoded list of product IDs — update the Offering in the dashboard and it changes live for all users.


5b. Making a Purchase: SKPaymentQueue.addpurchase(package:)

Before

// StoreKit 1 — add payment to queue, handle in transaction observer
func purchaseProduct(_ product: SKProduct) {
    let payment = SKPayment(product: product)
    SKPaymentQueue.default().add(payment) // result handled by delegate
}

After

// RevenueCat — purchase a package from your Offering
func purchasePackage(_ package: Package) {
    Purchases.shared.purchase(package: package) { transaction, customerInfo, error, userCancelled in
        if userCancelled {
            // User tapped Cancel — no error to surface
            return
        }

        if let error {
            print("Purchase failed: \(error.localizedDescription)")
            return
        }

        // Check entitlement status directly on the returned CustomerInfo
        if customerInfo?.entitlements["pro"]?.isActive == true {
            unlockProFeatures()
        }
    }
}

purchase(package:) handles the StoreKit payment sheet, receipt validation, and server sync in a single call. The userCancelled boolean means you can handle dismissals without unwrapping the error. Source

Note: RevenueCat automatically finishes transactions. If you have existing code that finishes transactions separately, see Section 6 on observer mode before removing it.


5c. Restoring Purchases: restoreCompletedTransactionsrestorePurchases

Before

// StoreKit 1 — trigger restore via payment queue
func restorePurchases() {
    SKPaymentQueue.default().restoreCompletedTransactions()
    // Result handled in paymentQueueRestoreCompletedTransactionsFinished delegate
}

After

// RevenueCat — restore and check entitlement status in one callback
Purchases.shared.restorePurchases { customerInfo, error in
    if let error {
        print("Restore failed: \(error.localizedDescription)")
        return
    }

    if customerInfo?.entitlements["pro"]?.isActive == true {
        print("Pro access restored!")
        unlockProFeatures()
    } else {
        print("No active subscription found for this Apple ID.")
    }
}

RevenueCat's restorePurchases reactivates purchases from the same store account. Only call this from an explicit user action (e.g., a "Restore Purchases" button) — it can trigger OS-level sign-in prompts. Source


5d. Checking Subscription Status

This is where RevenueCat's model pays the biggest dividends. Instead of parsing a multi-field receipt, you check a single boolean:

Purchases.shared.getCustomerInfo { customerInfo, error in
    guard let customerInfo, error == nil else { return }

    // Single entitlement check — handles renewals, grace periods, all of it
    if customerInfo.entitlements["pro"]?.isActive == true {
        showProContent()
    } else {
        showPaywall()
    }
}

getCustomerInfo() is safe to call repeatedly throughout your app. The SDK caches CustomerInfo locally and only makes a network request if the cache is older than 5 minutes. Source

To react to subscription changes in real time, implement the PurchasesDelegate:

extension AppDelegate: PurchasesDelegate {
    func purchases(_ purchases: Purchases, receivedUpdated customerInfo: CustomerInfo) {
        // Fired whenever CustomerInfo changes on this device
        let isProActive = customerInfo.entitlements["pro"]?.isActive == true
        NotificationCenter.default.post(name: .subscriptionStatusChanged,
                                        object: isProActive)
    }
}

6. Handling Existing Subscribers: Observer Mode for Gradual Rollout

If you're migrating a live app, you can't assume all users will update immediately. RevenueCat provides a safe path called observer mode (now formally called "your app completes purchases") that lets RevenueCat track and validate purchases made by your existing StoreKit code — without RevenueCat taking over the purchase flow.

// Configure RevenueCat to observe only — your code still finishes transactions
Purchases.configure(
    with: .builder(withAPIKey: "appl_xxxxxxxxxxxxxxxx")
        .with(purchasesAreCompletedBy: .myApp, storeKitVersion: .storeKit1)
        .build()
)

With this configuration active, RevenueCat reads transactions and syncs them to its backend, but does not call SKPaymentQueue.finishTransaction. Your existing transaction observer keeps doing that. Source

Syncing Past Subscriptions

To bring existing subscribers into RevenueCat, call syncPurchases() — but only once per user on first launch, not on every app open:

// Call ONCE when the user has existing purchases but RevenueCat hasn't seen them yet
let hasSyncedToRevenueCat = UserDefaults.standard.bool(forKey: "rc_synced")

if !hasSyncedToRevenueCat && userHasExistingSubscription {
    Purchases.shared.syncPurchases { customerInfo, error in
        if error == nil {
            UserDefaults.standard.set(true, forKey: "rc_synced")
        }
    }
}

⚠️ Don't call syncPurchases() on every launch. This causes latency and can unintentionally alias different customers together. Source

Server-Side Receipt Import (For Large Subscriber Bases)

If you have Apple receipts stored on your backend, use the POST /receipts API endpoint to import them directly — no user action required, no waiting for users to update the app. This is the fastest path to accurate RevenueCat dashboards:

curl -X POST https://api.revenuecat.com/v1/receipts \
  -H "Authorization: Bearer YOUR_SECRET_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "app_user_id": "user_12345",
    "fetch_token": "BASE64_ENCODED_RECEIPT",
    "platform": "iOS"
  }'

RevenueCat automatically deduplicates receipts, so it's safe to import and then also receive the same receipt from the SDK later. Source


7. Entitlement Mapping: Your Product IDs → RevenueCat Access

Here's the complete mental model for how your existing product IDs map to RevenueCat concepts:

Your StoreKit Setup RevenueCat Equivalent Purpose
Product ID list (hardcoded) Offering (dashboard-managed) What products to show
SKProduct StoreProduct (on a Package) Underlying store product
"User has receipt for X" entitlements["pro"]?.isActive Access gating
SKPaymentQueue + delegate Purchases.shared.purchase(package:) Purchase flow
/verifyReceipt server call Automatic (RevenueCat backend) Validation

The key insight: stop gating access on product IDs and start gating on entitlement identifiers. Product IDs change when you update pricing, add platforms, or run experiments. The "pro" entitlement identifier stays stable forever.

// ❌ Old pattern — fragile, product-ID dependent
let activeProductIDs = receipt.activeSubscriptionProductIDs
if activeProductIDs.contains("com.yourapp.pro.monthly") || 
   activeProductIDs.contains("com.yourapp.pro.annual") {
    unlockPro()
}

// ✅ New pattern — stable, platform-agnostic
if customerInfo.entitlements["pro"]?.isActive == true {
    unlockPro()
}

8. Testing Strategy

Enable Debug Logs First

Before any sandbox testing, turn on verbose logging. Set this before Purchases.configure:

Purchases.logLevel = .debug
Purchases.configure(withAPIKey: "appl_xxxxxxxxxxxxxxxx")

All SDK logs are prefixed with [Purchases] in the Xcode console. Look for ⚠️ warnings and 🍎❌ error lines — these point to misconfiguration before you waste time debugging purchase flows. Source

Use the Debug UI Overlay

SDK version 4.22.0+ includes a built-in debug overlay you can embed in development builds:

// In your SwiftUI root view (debug builds only)
#if DEBUG
import RevenueCatUI

// Present the debug overlay
.debugRevenueCatOverlay()
#endif

The overlay shows your configured Offerings, active entitlements, and lets you make test purchases directly. Source

Testing Workflow

Follow RevenueCat's recommended three-stage approach: Source

  1. Test Store (Development) — Use your Test Store API key during development. No App Store Connect setup needed. Test purchases work instantly and update CustomerInfo correctly.

  2. Apple Sandbox (Pre-Launch) — Switch to your iOS platform API key. Test with a Sandbox Apple ID in the iOS Settings app. This validates your full StoreKit integration including renewals.

  3. Production — Deploy with the platform API key. Never ship with the Test Store key.

Validating CustomerInfo After a Purchase

After each test purchase, verify the entitlement was granted:

Purchases.shared.getCustomerInfo { customerInfo, error in
    guard let info = customerInfo else { return }

    // Check entitlement is active
    let proEntitlement = info.entitlements["pro"]
    print("Pro active: \(proEntitlement?.isActive ?? false)")
    print("Expires: \(proEntitlement?.expirationDate?.description ?? "N/A")")
    print("Product: \(proEntitlement?.productIdentifier ?? "N/A")")
    print("Original App User ID: \(info.originalAppUserId)")
}

9. Go-Live Checklist

Before submitting to App Review:

  • [ ] Switch API keys. Replace any Test Store API key with your production iOS API key. The Test Store key must never reach the App Store. Source
  • [ ] All products attached to entitlements in the RevenueCat dashboard.
  • [ ] Remove Purchases.logLevel = .debug from release builds (or gate it with #if DEBUG).
  • [ ] Restore Purchases button exists somewhere in your UI — Apple requires this. Source
  • [ ] Tested full purchase flow in Apple Sandbox (not just Test Store).
  • [ ] Tested restore flow — purchase, delete app, reinstall, tap Restore.
  • [ ] SKPaymentQueue observer removed (if you're fully cutting over to RevenueCat).
  • [ ] syncPurchases() guarded so it only fires once per user, not on every launch.
  • [ ] In-App Purchase capability enabled in Xcode for your target.

Summary

Migrating from raw StoreKit to RevenueCat removes the three hardest parts of subscription engineering: receipt validation, subscription state modeling, and sandbox reliability. The actual API surface is small — configure, getOfferings, purchase(package:), restorePurchases, and getCustomerInfo cover 90% of what you need. Observer mode means you can ship RevenueCat alongside your existing StoreKit code in the same release, syncing your subscriber base gradually rather than doing a flag-day cutover.

The before/after code in this guide maps directly to what you have today. Start with the dashboard setup, install the SDK, swap the initialization, then replace your product-fetching and purchase calls one at a time. Your test suite will thank you. Your on-call rotation will thank you. Your users who email support will thank you.


Sources