25 KiB
RevenueCat Integration Guide for iOS
A comprehensive, reusable guide for integrating RevenueCat into iOS apps with SwiftUI. This guide covers the complete flow from SDK setup to production deployment.
Table of Contents
- Overview
- Prerequisites
- SDK Installation
- Configuration Architecture
- PremiumManager Implementation
- Paywall Implementation
- Event Handling
- App Store Connect Setup
- RevenueCat Dashboard Setup
- Testing Strategies
- Production Checklist
- Troubleshooting
Overview
RevenueCat simplifies in-app purchases by:
- Handling receipt validation server-side
- Providing cross-platform subscription status
- Offering analytics (MRR, churn, LTV)
- Managing entitlements across multiple products
- Providing pre-built UI components (Paywalls, Customer Center)
Key Concepts
| Concept | Description |
|---|---|
| Product | An individual purchasable item (monthly, yearly, lifetime) |
| Entitlement | A feature or access level granted by one or more products |
| Offering | A collection of packages shown in a paywall |
| Package | A product wrapped with metadata for display |
| Customer Info | User's current subscription/purchase state |
Prerequisites
- Apple Developer Program membership ($99/year)
- RevenueCat account (free up to $2,500/month revenue)
- Xcode 15.0+
- iOS 15.0+ deployment target
- Swift 5.9+
SDK Installation
Swift Package Manager (Recommended)
- In Xcode: File → Add Package Dependencies
- Enter:
https://github.com/RevenueCat/purchases-ios-spm - Select version (use latest stable)
- Add both packages to your target:
RevenueCat- Core SDKRevenueCatUI- Paywalls and Customer Center
Package.swift (for packages)
dependencies: [
.package(url: "https://github.com/RevenueCat/purchases-ios-spm", from: "5.0.0")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "RevenueCat", package: "purchases-ios-spm"),
.product(name: "RevenueCatUI", package: "purchases-ios-spm")
]
)
]
Configuration Architecture
Secrets Management
Create a secure configuration system that separates API keys from source code.
Option A: Swift Secrets File (Simple)
Create Configuration/Secrets.swift:
// Secrets.swift
// DO NOT COMMIT THIS FILE TO VERSION CONTROL
import Foundation
enum Secrets {
/// RevenueCat API Key
/// - Debug: Use test/sandbox key (starts with "test_")
/// - Release: Use production key (starts with "appl_")
static let revenueCatAPIKey = "YOUR_API_KEY_HERE"
}
Create Configuration/Secrets.swift.template (committed):
// Copy this file to Secrets.swift and add your actual API key
// Secrets.swift is gitignored
import Foundation
enum Secrets {
static let revenueCatAPIKey = "YOUR_REVENUECAT_API_KEY_HERE"
}
Add to .gitignore:
**/Secrets.swift
Option B: Separate Debug/Release Keys (Recommended for Production)
Create two secrets files:
Secrets.debug.swift (for testing):
enum Secrets {
static let revenueCatAPIKey = "test_your_test_key"
}
Secrets.release.swift (for production):
enum Secrets {
static let revenueCatAPIKey = "appl_your_production_key"
}
Use build configurations or compiler flags to include the correct file.
PremiumManager Implementation
Create a centralized manager for all RevenueCat interactions.
Complete Implementation
import RevenueCat
import SwiftUI
@MainActor
@Observable
final class PremiumManager {
// MARK: - Published State
/// Available packages for purchase
var availablePackages: [Package] = []
// MARK: - Configuration
/// Entitlement identifier - must match RevenueCat dashboard
private let entitlementIdentifier = "pro"
/// Task for listening to customer info updates
@ObservationIgnored private var customerInfoTask: Task<Void, Never>?
/// API key from secrets
private static var apiKey: String {
let key = Secrets.revenueCatAPIKey
guard !key.isEmpty, key != "YOUR_REVENUECAT_API_KEY_HERE" else {
#if DEBUG
print("⚠️ [PremiumManager] RevenueCat API key not configured")
#endif
return ""
}
return key
}
// MARK: - Debug Override (Optional)
#if DEBUG
@AppStorage("debugPremiumEnabled")
@ObservationIgnored private var debugPremiumEnabled = false
private var isDebugPremiumEnabled: Bool {
debugPremiumEnabled || ProcessInfo.processInfo.environment["ENABLE_DEBUG_PREMIUM"] == "1"
}
var isDebugPremiumToggleEnabled: Bool {
get { debugPremiumEnabled }
set { debugPremiumEnabled = newValue }
}
#endif
// MARK: - Premium Status
var isPremium: Bool {
#if DEBUG
if isDebugPremiumEnabled { return true }
#endif
guard !Self.apiKey.isEmpty else { return false }
return Purchases.shared.cachedCustomerInfo?
.entitlements[entitlementIdentifier]?.isActive == true
}
/// Alias for compatibility
var isPremiumUnlocked: Bool { isPremium }
// MARK: - Initialization
init() {
guard !Self.apiKey.isEmpty else {
#if DEBUG
print("⚠️ [PremiumManager] Skipping RevenueCat configuration - no API key")
#endif
return
}
#if DEBUG
Purchases.logLevel = .debug
#endif
Purchases.configure(withAPIKey: Self.apiKey)
Task {
try? await loadProducts()
}
}
// MARK: - Products
func loadProducts() async throws {
guard !Self.apiKey.isEmpty else { return }
let offerings = try await Purchases.shared.offerings()
if let current = offerings.current {
availablePackages = current.availablePackages
}
}
// MARK: - Purchase
func purchase(_ package: Package) async throws -> Bool {
#if DEBUG
if isDebugPremiumEnabled {
return true // Simulate success
}
#endif
let result = try await Purchases.shared.purchase(package: package)
return result.customerInfo.entitlements[entitlementIdentifier]?.isActive == true
}
func purchase(productId: String) async throws {
guard let package = availablePackages.first(where: {
$0.storeProduct.productIdentifier == productId
}) else {
throw PurchaseError.productNotFound
}
_ = try await purchase(package)
}
// MARK: - Restore
func restorePurchases() async throws {
#if DEBUG
if isDebugPremiumEnabled { return }
#endif
_ = try await Purchases.shared.restorePurchases()
}
// MARK: - Subscription Status
/// Fetch fresh customer info (call on app launch)
func checkSubscriptionStatus() async {
guard !Self.apiKey.isEmpty else { return }
do {
_ = try await Purchases.shared.customerInfo()
} catch {
#if DEBUG
print("⚠️ [PremiumManager] Failed to fetch customer info: \(error)")
#endif
}
}
// MARK: - Real-time Updates
/// Start listening for subscription changes
func startListeningForCustomerInfoUpdates() {
guard !Self.apiKey.isEmpty else { return }
customerInfoTask?.cancel()
customerInfoTask = Task {
for await customerInfo in Purchases.shared.customerInfoStream {
let isActive = customerInfo.entitlements[entitlementIdentifier]?.isActive == true
#if DEBUG
print("📱 [PremiumManager] Customer info updated. Premium: \(isActive)")
#endif
}
}
}
func stopListeningForCustomerInfoUpdates() {
customerInfoTask?.cancel()
customerInfoTask = nil
}
}
// MARK: - Errors
enum PurchaseError: LocalizedError {
case productNotFound
var errorDescription: String? {
switch self {
case .productNotFound:
return "Product not found"
}
}
}
App Entry Point Integration
@main
struct YourApp: App {
@State private var premiumManager = PremiumManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(premiumManager)
.task {
await premiumManager.checkSubscriptionStatus()
premiumManager.startListeningForCustomerInfoUpdates()
}
}
}
}
Paywall Implementation
PaywallPresenter with Fallback
Use RevenueCat's native PaywallView with a custom fallback for offline scenarios.
import SwiftUI
import RevenueCat
import RevenueCatUI
struct PaywallPresenter: View {
/// Called on successful purchase or restore
var onPurchaseSuccess: (() -> Void)?
@Environment(\.dismiss) private var dismiss
@State private var offering: Offering?
@State private var isLoading = true
@State private var useFallback = false
@State private var errorMessage: String?
@State private var showError = false
init(onPurchaseSuccess: (() -> Void)? = nil) {
self.onPurchaseSuccess = onPurchaseSuccess
}
var body: some View {
Group {
if isLoading {
loadingView
} else if useFallback {
CustomPaywallView(onPurchaseSuccess: onPurchaseSuccess)
} else if let offering {
paywallView(for: offering)
} else {
CustomPaywallView(onPurchaseSuccess: onPurchaseSuccess)
}
}
.task {
await loadOffering()
}
.alert("Purchase Error", isPresented: $showError, presenting: errorMessage) { _ in
Button("OK", role: .cancel) { errorMessage = nil }
} message: { message in
Text(message)
}
}
private var loadingView: some View {
VStack {
ProgressView()
.scaleEffect(1.5)
Text("Loading...")
.padding(.top, 16)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func paywallView(for offering: Offering) -> some View {
PaywallView(offering: offering)
.onPurchaseCompleted { _ in
onPurchaseSuccess?()
dismiss()
}
.onPurchaseCancelled {
// User cancelled - keep paywall open
}
.onPurchaseFailure { error in
errorMessage = error.localizedDescription
showError = true
}
.onRestoreCompleted { customerInfo in
if !customerInfo.entitlements.active.isEmpty {
onPurchaseSuccess?()
dismiss()
}
}
.onRestoreFailure { error in
errorMessage = error.localizedDescription
showError = true
}
}
private func loadOffering() async {
do {
let offerings = try await Purchases.shared.offerings()
if let current = offerings.current {
offering = current
} else {
useFallback = true
}
} catch {
useFallback = true
}
isLoading = false
}
}
Paywall Integration Patterns
There are two primary contexts for presenting paywalls, each requiring different handling.
Use Case 1: Onboarding Flow
During onboarding, a successful purchase should complete the onboarding and transition to the main app.
struct RootView: View {
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@State private var onboardingViewModel = OnboardingViewModel()
@State private var settingsViewModel = SettingsViewModel()
@State private var showPaywall = false
var body: some View {
ZStack {
if hasCompletedOnboarding {
MainAppView()
} else {
OnboardingView(
viewModel: onboardingViewModel,
showPaywall: $showPaywall,
onComplete: {
withAnimation {
hasCompletedOnboarding = true
}
}
)
}
}
.sheet(isPresented: $showPaywall) {
PaywallPresenter {
// Purchase successful during onboarding - complete it
if !hasCompletedOnboarding {
onboardingViewModel.completeOnboarding(settings: settingsViewModel)
withAnimation {
hasCompletedOnboarding = true
}
}
}
}
}
}
Key points for onboarding:
- Pass a success callback that completes onboarding
- Transition directly to main app after successful purchase
- User skipping the paywall continues onboarding normally
Use Case 2: In-App Purchase (Settings, Feature Gates)
When users trigger the paywall from within the app (e.g., tapping a premium feature in Settings), the UI must refresh to reflect the new premium status after purchase.
struct MainContentView: View {
@State private var settings = SettingsViewModel()
@State private var showSettings = false
@State private var showPaywall = false
var body: some View {
// Main app content
YourMainView()
.sheet(isPresented: $showSettings) {
SettingsView(viewModel: settings, showPaywall: $showPaywall)
}
.sheet(isPresented: $showPaywall, onDismiss: {
// CRITICAL: Force UI to refresh premium status after paywall closes
settings.refreshPremiumStatus()
}) {
PaywallPresenter()
}
}
}
Key points for in-app purchases:
- Use
onDismissto trigger a premium status refresh - No success callback needed (UI refreshes via observation)
- Settings dismisses before showing paywall, then user returns to main view
Combined Pattern
For apps with both onboarding and in-app paywalls, handle both cases:
.sheet(isPresented: $showPaywall, onDismiss: {
// Always refresh premium status when paywall closes
settings.refreshPremiumStatus()
}) {
PaywallPresenter {
// Only used during onboarding
if !hasCompletedOnboarding {
completeOnboarding()
}
// For in-app purchases, the onDismiss handles the refresh
}
}
Event Handling
Available RevenueCat PaywallView Callbacks
| Modifier | When Called |
|---|---|
.onPurchaseStarted { package in } |
Purchase initiated for a package |
.onPurchaseCompleted { customerInfo in } |
Purchase successful |
.onPurchaseCancelled { } |
User cancelled purchase |
.onPurchaseFailure { error in } |
Purchase failed |
.onRestoreStarted { } |
Restore initiated |
.onRestoreCompleted { customerInfo in } |
Restore successful |
.onRestoreFailure { error in } |
Restore failed |
.onRequestedDismissal { } |
Paywall requests to close |
Error Handling Best Practices
.onPurchaseFailure { error in
let nsError = error as NSError
// Don't show error for user cancellation
if nsError.code == 2 { // SKError.paymentCancelled
return
}
// Handle specific error types
switch nsError.code {
case 0: // SKError.unknown
errorMessage = "An unknown error occurred. Please try again."
case 1: // SKError.clientInvalid
errorMessage = "You are not authorized to make purchases."
case 3: // SKError.paymentNotAllowed
errorMessage = "Payments are not allowed on this device."
default:
errorMessage = error.localizedDescription
}
showError = true
}
SwiftUI Observation and Premium Status Updates
The Problem
When a user completes a purchase, RevenueCat updates Purchases.shared.cachedCustomerInfo with the new subscription status. However, SwiftUI's @Observable macro doesn't automatically detect changes to this shared singleton.
This causes a common bug: after purchasing from a paywall triggered outside of onboarding (e.g., from Settings), the UI doesn't reflect the new premium status even though the purchase succeeded.
Why It Happens
- Your
SettingsViewModel(or similar) has aPremiumManagerinstance isPremiumUnlockedis computed frompremiumManager.isPremiumUnlockedPremiumManager.isPremiumUnlockedreads fromPurchases.shared.cachedCustomerInfo- After purchase, the cached customer info IS updated
- But SwiftUI doesn't know to re-render because no
@Observableproperty changed
Solution: Refresh Token Pattern
Add a refresh mechanism that forces SwiftUI to re-evaluate the premium status:
@MainActor
@Observable
final class SettingsViewModel {
@ObservationIgnored let premiumManager = PremiumManager()
// Refresh token that forces re-evaluation when incremented
private var premiumRefreshToken: Int = 0
/// Premium status - always reads current value from RevenueCat
var isPremiumUnlocked: Bool {
// Access refresh token to create observation dependency
_ = premiumRefreshToken
return premiumManager.isPremiumUnlocked
}
/// Force SwiftUI to re-evaluate premium status
/// Call this after paywall dismisses
func refreshPremiumStatus() {
premiumRefreshToken += 1
}
}
Triggering the Refresh
Call refreshPremiumStatus() when the paywall sheet dismisses:
struct ContentView: View {
@State private var settings = SettingsViewModel()
@State private var showPaywall = false
var body: some View {
// ... your content ...
.sheet(isPresented: $showPaywall, onDismiss: {
// Force UI to re-check premium status after paywall closes
settings.refreshPremiumStatus()
}) {
PaywallPresenter()
}
}
}
Alternative: Shared PremiumManager
Instead of each view model having its own PremiumManager, you can use a single shared instance passed through the environment:
@main
struct YourApp: App {
@State private var premiumManager = PremiumManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(premiumManager)
.task {
await premiumManager.checkSubscriptionStatus()
premiumManager.startListeningForCustomerInfoUpdates()
}
}
}
}
// In views:
struct SettingsView: View {
@Environment(PremiumManager.self) private var premiumManager
var body: some View {
if premiumManager.isPremiumUnlocked {
// Premium content
}
}
}
This approach ensures all views observe the same instance and receive updates together.
Key Takeaways
- Always refresh after paywall dismisses - Use
onDismisson the sheet - Use a refresh token for computed properties that read from external sources
- Consider a shared instance if premium status is checked in many places
- Test the full flow - Skip onboarding, trigger paywall from Settings, complete purchase, verify UI updates
App Store Connect Setup
1. Create Your App
- Go to App Store Connect
- My Apps → + → New App
- Fill in bundle ID (must match Xcode exactly)
2. Create Subscription Group
- In your app → Subscriptions
- Click + next to "Subscription Groups"
- Name it (e.g., "Pro Access")
3. Create Products
Auto-Renewable Subscriptions (monthly, yearly):
| Field | Example Value |
|---|---|
| Reference Name | Pro Monthly |
| Product ID | com.company.app.pro.monthly |
| Duration | 1 Month |
| Price | $2.99 |
Non-Consumable (lifetime):
| Field | Example Value |
|---|---|
| Reference Name | Pro Lifetime |
| Product ID | com.company.app.pro.lifetime |
| Price | $39.99 |
4. Agreements
- Agreements, Tax, and Banking → Accept Paid Applications
- Add bank account and tax information
RevenueCat Dashboard Setup
1. Create Project and App
- Create project at RevenueCat
- Add iOS app with your bundle ID
2. Connect to App Store
- Get App-Specific Shared Secret from App Store Connect
- Add to RevenueCat: Project Settings → Apps → your app
3. Create Products
Add each product with IDs matching App Store Connect exactly.
4. Create Entitlement
- Entitlements → + New
- Name:
pro(or your identifier) - Attach all products that grant this entitlement
5. Create Offering
- Offerings → Edit default
- Add packages:
$rc_monthly→ monthly product$rc_annual→ yearly product$rc_lifetime→ lifetime product
6. Design Paywall (Optional)
- In your offering, click Add Paywall
- Use the visual editor to design
- Configure colors, text, layout
7. Get API Keys
- Test Key (starts with
test_): For development, free purchases - Production Key (starts with
appl_): For App Store builds
Testing Strategies
1. Debug Premium Toggle (Fastest)
For UI testing without purchase flow:
#if DEBUG
Toggle("Debug Premium", isOn: $premiumManager.isDebugPremiumToggleEnabled)
#endif
2. StoreKit Configuration File (Local)
- File → New → StoreKit Configuration File
- Add products matching your IDs
- Edit Scheme → Run → Options → Set StoreKit Configuration
- Purchases are instant and free
3. RevenueCat Test Mode (Integration)
Use the test API key (test_):
- Purchases are free
- Transactions appear in RevenueCat dashboard
- Restore not available (returns cached info)
4. Sandbox Testing (Full)
- Create Sandbox Tester in App Store Connect
- Sign out of App Store on device
- Make purchase, sign in with sandbox credentials
- Verify in RevenueCat dashboard
Sandbox Renewal Times:
| Real | Sandbox |
|---|---|
| 1 week | 3 min |
| 1 month | 5 min |
| 1 year | 1 hour |
Production Checklist
Code
- PremiumManager configured with production API key
- Secrets file is gitignored
- Error handling for all purchase scenarios
- Loading states during purchases
- Restore purchases functionality
- Real-time subscription status updates
App Store Connect
- App created with correct bundle ID
- All products created with localizations
- Subscription group configured
- Paid Apps agreement accepted
- Bank and tax info submitted
RevenueCat Dashboard
- Production app created with bundle ID
- Shared secret configured
- All products added (IDs match exactly)
- Entitlement created with products attached
- Offering configured with packages
- (Optional) Paywall designed
Testing
- Tested with StoreKit Configuration
- Tested with sandbox account
- Verified transactions in RevenueCat dashboard
- Tested restore purchases
- Tested subscription expiration/renewal
Troubleshooting
Products Not Loading
- Verify product IDs match exactly (case-sensitive)
- Check API key is correct
- Ensure shared secret is configured
- Wait a few minutes after creating products
"Purchases has not been configured"
Purchases.configure(withAPIKey:)must be called before any other SDK methods- Ensure it's called early in app lifecycle (App init or
@main)
Sandbox Purchase Fails
- Sign out of regular App Store
- Use sandbox credentials (not real Apple ID)
- Check Paid Apps agreement is accepted
- Ensure products have prices set
Entitlements Not Granting Access
- Verify entitlement identifier matches code exactly
- Check products are attached to entitlement
- Confirm packages are in the offering
RevenueCat Dashboard Empty
- Using test key shows in sandbox environment
- Check bundle ID matches exactly
- Wait 1-2 minutes for transactions to appear