SelfieCam/docs/REVENUECAT_INTEGRATION_GUIDE.md

37 KiB
Raw Blame History

name description
revenuecat-ios-integration Integrate RevenueCat for in-app purchases in iOS/SwiftUI apps. Covers SDK setup, xcconfig-based API key management, PremiumManager implementation, paywalls, and troubleshooting. Use when setting up RevenueCat, implementing subscriptions, configuring API keys via xcconfig/Info.plist, debugging "API key not configured" errors, or when the user mentions RevenueCat, in-app purchases, subscriptions, paywalls, or premium features.

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

  1. Overview
  2. Prerequisites
  3. SDK Installation
  4. Configuration Architecture
  5. PremiumManager Implementation
  6. Paywall Implementation
  7. Event Handling
  8. App Store Connect Setup
  9. RevenueCat Dashboard Setup
  10. Testing Strategies
  11. Production Checklist
  12. 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

  1. In Xcode: FileAdd Package Dependencies
  2. Enter: https://github.com/RevenueCat/purchases-ios-spm
  3. Select version (use latest stable)
  4. Add both packages to your target:
    • RevenueCat - Core SDK
    • RevenueCatUI - 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 (Swift Files)

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.

This approach is the most robust for modern Xcode projects, especially those using fileSystemSynchronizedGroups (automatic file sync). It keeps secrets in xcconfig files and injects them into Info.plist at build time.

Why use this approach:

  • Secrets stay in gitignored xcconfig files
  • Build settings automatically switch between debug/release keys
  • Works with Xcode's auto-generated Info.plist replacement
  • No Swift code changes needed to switch keys
Step 1: Create xcconfig Structure

Create the following files in YourApp/Configuration/:

Base.xcconfig (committed to git):

// Base.xcconfig - Source of truth for all identifiers

COMPANY_IDENTIFIER = com.yourcompany
BUNDLE_ID_NAME = YourApp
PRODUCT_NAME = Your App
DEVELOPMENT_TEAM = YOUR_TEAM_ID

APP_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)

Debug.xcconfig (committed to git):

// Debug.xcconfig
#include "Base.xcconfig"
#include "Secrets.debug.xcconfig"

Release.xcconfig (committed to git):

// Release.xcconfig
#include "Base.xcconfig"
#include "Secrets.release.xcconfig"

Secrets.debug.xcconfig (gitignored):

// Secrets.debug.xcconfig
// ⚠️ DO NOT COMMIT THIS FILE

// RevenueCat Test API Key (starts with "test_")
REVENUECAT_API_KEY = test_your_test_key_here

Secrets.release.xcconfig (gitignored):

// Secrets.release.xcconfig
// ⚠️ DO NOT COMMIT THIS FILE

// RevenueCat Production API Key (starts with "appl_")
REVENUECAT_API_KEY = appl_your_production_key_here

Add to .gitignore:

**/Secrets.debug.xcconfig
**/Secrets.release.xcconfig
Step 2: Create Info.plist at Project Root

CRITICAL: For projects using fileSystemSynchronizedGroups, the Info.plist MUST be placed at the project root (same level as the .xcodeproj), NOT inside the synced app folder. Otherwise Xcode will automatically add it to Copy Bundle Resources and cause build failures.

Create Info.plist at the project root (e.g., YourApp/Info.plist, NOT YourApp/YourApp/Info.plist):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleDevelopmentRegion</key>
    <string>$(DEVELOPMENT_LANGUAGE)</string>
    <key>CFBundleExecutable</key>
    <string>$(EXECUTABLE_NAME)</string>
    <key>CFBundleIdentifier</key>
    <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
    <key>CFBundleInfoDictionaryVersion</key>
    <string>6.0</string>
    <key>CFBundleName</key>
    <string>$(PRODUCT_NAME)</string>
    <key>CFBundlePackageType</key>
    <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
    <key>CFBundleShortVersionString</key>
    <string>$(MARKETING_VERSION)</string>
    <key>CFBundleVersion</key>
    <string>$(CURRENT_PROJECT_VERSION)</string>
    <key>CFBundleDisplayName</key>
    <string>$(PRODUCT_NAME)</string>
    <key>LSRequiresIPhoneOS</key>
    <true/>
    <key>UILaunchStoryboardName</key>
    <string>LaunchScreen</string>
    <key>UIApplicationSceneManifest</key>
    <dict>
        <key>UIApplicationSupportsMultipleScenes</key>
        <true/>
        <key>UISceneConfigurations</key>
        <dict/>
    </dict>
    <key>UIApplicationSupportsIndirectInputEvents</key>
    <true/>
    <key>UISupportedInterfaceOrientations</key>
    <array>
        <string>UIInterfaceOrientationPortrait</string>
        <string>UIInterfaceOrientationLandscapeLeft</string>
        <string>UIInterfaceOrientationLandscapeRight</string>
    </array>
    <key>UISupportedInterfaceOrientations~ipad</key>
    <array>
        <string>UIInterfaceOrientationPortrait</string>
        <string>UIInterfaceOrientationPortraitUpsideDown</string>
        <string>UIInterfaceOrientationLandscapeLeft</string>
        <string>UIInterfaceOrientationLandscapeRight</string>
    </array>
    
    <!-- Add your usage descriptions -->
    <key>NSCameraUsageDescription</key>
    <string>Your camera usage description</string>
    
    <!-- RevenueCat API Key - injected from xcconfig -->
    <key>RevenueCatAPIKey</key>
    <string>$(REVENUECAT_API_KEY)</string>
</dict>
</plist>
Step 3: Configure Xcode Project
  1. Set Build Configurations to use xcconfig files:

    • Project → Info → Configurations
    • Set Debug to use Debug.xcconfig
    • Set Release to use Release.xcconfig
  2. Update Build Settings for the app target:

    • Set GENERATE_INFOPLIST_FILE = NO
    • Set INFOPLIST_FILE = Info.plist (path relative to project root)
Step 4: Update PremiumManager to Read from Info.plist
private static var apiKey: String {
    let key = (Bundle.main.object(forInfoDictionaryKey: "RevenueCatAPIKey") as? String) ?? ""
    let placeholders = [
        "",
        "YOUR_REVENUECAT_API_KEY_HERE",
        "test_YOUR_TEST_KEY_HERE",
        "appl_YOUR_PRODUCTION_KEY_HERE"
    ]
    
    #if DEBUG
    let prefix = key.split(separator: "_").first.map(String.init) ?? "missing"
    let keyStatus = key.isEmpty ? "empty" : "present"
    print(" [PremiumManager] RevenueCatAPIKey \(keyStatus), prefix=\(prefix)")
    #endif
    
    guard !placeholders.contains(key) else {
        #if DEBUG
        print("⚠️ [PremiumManager] RevenueCat API key not configured. Check Secrets.debug.xcconfig / Secrets.release.xcconfig")
        #endif
        return ""
    }
    return key
}
Common Pitfalls

1. INFOPLIST_KEY_ prefix doesn't work for custom keys

The INFOPLIST_KEY_ build setting prefix (e.g., INFOPLIST_KEY_RevenueCatAPIKey) only works for Apple's predefined Info.plist keys. Custom keys like RevenueCatAPIKey will NOT be added to the generated Info.plist.

Does NOT work:

// In xcconfig - custom keys are ignored
INFOPLIST_KEY_RevenueCatAPIKey = $(REVENUECAT_API_KEY)

Works: Use a custom Info.plist with build setting substitution:

<key>RevenueCatAPIKey</key>
<string>$(REVENUECAT_API_KEY)</string>

2. Info.plist in synced folder causes "Multiple commands produce Info.plist"

If your project uses fileSystemSynchronizedGroups (Xcode 15+ default for new projects), placing Info.plist inside the app folder causes Xcode to automatically add it to Copy Bundle Resources, resulting in a build error.

Causes error: YourApp/YourApp/Info.plist

Correct location: YourApp/Info.plist (at project root, outside the synced folder)

3. Wrong xcconfig include path

The #include path in xcconfig files is relative to the xcconfig file itself.

Wrong (if files are in same directory):

#include "YourApp/Configuration/Secrets.debug.xcconfig"

Correct:

#include "Secrets.debug.xcconfig"

4. Verify the key is in the built app

After building, verify the key was properly substituted:

plutil -p ~/Library/Developer/Xcode/DerivedData/YourApp-*/Build/Products/Debug-iphonesimulator/Your\ App.app/Info.plist | grep RevenueCat

Expected output:

"RevenueCatAPIKey" => "test_your_actual_key"

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 onDismiss to 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 affects the in-app purchase flow (Use Case 2), not onboarding (Use Case 1):

Use Case Affected? Why
Onboarding No Success callback explicitly completes onboarding and transitions to main app
In-App (Settings) Yes UI must detect the change to show unlocked features

Why In-App Purchases Don't Update the UI

  1. User opens Settings (which has a SettingsViewModel with its own PremiumManager)
  2. User taps a premium feature, triggering the paywall
  3. User completes purchase successfully
  4. Paywall dismisses, returning to Settings
  5. isPremiumUnlocked still returns false in the UI even though purchase succeeded

Technical reason:

  • isPremiumUnlocked reads from Purchases.shared.cachedCustomerInfo
  • The cached customer info IS updated after purchase
  • But SwiftUI doesn't re-render because no @Observable property 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

Always call refreshPremiumStatus() when the paywall sheet dismisses:

.sheet(isPresented: $showPaywall, onDismiss: {
    // Force UI to re-check premium status after paywall closes
    // This handles both successful purchases and user cancellations
    settings.refreshPremiumStatus()
}) {
    PaywallPresenter()
}

Why use onDismiss instead of the success callback?

  • onDismiss is called for all dismissal reasons (purchase, cancel, swipe down)
  • It ensures the UI always reflects the current state
  • Works for both onboarding and in-app contexts

Complete Integration Example

Handling both onboarding and in-app purchase flows:

struct RootView: View {
    @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
    @State private var settings = SettingsViewModel()
    @State private var showPaywall = false
    
    var body: some View {
        Group {
            if hasCompletedOnboarding {
                MainAppView(settings: settings, showPaywall: $showPaywall)
            } else {
                OnboardingView(showPaywall: $showPaywall, onComplete: completeOnboarding)
            }
        }
        .sheet(isPresented: $showPaywall, onDismiss: {
            // ALWAYS refresh after paywall closes (for in-app purchases)
            settings.refreshPremiumStatus()
        }) {
            PaywallPresenter {
                // Only for onboarding: complete it on successful purchase
                if !hasCompletedOnboarding {
                    completeOnboarding()
                }
            }
        }
    }
    
    private func completeOnboarding() {
        withAnimation {
            hasCompletedOnboarding = true
        }
    }
}

Alternative: Shared PremiumManager via Environment

Instead of each view model having its own PremiumManager, use a single shared instance:

@main
struct YourApp: App {
    @State private var premiumManager = PremiumManager()
    
    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(premiumManager)
                .task {
                    await premiumManager.checkSubscriptionStatus()
                    premiumManager.startListeningForCustomerInfoUpdates()
                }
        }
    }
}

// In any view:
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.

Testing Checklist

Test both purchase flows to ensure they work correctly:

Onboarding Flow:

  • Skip to paywall step in onboarding
  • Complete a purchase
  • Verify: App transitions to main content immediately
  • Verify: Premium features are unlocked

In-App Purchase Flow:

  • Complete onboarding WITHOUT purchasing (tap "Maybe Later")
  • Open Settings
  • Tap a premium feature to trigger paywall
  • Complete a purchase
  • Verify: Paywall dismisses
  • Verify: Settings shows premium features as unlocked
  • Verify: No need to restart app or reopen Settings

Restore Purchases:

  • Test restore from onboarding soft paywall
  • Test restore from in-app paywall
  • Verify: UI updates correctly after restore

App Store Connect Setup

1. Create Your App

  1. Go to App Store Connect
  2. My Apps+New App
  3. Fill in bundle ID (must match Xcode exactly)

2. Create Subscription Group

  1. In your app → Subscriptions
  2. Click + next to "Subscription Groups"
  3. 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

  1. Agreements, Tax, and Banking → Accept Paid Applications
  2. Add bank account and tax information

RevenueCat Dashboard Setup

1. Create Project and App

  1. Create project at RevenueCat
  2. Add iOS app with your bundle ID

2. Connect to App Store

  1. Get App-Specific Shared Secret from App Store Connect
  2. Add to RevenueCat: Project SettingsApps → your app

3. Create Products

Add each product with IDs matching App Store Connect exactly.

4. Create Entitlement

  1. Entitlements+ New
  2. Name: pro (or your identifier)
  3. Attach all products that grant this entitlement

5. Create Offering

  1. Offerings → Edit default
  2. Add packages:
    • $rc_monthly → monthly product
    • $rc_annual → yearly product
    • $rc_lifetime → lifetime product

6. Design Paywall (Optional)

  1. In your offering, click Add Paywall
  2. Use the visual editor to design
  3. 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)

  1. FileNewStoreKit Configuration File
  2. Add products matching your IDs
  3. Edit SchemeRunOptions → Set StoreKit Configuration
  4. 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)

  1. Create Sandbox Tester in App Store Connect
  2. Sign out of App Store on device
  3. Make purchase, sign in with sandbox credentials
  4. 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

  1. Verify product IDs match exactly (case-sensitive)
  2. Check API key is correct
  3. Ensure shared secret is configured
  4. 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

  1. Sign out of regular App Store
  2. Use sandbox credentials (not real Apple ID)
  3. Check Paid Apps agreement is accepted
  4. Ensure products have prices set

Entitlements Not Granting Access

  1. Verify entitlement identifier matches code exactly
  2. Check products are attached to entitlement
  3. Confirm packages are in the offering

RevenueCat Dashboard Empty

  1. Using test key shows in sandbox environment
  2. Check bundle ID matches exactly
  3. Wait 1-2 minutes for transactions to appear

API Key Shows in Build Settings But Not in App (xcconfig)

Symptom: xcodebuild -showBuildSettings shows REVENUECAT_API_KEY = test_... but the app logs show "RevenueCatAPIKey empty".

Cause: The INFOPLIST_KEY_ prefix only works for Apple's predefined keys, not custom keys.

Solution:

  1. Create a custom Info.plist file at the project root (NOT inside the synced app folder)
  2. Add the key with build setting substitution: <string>$(REVENUECAT_API_KEY)</string>
  3. Set GENERATE_INFOPLIST_FILE = NO in build settings
  4. Set INFOPLIST_FILE = Info.plist

See Option C: xcconfig + Info.plist for complete instructions.

"Multiple commands produce Info.plist" Build Error

Cause: The Info.plist file is inside a fileSystemSynchronizedGroups folder (Xcode 15+ default), causing Xcode to automatically add it to Copy Bundle Resources.

Solution: Move Info.plist to the project root directory (same level as .xcodeproj), NOT inside the app source folder.

YourProject/
├── YourProject.xcodeproj
├── Info.plist              ← Correct location
└── YourProject/
    └── (source files)      ← NOT here

xcconfig Include Path Errors

Symptom: Build settings show empty or placeholder values even though the Secrets xcconfig files exist.

Cause: The #include path is relative to the xcconfig file itself, not the project root.

Fix: If Debug.xcconfig and Secrets.debug.xcconfig are in the same directory:

// Wrong
#include "YourApp/Configuration/Secrets.debug.xcconfig"

// Correct
#include "Secrets.debug.xcconfig"

Verifying xcconfig Setup

Run these commands to debug your xcconfig setup:

# Check if REVENUECAT_API_KEY is in build settings
xcodebuild -showBuildSettings -scheme "YourScheme" -configuration Debug | grep REVENUECAT

# Check if key is in the built Info.plist
plutil -p ~/Library/Developer/Xcode/DerivedData/YourApp-*/Build/Products/Debug-iphonesimulator/Your\ App.app/Info.plist | grep -i revenue

# Clean and rebuild after xcconfig changes
rm -rf ~/Library/Developer/Xcode/DerivedData/YourApp-*
xcodebuild clean build -scheme "YourScheme" -configuration Debug -destination "platform=iOS Simulator,name=iPhone 16 Pro"

Resources