Skip to content
Skip
3k

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 RCFusePaywallView SwiftUI 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.

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.

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.

Identify users with their own user IDs, or let RevenueCat generate anonymous IDs:

// Log in with a known user ID
try await RevenueCatFuse.shared.loginUser(userId: "user-123")
// Log out when the user signs out
try await RevenueCatFuse.shared.logoutUser()
// Check current user state
let userId = RevenueCatFuse.shared.appUserID
let isAnonymous = RevenueCatFuse.shared.isAnonymous

See Identifying Users for more on user identification strategies.

Offerings represent the products you’ve configured in the RevenueCat dashboard:

import SwiftUI
import 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 offering
if 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 offering
if let premium = offerings.offering(identifier: "premium") {
let packages = premium.availablePackages
}
// Look up a specific package within an offering
if 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.

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")
}
}

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.99
print("Currency: \(product.currencyCode ?? "")") // e.g., "USD"
// Introductory offer (iOS only)
if let introPrice = product.localizedIntroductoryPriceString {
print("Intro price: \(introPrice)")
}

The purchase API differs slightly between platforms because Android requires an Activity reference:

// iOS
#if !SKIP
let customerInfo = try await RevenueCatFuse.shared.purchase(package: package)
#else
// Android — pass the current activity
let customerInfo = try await RevenueCatFuse.shared.purchase(
package: package,
activity: ProcessInfo.processInfo.androidContext
)
#endif

Handle 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.

Allow users to restore purchases on a new device or after reinstalling:

let customerInfo = try await RevenueCatFuse.shared.restorePurchases()
let activeEntitlements = customerInfo.activeEntitlements

See Restoring Purchases for platform-specific considerations.

Query the current customer’s entitlement status at any time:

let customerInfo = try await RevenueCatFuse.shared.getCustomerInfo()
// Check a specific entitlement
if customerInfo.isEntitlementActive("pro") {
// User has the "pro" entitlement
}
// Check if the user has any active entitlements
if customerInfo.hasActiveEntitlements {
// User is a subscriber
}
// Get all active entitlement identifiers
let activeEntitlements = customerInfo.activeEntitlements
// Get all purchased product identifiers
let purchasedProducts = customerInfo.allPurchasedProductIdentifiers
// Check expiration dates
if let expiration = customerInfo.expirationDate(forEntitlement: "pro") {
print("Pro expires: \(expiration)")
}
// Check purchase dates
if let purchased = customerInfo.purchaseDate(forEntitlement: "pro") {
print("Pro purchased: \(purchased)")
}
// First seen date
print("First seen: \(customerInfo.firstSeen)")
// Latest expiration across all entitlements
if let latestExpiration = customerInfo.latestExpirationDate {
print("Latest expiration: \(latestExpiration)")
}

See Customer Info for more on working with customer data and Entitlements for configuring access levels.

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")

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 SwiftUI
import SkipRevenue
import 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.

The main service singleton for all RevenueCat operations.

Method / PropertyDescription
sharedThe singleton instance
configure(apiKey:)Initialize with a platform-specific API key
configure(apiKey:appUserID:)Initialize with an API key and known user ID
isConfigured: BoolWhether the SDK is configured
appUserID: StringCurrent app user ID
isAnonymous: BoolWhether the current user is anonymous
loginUser(userId:) async throwsLog in with a user ID
logoutUser() async throwsLog out to anonymous
loadOfferings() async throwsLoad all offerings
loadProducts(offeringIdentifier:) async throwsLoad packages from an offering
purchase(package:) async throwsPurchase a package (iOS)
purchase(package:activity:) async throwsPurchase a package (Android)
restorePurchases() async throwsRestore prior purchases
getCustomerInfo() async throwsGet current customer info
setAttributes(_:)Set subscriber attributes
setEmail(_:)Set user’s email
setDisplayName(_:)Set user’s display name
Property / MethodDescription
current: RCFuseOffering?The current (default) offering
all: [String: RCFuseOffering]All offerings keyed by identifier
offering(identifier:)Look up an offering by identifier
Property / MethodDescription
identifier: StringThe offering identifier
serverDescription: StringDescription 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
PropertyDescription
identifier: StringThe package identifier
packageType: RCFusePackageTypeThe package type (.monthly, .annual, etc.)
storeProduct: RCFuseStoreProductThe underlying store product
localizedPeriodString: String?Human-readable subscription period
PropertyDescription
productIdentifier: StringThe store product identifier
localizedTitle: StringThe product name
localizedDescription: StringThe product description
localizedPriceString: StringFormatted price (e.g., “$9.99”)
price: DoubleNumeric price value
currencyCode: String?Currency code (e.g., “USD”)
localizedIntroductoryPriceString: String?Intro offer price (iOS only)
Property / MethodDescription
originalAppUserId: StringThe original app user ID
activeEntitlements: Set<String>Currently active entitlement identifiers
allPurchasedProductIdentifiers: Set<String>All purchased product identifiers
hasActiveEntitlements: BoolWhether the user has any active entitlements
isEntitlementActive(_:) -> BoolCheck if a specific entitlement is active
firstSeen: DateWhen 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
CaseDescription
.lifetimeLifetime (non-recurring)
.annualAnnual subscription
.sixMonthSix-month subscription
.threeMonthThree-month subscription
.twoMonthTwo-month subscription
.monthlyMonthly subscription
.weeklyWeekly subscription
.customCustom package
.unknownUnknown type
CaseDescription
.userCancelledUser cancelled the purchase
.unknownUnknown error
.noPurchasesFoundNo purchases found to restore
.noProductsAvailableNo products available in the offering
.packageNotFoundPackage not found
.notConfiguredRevenueCat is not configured
ParameterDescription
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

This project is a Swift Package Manager module that uses the Skip plugin to build the package for both iOS and Android.

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.

We welcome contributions to SkipRevenue. The Skip product documentation includes helpful instructions and tips on local Skip library development.