Adding subscriptions to an iOS app can be complex. RevenueCat simplifies the process by handling receipt validation, subscription status, and cross-platform support. Here's how we implemented RevenueCat in Skyscraper for iOS.
What is RevenueCat?
RevenueCat provides a backend and SDK wrapper around StoreKit and Google Play Billing. Instead of building your own server infrastructure for receipt validation and subscription management, RevenueCat handles it all.
Key benefits:
- Server-side receipt validation – No need to build your own backend
- Cross-platform support – iOS, Android, and web
- Real-time subscription status – Webhook and SDK updates
- Analytics dashboard – Track revenue, churn, and conversions
- StoreKit 2 support – Modern Apple APIs
Installation
Install RevenueCat via Swift Package Manager:
- In Xcode, select File → Add Packages...
- Enter the repository URL:
https://github.com/RevenueCat/purchases-ios.git - Set Dependency Rule to "Up to next major" with version
4.0.0 < 5.0.0 - Select RevenueCat when prompted
- Enable In-App Purchase capability in Project Settings → Capabilities
Creating a RevenueCat Manager
We use a singleton manager class to handle all RevenueCat interactions. Here's the structure we use in Skyscraper:
import RevenueCat
import Combine
// Subscription status enum
enum SubscriptionStatus: Equatable {
case unknown
case notSubscribed
case subscribed(tier: SubscriptionTier, expirationDate: Date?)
case expired(tier: SubscriptionTier, expirationDate: Date?)
var isActive: Bool {
if case .subscribed = self { return true }
return false
}
}
// Subscription tiers
enum SubscriptionTier: String, CaseIterable {
case monthly = "monthly"
case yearly = "yearly"
var productIdentifier: String {
switch self {
case .monthly: return "monthly"
case .yearly: return "yearly"
}
}
}
Manager Class Implementation
The manager handles configuration, customer info, offerings, and purchases:
@MainActor
class RevenueCatManager: NSObject, ObservableObject {
static let shared = RevenueCatManager()
private static let apiKey = "your_api_key_here"
static let proEntitlementID = "Your Pro Entitlement"
@Published private(set) var subscriptionStatus: SubscriptionStatus = .unknown
@Published private(set) var isProUser: Bool = false
@Published private(set) var customerInfo: CustomerInfo?
@Published private(set) var offerings: Offerings?
@Published private(set) var currentOffering: Offering?
@Published private(set) var isPurchasing: Bool = false
private override init() {
super.init()
}
}
Configuration at App Launch
Configure RevenueCat once at app launch:
func configure() {
#if DEBUG
Purchases.logLevel = .debug
#else
Purchases.logLevel = .info
#endif
// Configure with API key
Purchases.configure(withAPIKey: Self.apiKey)
// Set delegate for updates
Purchases.shared.delegate = self
// Fetch initial data
Task {
await refreshCustomerInfo()
await fetchOfferings()
}
}
In your App struct or AppDelegate:
@main
struct MyApp: App {
init() {
RevenueCatManager.shared.configure()
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(RevenueCatManager.shared)
}
}
}
Fetching Offerings
Offerings contain the packages (subscription options) you've configured in the RevenueCat dashboard:
func fetchOfferings() async {
do {
let offerings = try await Purchases.shared.offerings()
self.offerings = offerings
self.currentOffering = offerings.current
if let current = offerings.current {
print("Fetched \(current.availablePackages.count) packages")
}
} catch {
print("Failed to fetch offerings: \(error)")
}
}
func getPackage(for tier: SubscriptionTier) -> Package? {
guard let offering = currentOffering else { return nil }
switch tier {
case .monthly:
return offering.monthly ?? offering.package(identifier: "monthly")
case .yearly:
return offering.annual ?? offering.package(identifier: "yearly")
}
}
Handling Purchases
Process purchases and update subscription status:
func purchase(package: Package) async -> Bool {
isPurchasing = true
do {
let result = try await Purchases.shared.purchase(package: package)
if !result.userCancelled {
updateSubscriptionStatus(from: result.customerInfo)
isPurchasing = false
return true
} else {
isPurchasing = false
return false
}
} catch {
print("Purchase failed: \(error)")
isPurchasing = false
return false
}
}
func purchase(tier: SubscriptionTier) async -> Bool {
guard let package = getPackage(for: tier) else {
return false
}
return await purchase(package: package)
}
Checking Entitlements
RevenueCat uses entitlements to grant access to features:
private func updateSubscriptionStatus(from customerInfo: CustomerInfo) {
self.customerInfo = customerInfo
// Check for pro entitlement
if let entitlement = customerInfo.entitlements[Self.proEntitlementID],
entitlement.isActive {
isProUser = true
let tier = determineTier(from: entitlement.productIdentifier)
subscriptionStatus = .subscribed(
tier: tier,
expirationDate: entitlement.expirationDate
)
} else {
isProUser = false
subscriptionStatus = .notSubscribed
}
}
func hasProAccess() -> Bool {
return customerInfo?.entitlements[Self.proEntitlementID]?.isActive == true
}
Restore Purchases
Always provide a way for users to restore previous purchases:
func restorePurchases() async -> Bool {
isPurchasing = true
do {
let customerInfo = try await Purchases.shared.restorePurchases()
updateSubscriptionStatus(from: customerInfo)
let hasActive = customerInfo.entitlements[Self.proEntitlementID]?.isActive == true
isPurchasing = false
return hasActive
} catch {
print("Restore failed: \(error)")
isPurchasing = false
return false
}
}
Listening for Updates
Subscribe to real-time customer info updates:
// Using PurchasesDelegate
extension RevenueCatManager: PurchasesDelegate {
nonisolated func purchases(
_ purchases: Purchases,
receivedUpdated customerInfo: CustomerInfo
) {
Task { @MainActor in
self.updateSubscriptionStatus(from: customerInfo)
}
}
}
// Or using AsyncSequence
func startListeningForUpdates() {
Task {
for await customerInfo in Purchases.shared.customerInfoStream {
await MainActor.run {
self.updateSubscriptionStatus(from: customerInfo)
}
}
}
}
Package Helper Extensions
Useful extensions for displaying package information:
extension Package {
var formattedPrice: String {
return storeProduct.localizedPriceString
}
var formattedMonthlyPrice: String? {
guard let period = storeProduct.subscriptionPeriod,
period.unit == .year else { return nil }
let monthly = storeProduct.price as Decimal / 12
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = storeProduct.priceFormatter?.locale ?? .current
return formatter.string(from: monthly as NSDecimalNumber)
}
var durationDescription: String {
guard let period = storeProduct.subscriptionPeriod else {
return "One-time"
}
switch period.unit {
case .month: return period.value == 1 ? "Monthly" : "\(period.value) Months"
case .year: return period.value == 1 ? "Yearly" : "\(period.value) Years"
default: return "Unknown"
}
}
}
Best Practices
- Configure once: Only configure RevenueCat once at app launch
- Use entitlements: Gate features on entitlements, not product IDs
- Handle errors: Always catch and display purchase errors
- Enable server notifications: Set up App Store Server Notifications for real-time updates
- Test thoroughly: Use sandbox testing before going live
- Provide restore: Always include a restore purchases button
SwiftUI Paywall Example
A simple paywall view using the manager:
struct PaywallView: View {
@EnvironmentObject var revenueCat: RevenueCatManager
var body: some View {
VStack {
if revenueCat.isProUser {
Text("You're a Pro subscriber!")
} else if let offering = revenueCat.currentOffering {
ForEach(offering.availablePackages, id: \.identifier) { package in
Button {
Task {
await revenueCat.purchase(package: package)
}
} label: {
VStack {
Text(package.durationDescription)
Text(package.formattedPrice)
}
}
.disabled(revenueCat.isPurchasing)
}
Button("Restore Purchases") {
Task { await revenueCat.restorePurchases() }
}
}
}
}
}
Resources
RevenueCat makes subscription management straightforward. Focus on building your app while RevenueCat handles the infrastructure.