I shipped subscriptions in Butter Skin, an AI skincare coach that analyzes your skin on-device with TensorFlow Lite, builds a personalized routine, and tracks progress in a visual diary. The app runs Flutter and Dart with Riverpod on the front end and Firebase Auth, Firestore, and Cloud Functions on the back end, so the architecture was already in place. The hard part was not the UI. It was the billing.
Every platform wants you to do subscriptions its own way: StoreKit 2 on iOS, the Google Play Billing Library on Android, receipts verified on your own server, renewal states reconciled through server notifications, edge cases like grace periods, billing retries, and refunds handled consistently. Writing all of that by hand means building and maintaining a mini billing backend. I used RevenueCat instead, and this article distills the integration pattern that has run in production ever since.
What follows is the complete flow: how RevenueCat's moving parts map to the App Store and Play Console, how to configure the SDK in Flutter, how to build a paywall from live offerings, and why entitlements — not local flags — must be your single source of truth.
Why RevenueCat instead of raw StoreKit and Play Billing
The in_app_purchase plugin from the Flutter team gives you access to the native purchase APIs, but that is only the first ten percent of the problem. On iOS you have to verify StoreKit transaction signatures server-side and handle App Store Server Notifications v2. On Android you have to acknowledge every purchase through the Play Developer API and consume RTDN notifications. On both platforms you must compute the current subscription state yourself: is this user in a trial, in a grace period, paused, expired, or revoked by a refund?
RevenueCat takes over everything after the purchase dialog: receipt verification, renewal tracking, cross-platform entitlement state, and a REST API plus webhooks when your server needs to react. You define an entitlement like premium once, and RevenueCat tells you whether any given user currently has it, on any platform, with a single boolean. That is the whole value proposition, and for a small team it is worth more than the SDK's free tier suggests.
The five pieces: products, entitlements, offerings, packages, paywall
RevenueCat has its own vocabulary, and the integration clicks once you learn it:
- Product: the thing you sell in the store — an auto-renewable subscription created in App Store Connect, or a subscription with base plans and offers in the Play Console.
-
Entitlement: your app's name for a level of access, like
premium. One entitlement can unlock features across iOS, Android, and web. - Package: a container that attaches a store product to an entitlement with a billing cadence, such as monthly or annual. You create packages inside an offering in the RevenueCat dashboard.
- Offering: the set of packages your paywall shows. You can have multiple offerings for A/B tests or seasonal pricing.
- Paywall: the screen itself. RevenueCat has a hosted Paywalls product, but building your own screen from the offering data takes twenty lines and gives you full design control.
The mental model is: stores sell products, RevenueCat maps products to entitlements, your app checks entitlements.
Setting up the dashboard and the stores
Start in the RevenueCat dashboard by creating a project, then an entitlement called premium, then an offering (the default one is fine) with a monthly and an annual package. For each package you will attach a store product, which means the store products must exist first.
On iOS, create the auto-renewable subscriptions in App Store Connect and attach them to a subscription group. On Android, create the subscription in the Play Console and add a base plan — Google's current model separates the subscription (the product family) from base plans and offers (the actual purchasable SKUs), and RevenueCat attaches to those. Use the exact same product identifiers in RevenueCat that you registered in the stores, or the dashboard will show the products as missing.
Copy the public SDK keys from the dashboard — one for the Apple app, one for the Google app. These are public keys, safe to ship in the client; the secret API key stays on your server for the REST API and webhook validation. This separation matters and is worth getting right from day one.
Configuring the SDK in Flutter
Add purchases_flutter to your pubspec, then configure once at startup before you need any purchase data:
import 'package:purchases_flutter/purchases_flutter.dart';
Future<void> initRevenueCat() async {
await Purchases.setLogLevel(LogLevel.debug);
const apiKey = String.fromEnvironment(
'REVENUECAT_KEY',
defaultValue: 'REPLACE_WITH_YOUR_PUBLIC_KEY',
);
await Purchases.configure(PurchasesConfiguration(apiKey));
}
On Android, the Google Play services check happens automatically; on iOS, no extra native code is required at all. One configuration detail that bites almost everyone: call Purchases.configure exactly once, and never call it again after logIn — logging in is a separate step.
Butter Skin uses Firebase Auth, so the RevenueCat identity is tied to the Firebase UID. Whenever the auth state changes, sync it:
Future<void> syncRevenueCatUser(String? firebaseUid) async {
if (firebaseUid == null) {
await Purchases.logOut();
} else {
final LogInResult result = await Purchases.logIn(firebaseUid);
// result.created is true when this was a brand-new RevenueCat user.
}
}
This is what makes entitlements follow the user across devices: the same Firebase UID on a new phone logs into the same RevenueCat app user, and the entitlement comes along with it. Without this step, purchases are tied to the anonymous RevenueCat ID on the original device and restorePurchases becomes your users' least favorite button.
Building the paywall from a live offering
Never hardcode prices or package lists in your app. Fetch the current offering so pricing changes in the dashboard take effect without an app update:
Future<List<Package>> loadPackages() async {
final Offerings offerings = await Purchases.getOfferings();
final Offering? current = offerings.current;
return current?.availablePackages ?? [];
}
Each Package carries a storeProduct with localized title, description, and priceString straight from the store, plus a packageType you can use to label monthly versus annual. The purchase itself is one call, and you must handle the user cancelling:
Future<bool> buyPackage(Package package) async {
try {
final CustomerInfo info = await Purchases.purchasePackage(package);
return info.entitlements.active.containsKey('premium');
} on PlatformException catch (e) {
final code = PurchasesErrorHelper.getErrorCode(e);
if (code == PurchasesErrorCode.purchaseCancelledError) {
return false; // User backed out of the native sheet. Not an error.
}
rethrow;
}
}
On Android, always offer a restore path in your settings screen — await Purchases.restorePurchases() — because users who reinstall or switch phones will look for it before they email you. On iOS, the restore flow is equally one line and works through the App Store account.
If you want a refresher on the on-device side of Butter Skin — the TFLite skin analysis that the paywall gates — I wrote that up in Flutter On-Device AI With TensorFlow Lite.
Entitlements are the single source of truth
This is the rule that keeps subscription bugs out of production: never gate a feature on a local boolean you set after a purchase. Check the entitlement from CustomerInfo every time, because RevenueCat keeps it current through renewals, cancellations, refunds, and billing retries without your app doing anything.
Butter Skin's state management is Riverpod, so the entitlement check lives in a provider that the UI reads reactively:
final premiumProvider = StateProvider<bool>((ref) => false);
Future<void> refreshPremiumStatus(WidgetRef ref) async {
final CustomerInfo info = await Purchases.getCustomerInfo();
ref.read(premiumProvider.notifier).state =
info.entitlements.all['premium']?.isActive ?? false;
}
And in main(), subscribe to updates so the UI reacts the moment a purchase completes, even in another part of the app:
Purchases.addCustomerInfoUpdateListener((CustomerInfo info) {
final bool active =
info.entitlements.all['premium']?.isActive ?? false;
refContainer.read(premiumProvider.notifier).state = active;
});
isActive already accounts for grace periods, billing retries, and Apple/Google billing issues, so you do not need to model any of those states yourself. If isActive is true, the user paid. Gate your premium screens on that provider and you are done:
class SkinAnalysisScreen extends ConsumerWidget {
const SkinAnalysisScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bool isPremium = ref.watch(premiumProvider);
return isPremium ? const AnalysisView() : const PaywallScreen();
}
}
One more detail worth knowing: getCustomerInfo caches locally and refreshes in the background, so calling it on app start is cheap and safe — no loading spinner required while RevenueCat syncs.
Sandbox testing that actually works
Subscriptions are the one feature you cannot fully test in an emulator on Android or without a real device flow on iOS, so set the sandbox up deliberately.
On iOS, create a StoreKit configuration file in Xcode and select it under your scheme's Run options — this gives you local sandbox products without touching App Store Connect. Test with a sandbox Apple ID, and remember that sandbox subscriptions renew at accelerated rates, so a monthly plan renews every few minutes. This is how you verify the entitlement flips to active and, later, expires on its own.
On Android, add your test Gmail accounts as license testers in the Play Console, upload a build to an internal track, and purchase with a real device signed into the license-tester account. Play Console lets you shorten test subscription durations down to minutes, which is how you verify renewals and the cancellation path without waiting a month. Purchases made by license testers are free and clearly marked.
The most common sandbox failure I see is testing against production product IDs with a sandbox account, or the reverse — keep the RevenueCat dashboard's sandbox and production keys straight, and use LogLevel.debug output to confirm which environment the SDK is talking to.
Production gotchas worth knowing
A few things that only surface after launch. First, webhooks: RevenueCat can notify your backend on every subscription event — initial purchase, renewal, cancellation, billing issue. Butter Skin runs Cloud Functions, and that is exactly where server-side reactions belong: granting cloud perks, updating analytics, or syncing subscription status into Firestore for support tooling. Never gate the client on a server round trip, though — the client trusts isActive, the server listens to webhooks.
Second, upgrades and downgrades: on Android, changing from monthly to annual requires the old subscription to be replaced, and the Play Billing Library handles proration modes. RevenueCat's purchasePackage with the new package handles the replacement flow; just make sure both packages belong to the same entitlement so the user never loses access mid-swap.
Third, price changes: when you raise a price in the store, existing subscribers stay on their old price until you push the change through the store's price-change consent flow. RevenueCat surfaces the new price in storeProduct.priceString for new purchasers automatically, so your paywall never needs a code change.
Finally, privacy: RevenueCat collects the purchase data needed to run entitlements. If your app has a privacy policy and data-deletion flow — and on both stores it must — document RevenueCat as a processor and use the dashboard's data deletion tooling when a user requests erasure. Butter Skin ships a clear privacy policy for exactly this reason.
Tying the RevenueCat identity to your auth provider is the step most tutorials skip, so if you have not set up sign-in yet, start with Flutter Firebase Authentication: Email, Google and Phone — the Purchases.logIn call in this article assumes you already have a stable user ID. And if Riverpod is new to you, Flutter Riverpod Code Generation covers the state patterns used above.
Subscriptions are one of those features where the last twenty percent takes eighty percent of the time, and that twenty percent is all receipt verification and edge cases RevenueCat already solved. Define your entitlement once, check isActive everywhere, test in the sandbox until renewals bore you, and ship. Get in touch and let's talk about your project.
Originally published at uzairxdev.online
Top comments (0)