One-time purchases are products the user buys once, unlike subscriptions that renew. There are three types, and each works differently in the stores.
This guide assumes RevenueCat is set up. If not, start with Subscriptions for the initial setup and dashboard configuration.
Product Types
- Consumable — can be purchased multiple times. Think coins, tokens, extra lives. The user spends them, then buys more. Apple and Google track the purchase, but your app tracks the balance.
- Non-consumable — purchased once, owned forever. Remove ads, unlock a level pack, premium themes. The stores remember the purchase permanently.
- Lifetime— a non-consumable that grants a subscription-style entitlement permanently. One payment instead of recurring. Useful as a "pay once, get premium forever" option alongside monthly/annual plans.
Dashboard Setup
Create the product in your app store first (App Store Connect → In-App Purchases, or Google Play Console → Monetize → Products → In-App Products). Then in RevenueCat:
- Add the product with the matching store product ID
- For non-consumables and lifetime: attach it to your entitlement (e.g., "premium") so the SDK can check access
- Add it to an offering as a package
For consumables, you typically do not attach them to an entitlement because they get used up. Instead, track the balance in your own database (e.g., a Supabase table).
Purchase Flow
The code is identical to subscriptions. Fetch the offering, find the package you want, and call purchasePackage. RevenueCat handles the native purchase dialog and receipt validation.
// Fetch available packages
final offerings = await Purchases.getOfferings();
final packages = offerings.current?.availablePackages ?? [];
// Find the lifetime package
final lifetime = packages.firstWhere(
(p) => p.packageType == PackageType.lifetime,
);
// Purchase it
try {
final result = await Purchases.purchasePackage(lifetime);
if (result.entitlements.active.containsKey('premium')) {
// User now has lifetime premium access
}
} on PlatformException catch (e) {
final errorCode = PurchasesErrorHelper.getErrorCode(e);
if (errorCode != PurchasesErrorCode.purchaseCancelledError) {
// Handle real error (not just user cancelling)
}
}Check Ownership
For non-consumables and lifetime purchases attached to an entitlement, check access the same way as subscriptions:
final customerInfo = await Purchases.getCustomerInfo();
final isPremium = customerInfo.entitlements.active.containsKey('premium');For consumables, RevenueCat records the purchase event but does not track an ongoing balance. You need to increment a counter in your own database when the purchase succeeds, and decrement it when the user spends the item.
Mixed Paywall
A common pattern: show subscriptions and a lifetime option on the same paywall. This lets the user choose between recurring payments or a single purchase. RevenueCat makes this easy because all package types come from the same offering.
class PaywallScreen extends StatelessWidget {
final List<Package> packages;
const PaywallScreen({super.key, required this.packages});
String _label(Package p) {
switch (p.packageType) {
case PackageType.monthly:
return 'Monthly';
case PackageType.annual:
return 'Annual';
case PackageType.lifetime:
return 'Lifetime';
default:
return p.storeProduct.title;
}
}
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: packages.length,
itemBuilder: (context, index) {
final package = packages[index];
return ListTile(
title: Text(_label(package)),
subtitle: Text(package.storeProduct.description),
trailing: Text(package.storeProduct.priceString),
onTap: () async {
try {
await Purchases.purchasePackage(package);
Navigator.pop(context, true);
} on PlatformException {
// Handle error
}
},
);
},
);
}
}