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
Swift Package Manager (Recommended)
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.configureonce, early in the app lifecycle. Elsewhere in the app, access the singleton viaPurchases.shared. Source
5. Replacing Core StoreKit Calls
5a. Fetching Products: SKProductsRequest → getOfferings
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.add → purchase(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: restoreCompletedTransactions → restorePurchases
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
-
Test Store (Development) — Use your Test Store API key during development. No App Store Connect setup needed. Test purchases work instantly and update
CustomerInfocorrectly. -
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.
-
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 = .debugfrom 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.
- [ ]
SKPaymentQueueobserver 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
- iOS Installation — RevenueCat Docs
- SDK Quickstart — RevenueCat Docs
- Configuring the SDK — RevenueCat Docs
- Displaying Products / Offerings — RevenueCat Docs
- Making Purchases — RevenueCat Docs
- Restoring Purchases — RevenueCat Docs
- Entitlements — RevenueCat Docs
- Getting Subscription Status (CustomerInfo) — RevenueCat Docs
- Using the SDK with Your Own IAP Code (Observer Mode) — RevenueCat Docs
- Importing Historical Purchases — RevenueCat Docs
- Migration Paths — RevenueCat Docs
- Sandbox Testing — RevenueCat Docs
- Debugging — RevenueCat Docs