RevenueCat
RevenueCat ↗ in-app purchases and subscriptions for Skip apps on both iOS and Android.
The framework contains two modules:
- SkipRevenue — Core service for configuring RevenueCat, loading offerings, purchasing packages, restoring purchases, and managing customer info.
- SkipRevenueUI — A
RCFusePaywallViewSwiftUI component that presents RevenueCat’s native paywall UI on both platforms.
Add the dependency to your Package.swift file:
let package = Package( name: "my-package", products: [ .library(name: "MyProduct", targets: ["MyTarget"]), ], dependencies: [ .package(url: "https://source.skip.dev/skip-revenue.git", "0.0.0"..<"2.0.0"), ], targets: [ .target(name: "MyTarget", dependencies: [ .product(name: "SkipRevenue", package: "skip-revenue"), .product(name: "SkipRevenueUI", package: "skip-revenue") ]) ])If you only need the core purchase APIs without the paywall UI, you can depend on SkipRevenue alone.
RevenueCat Account Setup
Section titled “RevenueCat Account Setup”Before using this framework you must create a RevenueCat ↗ account and configure your project with API keys for both platforms. See the RevenueCat Getting Started ↗ guide and Configuring Products ↗ documentation for details on setting up your products, offerings, and entitlements in the RevenueCat dashboard.
Configuration
Section titled “Configuration”Configure the RevenueCat SDK early in your app’s lifecycle, typically in your App init or onAppear:
import SkipRevenue
@main struct MyApp: App { init() { #if !SKIP RevenueCatFuse.shared.configure(apiKey: "appl_your_ios_api_key") #else RevenueCatFuse.shared.configure(apiKey: "goog_your_android_api_key") #endif }
var body: some Scene { WindowGroup { ContentView() } }}You can also configure with a known user ID:
RevenueCatFuse.shared.configure(apiKey: "appl_your_ios_api_key", appUserID: "user-123")See the RevenueCat API Keys ↗ documentation for information on obtaining your platform-specific API keys.
User Identification
Section titled “User Identification”Identify users with their own user IDs, or let RevenueCat generate anonymous IDs:
// Log in with a known user IDtry await RevenueCatFuse.shared.loginUser(userId: "user-123")
// Log out when the user signs outtry await RevenueCatFuse.shared.logoutUser()
// Check current user statelet userId = RevenueCatFuse.shared.appUserIDlet isAnonymous = RevenueCatFuse.shared.isAnonymousSee Identifying Users ↗ for more on user identification strategies.
Loading Offerings and Products
Section titled “Loading Offerings and Products”Offerings represent the products you’ve configured in the RevenueCat dashboard:
import SwiftUIimport SkipRevenue
struct StoreView: View { @State var packages: [RCFusePackage] = [] @State var errorMessage: String?
var body: some View { List(packages, id: \.identifier) { package in HStack { VStack(alignment: .leading) { Text(package.storeProduct.localizedTitle) Text(package.storeProduct.localizedDescription) .font(.caption) .foregroundStyle(.secondary) } Spacer() Text(package.storeProduct.localizedPriceString) .bold() } } .task { do { packages = try await RevenueCatFuse.shared.loadProducts() } catch { errorMessage = error.localizedDescription } } }}You can also load the full offerings tree to access specific offerings by identifier:
let offerings = try await RevenueCatFuse.shared.loadOfferings()
// Access the default offeringif let current = offerings.current { let packages = current.availablePackages let description = current.serverDescription
// Access convenience package slots if let monthly = current.monthly { print("Monthly: \(monthly.storeProduct.localizedPriceString)") } if let annual = current.annual { print("Annual: \(annual.storeProduct.localizedPriceString)") }}
// Access a specific offeringif let premium = offerings.offering(identifier: "premium") { let packages = premium.availablePackages}
// Look up a specific package within an offeringif let offering = offerings.current { if let pkg = offering.package(identifier: "$rc_monthly") { print("Found package: \(pkg.identifier)") }}See Displaying Products ↗ for more on configuring and presenting offerings.
Package Types
Section titled “Package Types”Each package has a packageType that indicates its duration:
for package in offering.availablePackages { switch package.packageType { case .annual: print("Annual") case .monthly: print("Monthly") case .weekly: print("Weekly") case .lifetime: print("Lifetime") case .sixMonth: print("6 Month") case .threeMonth: print("3 Month") case .twoMonth: print("2 Month") case .custom: print("Custom: \(package.identifier)") case .unknown: print("Unknown") }}Store Product Details
Section titled “Store Product Details”Access product pricing and metadata:
let product = package.storeProduct
print("ID: \(product.productIdentifier)")print("Title: \(product.localizedTitle)")print("Description: \(product.localizedDescription)")print("Price: \(product.localizedPriceString)") // e.g., "$9.99"print("Price value: \(product.price)") // e.g., 9.99print("Currency: \(product.currencyCode ?? "")") // e.g., "USD"
// Introductory offer (iOS only)if let introPrice = product.localizedIntroductoryPriceString { print("Intro price: \(introPrice)")}Purchasing
Section titled “Purchasing”The purchase API differs slightly between platforms because Android requires an Activity reference:
// iOS#if !SKIPlet customerInfo = try await RevenueCatFuse.shared.purchase(package: package)#else// Android — pass the current activitylet customerInfo = try await RevenueCatFuse.shared.purchase( package: package, activity: ProcessInfo.processInfo.androidContext)#endifHandle purchase errors, including user cancellation:
do { #if !SKIP let customerInfo = try await RevenueCatFuse.shared.purchase(package: package) #else let customerInfo = try await RevenueCatFuse.shared.purchase( package: package, activity: ProcessInfo.processInfo.androidContext ) #endif // Purchase succeeded — check entitlements if customerInfo.isEntitlementActive("pro") { // Unlock pro features }} catch let error as StoreError where error == .userCancelled { // User cancelled — no action needed} catch { // Handle other errors}See Making Purchases ↗ for more details.
Restoring Purchases
Section titled “Restoring Purchases”Allow users to restore purchases on a new device or after reinstalling:
let customerInfo = try await RevenueCatFuse.shared.restorePurchases()let activeEntitlements = customerInfo.activeEntitlementsSee Restoring Purchases ↗ for platform-specific considerations.
Checking Customer Info
Section titled “Checking Customer Info”Query the current customer’s entitlement status at any time:
let customerInfo = try await RevenueCatFuse.shared.getCustomerInfo()
// Check a specific entitlementif customerInfo.isEntitlementActive("pro") { // User has the "pro" entitlement}
// Check if the user has any active entitlementsif customerInfo.hasActiveEntitlements { // User is a subscriber}
// Get all active entitlement identifierslet activeEntitlements = customerInfo.activeEntitlements
// Get all purchased product identifierslet purchasedProducts = customerInfo.allPurchasedProductIdentifiers
// Check expiration datesif let expiration = customerInfo.expirationDate(forEntitlement: "pro") { print("Pro expires: \(expiration)")}
// Check purchase datesif let purchased = customerInfo.purchaseDate(forEntitlement: "pro") { print("Pro purchased: \(purchased)")}
// First seen dateprint("First seen: \(customerInfo.firstSeen)")
// Latest expiration across all entitlementsif let latestExpiration = customerInfo.latestExpirationDate { print("Latest expiration: \(latestExpiration)")}See Customer Info ↗ for more on working with customer data and Entitlements ↗ for configuring access levels.
Subscriber Attributes
Section titled “Subscriber Attributes”Set custom attributes for analytics, integrations, and customer segmentation:
RevenueCatFuse.shared.setAttributes([ "source": "onboarding", "plan_interest": "premium"])
RevenueCatFuse.shared.setEmail("user@example.com")RevenueCatFuse.shared.setDisplayName("Jane Doe")Paywall UI
Section titled “Paywall UI”The SkipRevenueUI module provides RCFusePaywallView, which presents RevenueCat’s native paywall UI. On iOS this uses PaywallView from RevenueCatUI, and on Android it uses the RevenueCat Compose Paywall component.
import SwiftUIimport SkipRevenueimport SkipRevenueUI
struct SubscriptionView: View { @State var showPaywall = false
var body: some View { Button("Subscribe") { showPaywall = true } .sheet(isPresented: $showPaywall) { RCFusePaywallView( onPurchaseCompleted: { userId in // Purchase succeeded — userId is the customer's user ID showPaywall = false }, onRestoreCompleted: { userId in // Restore succeeded showPaywall = false }, onDismiss: { showPaywall = false } ) } }}To display a specific offering in the paywall, pass an RCFuseOffering:
let offerings = try await RevenueCatFuse.shared.loadOfferings()if let premium = offerings.offering(identifier: "premium") { RCFusePaywallView( offering: premium, onPurchaseCompleted: { userId in }, onDismiss: { } )}See the RevenueCat Paywalls ↗ documentation for information on designing and configuring paywall templates in the RevenueCat dashboard.
API Reference
Section titled “API Reference”RevenueCatFuse
Section titled “RevenueCatFuse”The main service singleton for all RevenueCat operations.
| Method / Property | Description |
|---|---|
shared | The singleton instance |
configure(apiKey:) | Initialize with a platform-specific API key |
configure(apiKey:appUserID:) | Initialize with an API key and known user ID |
isConfigured: Bool | Whether the SDK is configured |
appUserID: String | Current app user ID |
isAnonymous: Bool | Whether the current user is anonymous |
loginUser(userId:) async throws | Log in with a user ID |
logoutUser() async throws | Log out to anonymous |
loadOfferings() async throws | Load all offerings |
loadProducts(offeringIdentifier:) async throws | Load packages from an offering |
purchase(package:) async throws | Purchase a package (iOS) |
purchase(package:activity:) async throws | Purchase a package (Android) |
restorePurchases() async throws | Restore prior purchases |
getCustomerInfo() async throws | Get current customer info |
setAttributes(_:) | Set subscriber attributes |
setEmail(_:) | Set user’s email |
setDisplayName(_:) | Set user’s display name |
RCFuseOfferings
Section titled “RCFuseOfferings”| Property / Method | Description |
|---|---|
current: RCFuseOffering? | The current (default) offering |
all: [String: RCFuseOffering] | All offerings keyed by identifier |
offering(identifier:) | Look up an offering by identifier |
RCFuseOffering
Section titled “RCFuseOffering”| Property / Method | Description |
|---|---|
identifier: String | The offering identifier |
serverDescription: String | Description from the RevenueCat dashboard |
availablePackages: [RCFusePackage] | All packages in this offering |
lifetime: RCFusePackage? | Lifetime package, if available |
annual: RCFusePackage? | Annual package, if available |
sixMonth: RCFusePackage? | Six-month package, if available |
threeMonth: RCFusePackage? | Three-month package, if available |
twoMonth: RCFusePackage? | Two-month package, if available |
monthly: RCFusePackage? | Monthly package, if available |
weekly: RCFusePackage? | Weekly package, if available |
package(identifier:) | Look up a package by identifier |
RCFusePackage
Section titled “RCFusePackage”| Property | Description |
|---|---|
identifier: String | The package identifier |
packageType: RCFusePackageType | The package type (.monthly, .annual, etc.) |
storeProduct: RCFuseStoreProduct | The underlying store product |
localizedPeriodString: String? | Human-readable subscription period |
RCFuseStoreProduct
Section titled “RCFuseStoreProduct”| Property | Description |
|---|---|
productIdentifier: String | The store product identifier |
localizedTitle: String | The product name |
localizedDescription: String | The product description |
localizedPriceString: String | Formatted price (e.g., “$9.99”) |
price: Double | Numeric price value |
currencyCode: String? | Currency code (e.g., “USD”) |
localizedIntroductoryPriceString: String? | Intro offer price (iOS only) |
RCFuseCustomerInfo
Section titled “RCFuseCustomerInfo”| Property / Method | Description |
|---|---|
originalAppUserId: String | The original app user ID |
activeEntitlements: Set<String> | Currently active entitlement identifiers |
allPurchasedProductIdentifiers: Set<String> | All purchased product identifiers |
hasActiveEntitlements: Bool | Whether the user has any active entitlements |
isEntitlementActive(_:) -> Bool | Check if a specific entitlement is active |
firstSeen: Date | When the user was first seen |
latestExpirationDate: Date? | Latest expiration across all entitlements |
expirationDate(forEntitlement:) -> Date? | Expiration date for a specific entitlement |
purchaseDate(forEntitlement:) -> Date? | Purchase date for a specific entitlement |
RCFusePackageType
Section titled “RCFusePackageType”| Case | Description |
|---|---|
.lifetime | Lifetime (non-recurring) |
.annual | Annual subscription |
.sixMonth | Six-month subscription |
.threeMonth | Three-month subscription |
.twoMonth | Two-month subscription |
.monthly | Monthly subscription |
.weekly | Weekly subscription |
.custom | Custom package |
.unknown | Unknown type |
StoreError
Section titled “StoreError”| Case | Description |
|---|---|
.userCancelled | User cancelled the purchase |
.unknown | Unknown error |
.noPurchasesFound | No purchases found to restore |
.noProductsAvailable | No products available in the offering |
.packageNotFound | Package not found |
.notConfigured | RevenueCat is not configured |
RCFusePaywallView
Section titled “RCFusePaywallView”| Parameter | Description |
|---|---|
offering: RCFuseOffering? | Optional specific offering to display |
onPurchaseCompleted: ((String) -> Void)? | Callback with user ID after purchase |
onRestoreCompleted: ((String) -> Void)? | Callback with user ID after restore |
onDismiss: (() -> Void)? | Called when user dismisses the paywall |
Building
Section titled “Building”This project is a Swift Package Manager module that uses the Skip plugin to build the package for both iOS and Android.
Testing
Section titled “Testing”The module can be tested using the standard swift test command
or by running the test target for the macOS destination in Xcode,
which will run the Swift tests as well as the transpiled
Kotlin JUnit tests in the Robolectric Android simulation environment.
Parity testing can be performed with skip test,
which will output a table of the test results for both platforms.
Contributing
Section titled “Contributing”We welcome contributions to SkipRevenue. The Skip product documentation includes helpful instructions and tips on local Skip library development.