1271 lines
37 KiB
Markdown
1271 lines
37 KiB
Markdown
---
|
||
name: revenuecat-ios-integration
|
||
description: 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](#overview)
|
||
2. [Prerequisites](#prerequisites)
|
||
3. [SDK Installation](#sdk-installation)
|
||
4. [Configuration Architecture](#configuration-architecture)
|
||
- [Option A: Swift Secrets File](#option-a-swift-secrets-file-simple)
|
||
- [Option B: Separate Debug/Release Keys](#option-b-separate-debugrelease-keys-swift-files)
|
||
- [Option C: xcconfig + Info.plist (Recommended)](#option-c-xcconfig--infoplist-recommended-for-xcode-15-projects)
|
||
5. [PremiumManager Implementation](#premiummanager-implementation)
|
||
6. [Paywall Implementation](#paywall-implementation)
|
||
7. [Event Handling](#event-handling)
|
||
8. [App Store Connect Setup](#app-store-connect-setup)
|
||
9. [RevenueCat Dashboard Setup](#revenuecat-dashboard-setup)
|
||
10. [Testing Strategies](#testing-strategies)
|
||
11. [Production Checklist](#production-checklist)
|
||
12. [Troubleshooting](#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)
|
||
|
||
1. In Xcode: **File** → **Add 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)
|
||
|
||
```swift
|
||
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`:
|
||
|
||
```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):
|
||
|
||
```swift
|
||
// 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):
|
||
```swift
|
||
enum Secrets {
|
||
static let revenueCatAPIKey = "test_your_test_key"
|
||
}
|
||
```
|
||
|
||
**`Secrets.release.swift`** (for production):
|
||
```swift
|
||
enum Secrets {
|
||
static let revenueCatAPIKey = "appl_your_production_key"
|
||
}
|
||
```
|
||
|
||
Use build configurations or compiler flags to include the correct file.
|
||
|
||
#### Option C: xcconfig + Info.plist (Recommended for Xcode 15+ Projects)
|
||
|
||
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
|
||
<?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
|
||
|
||
```swift
|
||
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:
|
||
```xml
|
||
<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:
|
||
```bash
|
||
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
|
||
|
||
```swift
|
||
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
|
||
|
||
```swift
|
||
@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.
|
||
|
||
```swift
|
||
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.
|
||
|
||
```swift
|
||
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.
|
||
|
||
```swift
|
||
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:
|
||
|
||
```swift
|
||
.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
|
||
|
||
```swift
|
||
.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:
|
||
|
||
```swift
|
||
@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:
|
||
|
||
```swift
|
||
.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:
|
||
|
||
```swift
|
||
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:
|
||
|
||
```swift
|
||
@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](https://appstoreconnect.apple.com)
|
||
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](https://app.revenuecat.com)
|
||
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 Settings** → **Apps** → 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:
|
||
|
||
```swift
|
||
#if DEBUG
|
||
Toggle("Debug Premium", isOn: $premiumManager.isDebugPremiumToggleEnabled)
|
||
#endif
|
||
```
|
||
|
||
### 2. StoreKit Configuration File (Local)
|
||
|
||
1. **File** → **New** → **StoreKit Configuration File**
|
||
2. Add products matching your IDs
|
||
3. **Edit Scheme** → **Run** → **Options** → 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](#option-c-xcconfig--infoplist-recommended-for-xcode-15-projects) 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:
|
||
|
||
```bash
|
||
# 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
|
||
|
||
- [RevenueCat Documentation](https://docs.revenuecat.com)
|
||
- [RevenueCat iOS SDK Reference](https://sdk.revenuecat.com/ios/index.html)
|
||
- [App Store Connect Help](https://help.apple.com/app-store-connect/)
|
||
- [StoreKit Documentation](https://developer.apple.com/documentation/storekit)
|