Subscriptions are where a lot of Flutter apps quietly lose money. Not because the paywall is ugly, but because the plumbing is wrong: entitlements checked in the wrong place, restore-purchases missing, receipts validated on the client, no single source of truth for "is this user premium?" RevenueCat exists to handle that plumbing, but you still have to wire it correctly.
This is a production-minded walkthrough — not "call purchasePackage and you're done," but the setup I'd actually ship: a clean entitlement gate, correct restore behavior, and a reactive premium state your whole app can trust.
Why not just use the raw billing APIs?
Apple and Google's billing APIs give you a receipt. They do not give you: cross-platform entitlement state, server-side receipt validation, subscription status webhooks, or an answer to "did this user's trial convert?" You can build all of that yourself. On a solo or small team, you shouldn't. RevenueCat gives you a single CustomerInfo object that tells you what the user is entitled to, validated server-side, identical on iOS and Android.
Modeling products the right way
Before touching code, get the mental model straight, because this is where people go wrong:
-
Products are the SKUs you configure in App Store Connect and Google Play (e.g.
pro_monthly,pro_annual). -
Entitlements are what a product unlocks (e.g.
premium). Your app checks entitlements, never product IDs. - Offerings are the set of products you show on a paywall. You can swap offerings remotely without shipping an update.
The golden rule: your code asks "does the user have the premium entitlement?" — it never asks "did they buy pro_monthly?" That indirection is what lets you rename SKUs, run price experiments, and add plans without touching app logic.
Initialization
Initialize once at startup, before any purchase UI can appear:
Future<void> initPurchases() async {
await Purchases.setLogLevel(LogLevel.info);
final config = PurchasesConfiguration(
Platform.isIOS ? 'appl_YOUR_IOS_KEY' : 'goog_YOUR_ANDROID_KEY',
);
await Purchases.configure(config);
}
One production note: do not set an app user ID here unless you have a real, stable account ID for the user. If you use anonymous IDs now and add login later, RevenueCat's logIn() will alias the two. Getting this wrong scatters a single customer across multiple identities and breaks your revenue reporting.
A reactive premium gate
The whole app needs to know, reactively, whether the user is premium. RevenueCat pushes updates through a listener; wrap it so the rest of your app consumes a simple boolean stream.
void start() {
Purchases.addCustomerInfoUpdateListener(_onUpdate);
_refresh();
}
void _onUpdate(CustomerInfo info) {
final active = info.entitlements.active.containsKey('premium');
_controller.add(active);
}
Because RevenueCat validates receipts server-side and pushes changes through this listener, your gate updates correctly even when a subscription renews, lapses, or gets refunded in the background — no polling, no client-side receipt parsing.
Presenting the paywall
Fetch the current offering and render its packages. Never hard-code prices — read the localized priceString so users see the right currency and amount for their store region.
Future<bool> buy(Package package) async {
try {
final result = await Purchases.purchasePackage(package);
return result.customerInfo.entitlements.active.containsKey('premium');
} on PlatformException catch (e) {
final code = PurchasesErrorHelper.getErrorCode(e);
if (code == PurchasesErrorCode.purchaseCancelledError) {
return false; // user backed out — not an error, don't alert
}
rethrow;
}
}
That purchaseCancelledError branch matters. A huge share of "purchase failed" support tickets are really just users tapping cancel. Treat cancellation as a normal outcome, not a crash.
The two things people forget
1. Restore purchases. Apple will reject your app without it, and users switching devices need it. Call Purchases.restorePurchases() — the entitlement listener fires automatically, so there's no manual state update to do.
2. Trial and intro-offer eligibility. If you advertise "7-day free trial" to a user who already used it, the store will charge them immediately and you'll get a chargeback and a one-star review. Check eligibility with checkTrialOrIntroductoryPriceEligibility before showing trial copy.
Testing before you ship
Use sandbox accounts on iOS and a license-tester account on Android. Verify, at minimum: a fresh purchase flips the gate, killing and relaunching the app keeps premium (state is restored from RevenueCat, not local storage), restore works on a second device, and cancelling mid-flow leaves the user unchanged. If all four pass, your plumbing is sound.
The payoff
Once this is in place, adding a new plan is a config change in the RevenueCat dashboard, not a release. Running a price experiment is an offering swap. Answering "how many trials converted last month?" is a dashboard glance instead of a data-engineering project. That's the real win — you spent an afternoon on plumbing so you never have to think about it again.
Top comments (0)