
Revenuecat Purchase Flow
- 393 installs
- 55 repo stars
- Updated August 3, 2026
- revenuecat/ai-toolkit
Implement in-app paywalls, package selection, purchase, and restore flows with RevenueCat SDK so users can subscribe inside iOS or Android apps.
About
Builds end-to-end RevenueCat mobile purchase flow: load offerings, render paywall UI, initiate purchases and restores via SDK, propagate CustomerInfo updates, and present clear success, cancellation, and billing error states to users.
- Presents offerings and packages from RevenueCat
- Triggers purchase and handles user-cancelled transactions
- Implements restore purchases and receipt refresh
- Surfaces errors and loading states on paywall screens
Revenuecat Purchase Flow by the numbers
- 393 all-time installs (skills.sh)
- +47 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #336 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/revenuecat/ai-toolkit --skill revenuecat-purchase-flowAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 393 |
|---|---|
| repo stars | ★ 55 |
| Last updated | August 3, 2026 |
| Repository | revenuecat/ai-toolkit ↗ |
What it does
Implement in-app paywalls, package selection, purchase, and restore flows with RevenueCat SDK so users can subscribe inside iOS or Android apps.
Files
revenuecat-purchase-flow: buy a package and restore purchases
Use this skill when the user wants to complete the purchase side of RevenueCat: fetch offerings, call purchase, deal with cancellation and errors, and expose a "Restore" action. It does not cover rendering a paywall UI (that lives in revenuecat-paywall) or gating features (that lives in revenuecat-entitlements-gate).
1. Detect the platform
Inspect the working directory and pick the first match, from top to bottom:
1. React Native: package.json has a react-native-purchases entry, or react-native as a dependency → read platforms/react-native.md. If expo is also a dependency, note it as an Expo project. 2. Flutter: pubspec.yaml exists at the project root → read platforms/flutter.md. 3. Kotlin Multiplatform: build.gradle.kts contains a kotlin { … } multiplatform source sets block, or depends on com.revenuecat.purchases:purchases-kmp* → read platforms/kmp.md. 4. Android (native): build.gradle(.kts) applies com.android.application (and is not KMP) → read platforms/android.md. 5. iOS (native): Package.swift, *.xcodeproj, *.xcworkspace, or Podfile at the project root → read platforms/ios.md.
If several match (e.g. an ios/ folder inside a Flutter project), pick the outermost project, the one that owns the build. If still ambiguous, ask the user which platform they want to configure.
2. Shared concepts (all platforms)
- Flow. Call
getOfferings(), pick aPackagefrom the current offering, callpurchase(package). When it completes successfully, the returnedcustomerInfoalready reflects the purchase. ReadcustomerInfo.entitlements.active["<id>"]to confirm access. - User cancellation is not an application error. Each SDK surfaces it differently: iOS throws a
purchaseCancelledErrorcode, Android throws aPurchasesExceptionwithPurchasesErrorCode.PurchaseCancelledError, Flutter surfaces aPlatformExceptionwith that same code, React Native setse.userCancelled === true. Return silently in this case. Do not show an alert. - Errors worth messaging. Payment declined, network errors, store unavailable, receipt already in use. Everything else should be logged and let the user try again. Never silently succeed when the purchase actually failed.
- Do not unlock content inside the purchase callback. Refresh customer info and let your entitlements listener (see
revenuecat-entitlements-gate) flip the gated UI. This keeps one source of truth for access and avoids drift between the purchase path and the restore path. - `restorePurchases()` is a user action, not an automatic step. It asks the store for the current receipt and syncs it to RevenueCat. Expose it from a visible "Restore purchases" button on the paywall and/or settings screen. Legal requirements on iOS mandate such a button.
- One purchase at a time. Disable the paywall buy buttons while a purchase is in flight to prevent double charges.
3. Implementation
Read the platform file that matches detection:
platforms/ios.mdplatforms/android.mdplatforms/kmp.mdplatforms/flutter.mdplatforms/react-native.md
Each platform file contains a complete purchase function and a restore function.
4. Verify
Do not claim the flow works until:
1. A sandbox purchase of the current offering's package succeeds end to end, and the user's entitlement flips to active. 2. Cancelling the store sheet does not show an error alert and does not leave the UI in a loading state. 3. A second purchase attempt for the same active subscription is handled cleanly (StoreKit / Play Billing will surface a productAlreadyPurchased / receiptAlreadyInUse path; the flow should not crash). 4. The restore button, on a fresh install signed in to the same store account, restores the entitlement and updates the UI.
revenuecat-purchase-flow: Android (native Kotlin)
Fetch offerings
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.awaitOfferings
import com.revenuecat.purchases.models.Package
suspend fun currentPackages(): List<Package> {
val offerings = Purchases.sharedInstance.awaitOfferings()
return offerings.current?.availablePackages.orEmpty()
}offerings.current reflects the current offering configured in the RevenueCat dashboard.
Purchase a package
awaitPurchase needs a PurchaseParams built with the launching Activity and the Package. It throws a PurchasesException whose error.code can be compared against PurchasesErrorCode.
import android.app.Activity
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesException
import com.revenuecat.purchases.PurchasesErrorCode
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.awaitPurchase
import com.revenuecat.purchases.models.Package
sealed interface PurchaseOutcome {
data object Purchased : PurchaseOutcome
data object Cancelled : PurchaseOutcome
data class Failed(val error: Throwable) : PurchaseOutcome
}
suspend fun buy(activity: Activity, pkg: Package): PurchaseOutcome = try {
val params = PurchaseParams.Builder(activity, pkg).build()
Purchases.sharedInstance.awaitPurchase(params)
// Do not unlock content here. The updatedCustomerInfoListener flips the
// gated UI (see revenuecat-entitlements-gate).
PurchaseOutcome.Purchased
} catch (e: PurchasesException) {
if (e.code == PurchasesErrorCode.PurchaseCancelledError) {
PurchaseOutcome.Cancelled
} else {
PurchaseOutcome.Failed(e)
}
}awaitPurchase returns a PurchaseResult (storeTransaction + customerInfo) on success; you usually do not need either if you are already listening to updatedCustomerInfoListener.
Wire it to a Compose button
@Composable
fun BuyButton(pkg: Package) {
val activity = LocalContext.current as Activity
val scope = rememberCoroutineScope()
var isBuying by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
Button(
enabled = !isBuying,
onClick = {
scope.launch {
isBuying = true
try {
when (val outcome = buy(activity, pkg)) {
is PurchaseOutcome.Purchased,
is PurchaseOutcome.Cancelled -> Unit
is PurchaseOutcome.Failed ->
errorMessage = outcome.error.message
}
} finally {
isBuying = false
}
}
}
) {
Text(pkg.product.price.formatted)
}
errorMessage?.let { msg ->
AlertDialog(
onDismissRequest = { errorMessage = null },
confirmButton = { TextButton({ errorMessage = null }) { Text("OK") } },
title = { Text("Purchase failed") },
text = { Text(msg) }
)
}
}Restore purchases
import com.revenuecat.purchases.awaitRestore
suspend fun restore(): Result<CustomerInfo> = runCatching {
Purchases.sharedInstance.awaitRestore()
}Expose this from a visible "Restore purchases" button on the paywall and/or settings screen.
Notes
awaitPurchase,awaitOfferings,awaitRestore, andawaitCustomerInfolive incom.revenuecat.purchases.*as extensions onPurchases. They throwPurchasesException.PurchaseParams.Buildermust receive the currently visibleActivity, not theApplicationor a detached context. Google Play needs a surface to attach its billing sheet.- The callback variants (
purchase(PurchaseParams, PurchaseCallback)) exposeuserCancelled: Booleandirectly inonError. Use them if you do not want the coroutine dependency. - Kotlin enum comparison uses
==onPurchasesErrorCode.e.codereturns the enum value.
Verify
1. A sandbox purchase of a package flips the "premium" entitlement to active, observed via updatedCustomerInfoListener. 2. Tapping the Play Billing sheet's back button returns to the app without an error dialog. 3. On a fresh install signed into the same Google account, "Restore purchases" re-grants the entitlement. 4. adb logcat | grep Purchases shows the purchase lifecycle. An InvalidCredentialsError means the API key does not match the app's package name in the dashboard.
revenuecat-purchase-flow: Flutter
Fetch offerings
import 'package:purchases_flutter/purchases_flutter.dart';
Future<List<Package>> currentPackages() async {
final offerings = await Purchases.getOfferings();
return offerings.current?.availablePackages ?? const [];
}Purchase a package
Purchases.purchasePackage throws a PlatformException on failure. Use PurchasesErrorHelper.getErrorCode(e) to detect cancellation.
import 'package:flutter/services.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
sealed class PurchaseOutcome {}
class Purchased extends PurchaseOutcome {}
class Cancelled extends PurchaseOutcome {}
class Failed extends PurchaseOutcome {
final Object error;
Failed(this.error);
}
Future<PurchaseOutcome> buy(Package pkg) async {
try {
await Purchases.purchasePackage(pkg);
// Do not unlock content here. A CustomerInfoUpdateListener flips the
// gated UI (see revenuecat-entitlements-gate).
return Purchased();
} on PlatformException catch (e) {
final code = PurchasesErrorHelper.getErrorCode(e);
if (code == PurchasesErrorCode.purchaseCancelledError) {
return Cancelled();
}
return Failed(e);
}
}Wire it to a widget
class BuyButton extends StatefulWidget {
final Package package;
const BuyButton(this.package, {super.key});
@override
State<BuyButton> createState() => _BuyButtonState();
}
class _BuyButtonState extends State<BuyButton> {
bool _isBuying = false;
Future<void> _tap() async {
setState(() => _isBuying = true);
try {
final outcome = await buy(widget.package);
if (!mounted) return;
if (outcome is Failed) {
showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Purchase failed'),
content: Text(outcome.error.toString()),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
}
} finally {
if (mounted) setState(() => _isBuying = false);
}
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: _isBuying ? null : _tap,
child: Text(widget.package.storeProduct.priceString),
);
}
}Restore purchases
Future<CustomerInfo?> restore() async {
try {
return await Purchases.restorePurchases();
} on PlatformException {
return null;
}
}Expose this from a visible "Restore purchases" button on the paywall and/or settings screen.
Notes
PurchasesErrorHelper.getErrorCode(e)returns aPurchasesErrorCodeenum. Compare with==againstPurchasesErrorCode.purchaseCancelledErrorand friends. This is the supported way and survives plugin version bumps.Purchases.purchase(PurchaseParams.package(pkg))is the newer overload and accepts promotional offers and other options.purchasePackage(pkg)remains the simplest call for the common case.- Do not read
PlatformException.codestrings directly. The underlying native error strings differ between iOS and Android.
Verify
1. A sandbox purchase flips the "premium" entitlement to active and the listener notifies the UI. 2. Cancelling the native sheet returns Cancelled and no error dialog appears. 3. On a fresh install signed into the same store account, "Restore purchases" re-grants access. 4. flutter logs (or adb logcat / Xcode console) shows the Purchases SDK logs through the transaction.
revenuecat-purchase-flow: iOS (native)
Fetch offerings
import RevenueCat
func currentPackages() async throws -> [Package] {
let offerings = try await Purchases.shared.getOfferings()
guard let current = offerings.current else { return [] }
return current.availablePackages
}offerings.current reflects the current offering configured in the RevenueCat dashboard. If it is nil, no packages are live for this user.
Purchase a package
Purchases.shared.purchase(package:) returns PurchaseResultData (a tuple of transaction, customerInfo, userCancelled). User cancellation is also surfaced as a thrown ErrorCode.purchaseCancelledError. Handle both to be safe.
import RevenueCat
enum PurchaseOutcome {
case purchased
case cancelled
case failed(Error)
}
func buy(_ package: Package) async -> PurchaseOutcome {
do {
let result = try await Purchases.shared.purchase(package: package)
if result.userCancelled { return .cancelled }
// Do not unlock content here. The entitlements listener observes
// customerInfo and flips the gated UI.
return .purchased
} catch {
let nsError = error as NSError
if nsError.code == ErrorCode.purchaseCancelledError.rawValue {
return .cancelled
}
return .failed(error)
}
}Wire it to a SwiftUI button
struct BuyButton: View {
let package: Package
@State private var isBuying = false
@State private var errorMessage: String?
var body: some View {
Button(package.storeProduct.localizedPriceString) {
Task {
isBuying = true
defer { isBuying = false }
switch await buy(package) {
case .purchased, .cancelled:
break
case .failed(let error):
errorMessage = (error as NSError).localizedDescription
}
}
}
.disabled(isBuying)
.alert("Purchase failed", isPresented: .constant(errorMessage != nil)) {
Button("OK") { errorMessage = nil }
} message: { Text(errorMessage ?? "") }
}
}Restore purchases
func restore() async -> Result<CustomerInfo, Error> {
do {
let info = try await Purchases.shared.restorePurchases()
return .success(info)
} catch {
return .failure(error)
}
}Surface this from a visible "Restore purchases" button in the paywall and/or settings. After it returns, check info.entitlements.active["premium"] if you want to message "nothing to restore".
Notes
purchase(package:)throws on iOS 13+ viaasync. For older targets, the completion variantpurchase(package:completion:)delivers(transaction, customerInfo, error, userCancelled).ErrorCodeis a Swift enum conforming toError. Because the SDK throws aPublicError(anNSError), compare againstErrorCode.<case>.rawValueon theNSError.codeinstead of casting withas?.offerings.currentreflects the current offering for this user. Targeting rules in the dashboard can change it between users.
Verify
1. A sandbox purchase of a package flips the "premium" entitlement to active within a few seconds. 2. Tapping "Cancel" on the StoreKit sheet returns without an error alert and re-enables the buy button. 3. A fresh install signed in to the same sandbox Apple ID can restore via "Restore purchases" and regain access.
revenuecat-purchase-flow: Kotlin Multiplatform
purchases-kmp mirrors the native SDKs. Coroutine extensions live in com.revenuecat.purchases.kmp.ktx and work identically on both targets.
Fetch offerings (commonMain)
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.ktx.awaitOfferings
import com.revenuecat.purchases.kmp.models.Package
suspend fun currentPackages(): List<Package> {
val offerings = Purchases.sharedInstance.awaitOfferings()
return offerings.current?.availablePackages.orEmpty()
}Purchase a package (commonMain)
awaitPurchase(package) throws PurchasesTransactionException, which carries both the underlying PurchasesError and a userCancelled: Boolean. Check userCancelled first, then fall back to the error.
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.ktx.awaitPurchase
import com.revenuecat.purchases.kmp.models.Package
import com.revenuecat.purchases.kmp.models.PurchasesTransactionException
sealed interface PurchaseOutcome {
data object Purchased : PurchaseOutcome
data object Cancelled : PurchaseOutcome
data class Failed(val error: Throwable) : PurchaseOutcome
}
suspend fun buy(pkg: Package): PurchaseOutcome = try {
Purchases.sharedInstance.awaitPurchase(pkg)
// Do not unlock content here. A `PurchasesDelegate.onCustomerInfoUpdated`
// observer flips the gated UI (see revenuecat-entitlements-gate).
PurchaseOutcome.Purchased
} catch (e: PurchasesTransactionException) {
if (e.userCancelled) PurchaseOutcome.Cancelled
else PurchaseOutcome.Failed(e)
}On Android, Google Play requires an Activity to host the billing sheet. The KMP SDK takes the foreground activity from the platform actual on Android automatically in most versions. If your installed version requires you to pass one explicitly, prefer what the IDE autocompletes and see the purchases-kmp README.
Restore purchases (commonMain)
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.ktx.awaitRestore
import com.revenuecat.purchases.kmp.models.CustomerInfo
suspend fun restore(): Result<CustomerInfo> = runCatching {
Purchases.sharedInstance.awaitRestore()
}Expose this from a visible "Restore purchases" button on each platform's paywall / settings screen.
Callback variants
If you do not want the coroutine dependency, every suspending extension has a callback counterpart on Purchases.sharedInstance: getOfferings(onError, onSuccess), purchase(packageToPurchase, onError, onSuccess) (where onError takes (PurchasesError, userCancelled: Boolean)), and restorePurchases(onError, onSuccess).
Notes
PurchasesTransactionExceptionis specific to transactional calls and exposesuserCancelled. Non transactional calls (awaitCustomerInfo,awaitOfferings,awaitRestore,awaitLogIn,awaitLogOut) throw the plainPurchasesException.- Imports live under
com.revenuecat.purchases.kmp.ktx.*for coroutines andcom.revenuecat.purchases.kmp.models.*for types. - Error constants are on
com.revenuecat.purchases.kmp.models.PurchasesErrorCodeif you need to branch on specific non cancellation errors.
Verify
1. On each target (iOS + Android) a sandbox purchase of the current offering's first package flips the premium entitlement to active. 2. Cancelling the store sheet lands in the Cancelled branch on both platforms without showing an error dialog. 3. "Restore purchases" on a fresh install restores the entitlement on whichever store the user is signed in to. 4. Purchases logs appear in the native console of each platform (Xcode console on iOS, logcat on Android).
revenuecat-purchase-flow: React Native
Fetch offerings
import Purchases, { PurchasesPackage } from 'react-native-purchases';
export async function currentPackages(): Promise<PurchasesPackage[]> {
const offerings = await Purchases.getOfferings();
return offerings.current?.availablePackages ?? [];
}Purchase a package
Purchases.purchasePackage rejects on failure. The SDK sets error.userCancelled === true when the user dismisses the store sheet. Check that before treating anything as an error.
import Purchases, { PurchasesPackage } from 'react-native-purchases';
export type PurchaseOutcome =
| { kind: 'purchased' }
| { kind: 'cancelled' }
| { kind: 'failed'; error: unknown };
export async function buy(pkg: PurchasesPackage): Promise<PurchaseOutcome> {
try {
await Purchases.purchasePackage(pkg);
// Do not unlock content here. A CustomerInfoUpdateListener flips the
// gated UI (see revenuecat-entitlements-gate).
return { kind: 'purchased' };
} catch (e: any) {
if (e?.userCancelled === true) return { kind: 'cancelled' };
return { kind: 'failed', error: e };
}
}If you prefer an explicit error code comparison, Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR is also set on e.code when the user cancels.
Wire it to a component
import React, { useState } from 'react';
import { Alert, Button } from 'react-native';
import type { PurchasesPackage } from 'react-native-purchases';
import { buy } from './buy';
export function BuyButton({ package: pkg }: { package: PurchasesPackage }) {
const [isBuying, setIsBuying] = useState(false);
const onPress = async () => {
setIsBuying(true);
const outcome = await buy(pkg);
setIsBuying(false);
if (outcome.kind === 'failed') {
Alert.alert('Purchase failed', String((outcome.error as any)?.message ?? outcome.error));
}
};
return (
<Button
disabled={isBuying}
title={pkg.product.priceString}
onPress={onPress}
/>
);
}Restore purchases
export async function restore(): Promise<boolean> {
try {
await Purchases.restorePurchases();
return true;
} catch {
return false;
}
}Expose this from a visible "Restore purchases" button on the paywall and/or settings screen.
Notes
- The SDK sets
error.userCancelled = (error.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR)inside the reject path, so the shortcute.userCancelled === trueis safe on both iOS and Android. - Under Expo you must be on a development build.
purchasePackagethrows in Expo Go because the native module is missing. - Disable the buy button while a purchase is in flight. StoreKit and Play Billing queue duplicate calls; your UI should not let the user fire them.
Verify
1. A sandbox purchase of a package flips the entitlements.active['premium'] flag and your listener re-renders gated screens. 2. Dismissing the native purchase sheet returns cancelled and does not show an alert. 3. On a fresh install signed into the same store account, "Restore purchases" re-grants access. 4. Platform logs (Xcode console on iOS, adb logcat filtered by Purchases on Android) show the full transaction lifecycle. Metro's JS console will not.