You've just shipped your Flutter app's paywall. A user on Reddit reports they reinstalled the app and lost their premium access. Your support inbox fills up overnight. The fix is three lines of Dart, but you have to know exactly where to put them.
This guide is written for two kinds of Flutter developers: those starting fresh who want to avoid landmines, and those debugging a broken restore flow right now. Every section is backed by a real code example and a citation to the official docs.
1. SDK Setup and Installation
Add the dependency
flutter pub add purchases_flutter
Or add it manually to pubspec.yaml, then run flutter pub get. Source
iOS-specific setup
RevenueCat requires iOS 11.0+ and Swift 5.0+. Flutter doesn't automatically set the deployment target, so open ios/Podfile and ensure this line is present:
platform :ios, '11.0'
Then enable the In-App Purchase capability in Xcode under Project Target → Capabilities → In-App Purchase. Source
Android-specific setup
Add the BILLING permission to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="com.android.vending.BILLING" />
Critical: Set your Activity's launchMode to standard or singleTop. If it's set to singleTask or singleInstance, the payment verification step—where Google may redirect a user to their banking app—can cancel the purchase mid-flow:
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
.../>
Import the package
import 'package:purchases_flutter/purchases_flutter.dart';
If you hit type-name collisions with other packages (e.g., flutter_mobx), use an import alias:
import 'package:purchases_flutter/purchases_flutter.dart' as rc;
// Then use: rc.CustomerInfo, rc.Purchases, etc.
2. SDK Initialization
Configure Purchases once, as early as possible in your app's lifecycle—typically in main() or the root initState(). The configured instance is then a shared singleton accessible everywhere via Purchases.shared. Source
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Enable verbose logs BEFORE calling configure.
await Purchases.setLogLevel(LogLevel.debug);
PurchasesConfiguration configuration;
if (Platform.isAndroid) {
configuration = PurchasesConfiguration('<google_api_key>');
} else {
configuration = PurchasesConfiguration('<apple_api_key>');
}
// Optionally attach your own user ID. If omitted, RevenueCat
// generates an anonymous ID automatically.
// configuration.appUserID = 'your_user_id';
await Purchases.configure(configuration);
runApp(const MyApp());
}
Note: Flutter requires separate API keys per platform (iOS and Android). Find these under Project Settings → API keys → App specific keys in the RevenueCat dashboard. Source
⚠️ Never ship a Test Store API key in a production build. Use build configurations or environment variables to switch keys. Source
3. Displaying Products from Offerings
RevenueCat's Offerings system lets you control what's shown on your paywall remotely—no app update required. The SDK pre-fetches Offerings on launch, so calling getOfferings() is usually a fast cache hit. Source
Future<void> loadOfferings() async {
try {
final offerings = await Purchases.getOfferings();
// The "current" offering is your default, or the one targeted
// to this user via Experiments/Targeting.
final current = offerings.current;
if (current == null) {
// No offering configured — check RevenueCat dashboard.
debugPrint('No current offering found.');
return;
}
// Packages group equivalent products across iOS and Android.
for (final package in current.availablePackages) {
debugPrint(
'${package.packageType}: '
'${package.storeProduct.priceString}',
);
}
// You can also access convenience properties directly:
final monthly = current.monthly; // PackageType.monthly
final annual = current.annual; // PackageType.annual
} on PlatformException catch (e) {
debugPrint('Error fetching offerings: $e');
}
}
⚠️ Avoid calling
getOfferings()insideApplication.onCreateon Android. This can trigger unnecessary network requests for background processes like push notifications. Let the SDK pre-fetch it automatically. Source
Each Package contains a storeProduct with priceString, title, description, and subscriptionPeriod—everything you need to build a dynamic paywall without hardcoding strings. Source
4. Making Purchases with Full Error Handling
Pass a Package directly to Purchases.purchasePackage(). The SDK handles the entire StoreKit/Play Billing dialog, receipt validation, and CustomerInfo refresh automatically. Source
Future<void> purchasePackage(Package package) async {
try {
final customerInfo = await Purchases.purchasePackage(package);
// Check if the expected entitlement is now active.
if (customerInfo.entitlements.active.containsKey('premium')) {
// Unlock premium content.
navigateToPremiumContent();
}
} on PlatformException catch (e) {
final errorCode = PurchasesErrorHelper.getErrorCode(e);
if (errorCode == PurchasesErrorCode.purchaseCancelledError) {
// User tapped "Cancel" — this is normal, don't show an error.
debugPrint('User cancelled the purchase.');
} else if (errorCode == PurchasesErrorCode.storeProblemError) {
// The store had an issue. The user *may or may not* have been
// charged. RevenueCat will auto-retry; don't block the user.
showDialog(context, 'Store issue. Please try again later.');
} else if (errorCode == PurchasesErrorCode.networkError) {
showDialog(context, 'No internet connection. Please retry.');
} else if (errorCode == PurchasesErrorCode.productAlreadyPurchasedError) {
// User already owns this. Prompt them to restore instead.
showDialog(context, 'Already purchased! Try Restore Purchases.');
} else {
// For all other errors, retrying with the same arguments
// won't help — surface the error message to the user.
showDialog(context, 'Purchase failed: ${e.message}');
}
}
}
Key rules from the docs on error handling Source:
- NetworkError and StoreProblemError are retriable.
- All other errors won't succeed on retry with the same arguments.
- For StoreProblemError, assume the user may have been charged — don't optimistically block access.
- userCancelled is a convenience bool you can check, but a PurchasesError will still be thrown.
5. Restore Purchases — The #1 Pain Point
Why restore matters (and why it breaks)
Apple's App Store guidelines require a "Restore Purchases" button in every app with non-consumable or subscription IAPs. But more practically: users reinstall apps, switch devices, reset phones. Without restore, every one of those users files a support ticket or leaves a 1-star review.
The exact flow
Future<void> restorePurchases() async {
setState(() => _isRestoring = true);
try {
final customerInfo = await Purchases.restorePurchases();
if (customerInfo.entitlements.active.isNotEmpty) {
// Purchases found and restored.
showDialog(context, 'Purchases restored! Welcome back.');
navigateToPremiumContent();
} else {
// restorePurchases succeeded but no active entitlements found.
showDialog(
context,
'No active purchases found for this account.',
);
}
} on PlatformException catch (e) {
final errorCode = PurchasesErrorHelper.getErrorCode(e);
if (errorCode == PurchasesErrorCode.receiptAlreadyInUseError) {
// This receipt belongs to a different App User ID.
// Your Project's restore behavior setting controls what happens.
showDialog(context, 'This purchase is linked to another account.');
} else {
showDialog(context, 'Restore failed: ${e.message}');
}
} finally {
setState(() => _isRestoring = false);
}
}
When to call restorePurchases() vs syncPurchases()
| Method | When to use |
|---|---|
restorePurchases() |
User taps a "Restore Purchases" button — only on user interaction |
syncPurchases() |
Programmatic sync (e.g., migration) — won't trigger OS sign-in prompts |
⚠️
restorePurchases()must only be called from a user interaction. Calling it programmatically can trigger OS-level sign-in prompts unexpectedly. Source
UI/UX pattern: where to put the button
Place the "Restore Purchases" button: 1. On your paywall — below the main CTA, in smaller text. 2. In your app's Settings screen — always accessible. 3. NOT on the main home screen — it creates confusion for non-subscribers.
// Minimal restore button widget
TextButton(
onPressed: _isRestoring ? null : restorePurchases,
child: _isRestoring
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Restore Purchases'),
),
The restore behavior setting
When a user restores a purchase that's already attached to a different identified App User ID, RevenueCat uses your configured Restore Behavior (found under Project Settings → General):
- Transfer to new App User ID (default) — the restoring user gets access.
- Keep with original App User ID — returns a
receiptAlreadyInUseError. - Transfer if no active subscriptions — hybrid approach for apps with accounts.
Android-specific: consumable restore caveat
Starting with RevenueCat's Android SDK v9.0+, which uses Billing Client 8, Google no longer lets apps query consumed one-time purchases. This means anonymous users who consumed a purchase cannot restore it after reinstalling. The fix: upgrade to purchases-flutter 9.10.2+ which includes a patch, or use a proper account system with custom App User IDs. Source
6. Listening to CustomerInfo Changes with Streams
CustomerInfo can change from multiple sources: a purchase, a restore, a subscription expiry, or a refund processed server-side. The SDK exposes a stream to react to any of these in real time.
import 'package:purchases_flutter/purchases_flutter.dart';
class PremiumStatusNotifier extends ChangeNotifier {
bool _isPremium = false;
bool get isPremium => _isPremium;
PremiumStatusNotifier() {
// Listen for CustomerInfo updates for the lifetime of this object.
Purchases.addCustomerInfoUpdateListener(_onCustomerInfoUpdated);
// Fetch current state on initialization.
_refreshStatus();
}
void _onCustomerInfoUpdated(CustomerInfo info) {
_isPremium = info.entitlements.active.containsKey('premium');
notifyListeners();
}
Future<void> _refreshStatus() async {
try {
final info = await Purchases.getCustomerInfo();
_onCustomerInfoUpdated(info);
} on PlatformException catch (e) {
debugPrint('Could not fetch CustomerInfo: $e');
}
}
@override
void dispose() {
Purchases.removeCustomerInfoUpdateListener(_onCustomerInfoUpdated);
super.dispose();
}
}
Important: RevenueCat does not push CustomerInfo updates from the backend to your app. The listener fires only when CustomerInfo changes on the current device (e.g., after a purchase, restore, or a call to
getCustomerInfo()). Source
7. Entitlement Checking Patterns
The CustomerInfo object is your source of truth for access control. It's safe to call getCustomerInfo() frequently — the SDK caches results and only hits the network when the cache is older than 5 minutes. Source
// Pattern 1: Check a specific entitlement (most common)
Future<bool> hasPremiumAccess() async {
try {
final customerInfo = await Purchases.getCustomerInfo();
return customerInfo.entitlements.active.containsKey('premium');
} on PlatformException catch (_) {
return false; // Fail closed — don't grant access on error.
}
}
// Pattern 2: Check if the user has ANY active entitlement
Future<bool> hasAnyEntitlement() async {
final customerInfo = await Purchases.getCustomerInfo();
return customerInfo.entitlements.active.isNotEmpty;
}
// Pattern 3: Inspect a specific entitlement's details
Future<void> inspectEntitlement() async {
final customerInfo = await Purchases.getCustomerInfo();
final entitlement = customerInfo.entitlements.active['premium'];
if (entitlement != null) {
debugPrint('Will renew: ${entitlement.willRenew}');
debugPrint('Expires: ${entitlement.expirationDate}');
debugPrint('Store: ${entitlement.store}');
}
}
Heads up:
CustomerInfowill be empty if the user has never made a purchase and no transactions have been synced — even if you've configured entitlements in the dashboard. Always handle the empty/null case gracefully. Source
8. Common Pitfalls
Pitfall 1: Calling purchasePackage() before Purchases.configure()
// ❌ WRONG — configure() hasn't been awaited
void main() {
Purchases.configure(PurchasesConfiguration('key')); // Not awaited!
runApp(const MyApp());
}
// ✅ RIGHT
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Purchases.configure(PurchasesConfiguration('key'));
runApp(const MyApp());
}
Any SDK method called before configure() completes will throw an error. Always await configuration. Source
Pitfall 2: Not awaiting async SDK calls
// ❌ WRONG — fire-and-forget, unhandled exceptions
void onRestorePressed() {
Purchases.restorePurchases(); // Missing await and try/catch
}
// ✅ RIGHT
Future<void> onRestorePressed() async {
try {
await Purchases.restorePurchases();
} on PlatformException catch (e) {
// Handle the error
}
}
Every RevenueCat SDK method that returns a Future must be awaited. Unawaited futures silently swallow errors and leave your UI in a stale state.
Pitfall 3: Testing on the iOS Simulator with flutter run
StoreKit testing on the simulator only works when launched directly from Xcode, not via flutter run or VSCode's Flutter extension. These tools use xcodebuild and don't pick up the StoreKit Configuration File attached to your scheme. Source
Workaround: Open the ios/ folder in Xcode, select your StoreKit-enabled scheme, and run from there. Or test on a real physical device with a sandbox account.
Pitfall 4: Sandbox account issues on iOS
iOS 13 and earlier require a real physical device for sandbox testing — the simulator simply cannot process real App Store sandbox purchases without StoreKit 1 configuration files. Source
On iOS 12+, add your sandbox test account under Settings → App Store → Sandbox Account without signing out of your real Apple ID. Create sandbox accounts at App Store Connect → Users and Access → Sandbox Testers, using a real, verifiable email address.
⚠️ If you need to test as a "fresh" user (no purchase history), create a new sandbox test account. Sandbox accounts accumulate purchase history and cannot be fully reset.
Pitfall 5: Using the wrong API key
RevenueCat uses separate API keys for iOS, Android, and the Test Store. Using an iOS key on Android (or a Test Store key in a production build) will produce INVALID_CREDENTIALS errors. The keys are found under Project Settings → API keys → App specific keys. Source
// Defensive key selection by platform
final apiKey = Platform.isAndroid
? const String.fromEnvironment('RC_ANDROID_KEY')
: const String.fromEnvironment('RC_IOS_KEY');
await Purchases.configure(PurchasesConfiguration(apiKey));
Pitfall 6: Empty offerings in production
If getOfferings() returns null or empty packages, the cause is almost always a dashboard configuration issue — product IDs don't match what's set up in App Store Connect/Google Play, or the products haven't been approved/activated yet. Enable debug logs (LogLevel.debug) and check for Invalid Product Identifiers in the output. Source
Pitfall 7: Restoring consumables for anonymous users on Android
If your app sells consumable one-time purchases AND uses anonymous App User IDs (no login), users who reinstall on Android cannot restore those purchases with Billing Client 8. The fix is to upgrade to purchases-flutter 9.10.2+, which includes a Google-provided workaround, and to ensure Android Auto Backup is enabled (it's on by default) so RevenueCat's SharedPreferences file — including the anonymous user ID — survives reinstalls. Source
Quick-Reference Checklist
| Step | Done? |
|---|---|
flutter pub add purchases_flutter |
☐ |
iOS deployment target ≥ 11.0 in Podfile |
☐ |
| In-App Purchase capability enabled in Xcode | ☐ |
BILLING permission in AndroidManifest.xml |
☐ |
launchMode = standard or singleTop |
☐ |
await Purchases.configure(...) before any other SDK call |
☐ |
| Separate API keys for iOS vs Android | ☐ |
| "Restore Purchases" button on paywall and in Settings | ☐ |
restorePurchases() called only from user interaction |
☐ |
Error handling for storeProblemError (user may be charged!) |
☐ |
CustomerInfo listener cleaned up in dispose() |
☐ |
| Test Store / Test API key not shipped in production | ☐ |
Sources
- Flutter SDK Installation
- Configuring the SDK
- SDK Quickstart
- Displaying Products
- Making Purchases
- Restoring Purchases
- Restore Behavior (Transfer Settings)
- Getting Subscription Status (CustomerInfo)
- Error Handling
- Debugging
- Troubleshooting the SDKs
- Apple App Store Sandbox Testing
- Restoring Consumable Purchases — Billing Client 8 Issue