Lesson 54 of 77 intermediate

Monetization: IAP, Subscriptions, Ads & RevenueCat

Implementing in-app purchases, subscription lifecycles, and ad monetization the right way

Open interactive version (quiz + challenge)

Real-world analogy

Monetization is like running a coffee shop: you can sell individual drinks (one-time IAP), sell monthly subscriptions (premium membership), or let advertisers put posters on your wall (ads). RevenueCat is like a POS system that handles all payment types, tracks memberships, and talks to the credit card networks so you don't have to.

What is it?

Monetization in Flutter apps involves implementing in-app purchases, subscriptions, and advertising using platform-native billing systems abstracted by packages like in_app_purchase and RevenueCat, combined with ad SDKs like AdMob.

Real-world relevance

A field operations SaaS app uses RevenueCat to manage team subscriptions: free tier for 3 users, Pro at 29 USD/month for unlimited users. RevenueCat handles Apple/Google receipt validation, grace periods when a company's card expires, and sends webhooks to the NestJS backend to update the team's feature access in Supabase.

Key points

Code example

// RevenueCat Flutter integration
import 'package:purchases_flutter/purchases_flutter.dart';

class PurchaseService {
  static Future<void> initialize() async {
    await Purchases.setLogLevel(LogLevel.debug);
    final config = PurchasesConfiguration(
      Platform.isIOS ? 'appl_your_key' : 'goog_your_key',
    );
    await Purchases.configure(config);
  }

  // Identify user after login (links purchases to your user ID)
  static Future<void> identifyUser(String userId) async {
    await Purchases.logIn(userId);
  }

  // Check if user has 'premium' entitlement
  static Future<bool> hasPremiumAccess() async {
    final customerInfo = await Purchases.getCustomerInfo();
    return customerInfo.entitlements.active.containsKey('premium');
  }

  // Fetch current offering and display paywall
  static Future<Offerings> getOfferings() async {
    return await Purchases.getOfferings();
  }

  // Purchase a package (e.g., monthly subscription)
  static Future<CustomerInfo> purchasePackage(Package package) async {
    try {
      return await Purchases.purchasePackage(package);
    } on PurchasesErrorCode catch (e) {
      if (e == PurchasesErrorCode.purchaseCancelledError) {
        throw const UserCancelledPurchaseException();
      }
      rethrow;
    }
  }

  // Restore purchases (required by App Store guidelines)
  static Future<CustomerInfo> restorePurchases() async {
    return await Purchases.restorePurchases();
  }
}

// Usage in paywall widget
class PaywallScreen extends StatefulWidget {
  const PaywallScreen({super.key});
  @override
  State<PaywallScreen> createState() => _PaywallScreenState();
}

class _PaywallScreenState extends State<PaywallScreen> {
  Offerings? _offerings;

  @override
  void initState() {
    super.initState();
    PurchaseService.getOfferings().then((o) => setState(() => _offerings = o));
  }

  @override
  Widget build(BuildContext context) {
    final offering = _offerings?.current;
    if (offering == null) return const CircularProgressIndicator();

    return Column(
      children: offering.availablePackages.map((pkg) {
        return ElevatedButton(
          onPressed: () async {
            final info = await PurchaseService.purchasePackage(pkg);
            if (info.entitlements.active.containsKey('premium')) {
              Navigator.pop(context, true);
            }
          },
          child: Text('${pkg.packageType.name} — ${pkg.storeProduct.priceString}'),
        );
      }).toList(),
    );
  }
}

Line-by-line walkthrough

  1. 1. PurchasesConfiguration takes the platform-specific key — using Platform.isIOS ternary avoids shipping both keys to both platforms unnecessarily.
  2. 2. Purchases.logIn(userId) must be called after your own auth completes — it links anonymous device purchases to your authenticated user, enabling cross-device subscription access.
  3. 3. entitlements.active.containsKey('premium') is the canonical access check — always check entitlements, never raw subscription state, for clean feature gating logic.
  4. 4. Purchases.getOfferings() fetches the current offering configured in RevenueCat dashboard — changing products requires no app update.
  5. 5. offering.availablePackages contains the packages defined for that offering — monthly, annual, weekly, or custom configured in the dashboard.
  6. 6. purchasePackage wraps the native StoreKit/Play Billing purchase flow — RevenueCat handles receipt posting and validation automatically.
  7. 7. PurchasesErrorCode.purchaseCancelledError must be caught and handled gracefully — it's a normal user action, not an error to report.
  8. 8. Purchases.restorePurchases() is required by App Store Review guidelines — failure to include it is a common rejection reason.

Spot the bug

class PremiumGate extends StatelessWidget {
  const PremiumGate({super.key, required this.child});
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: Purchases.getCustomerInfo(),
      builder: (context, snapshot) {
        final info = snapshot.data;
        final isPremium = info?.activeSubscriptions.isNotEmpty ?? false;
        if (!isPremium) return const PaywallScreen();
        return child;
      },
    );
  }
}
Need a hint?
A user buys a one-time non-consumable 'lifetime access' IAP. PremiumGate still shows the paywall. Why?
Show answer
Bug: activeSubscriptions only contains auto-renewable subscriptions — it does NOT include non-consumable or non-renewing purchases. A lifetime IAP would be delivered as a non-consumable, appearing in entitlements.active or nonSubscriptionTransactions, not activeSubscriptions. Fix: check entitlements instead — info?.entitlements.active.containsKey('premium') ?? false — this is the correct check because RevenueCat entitlements aggregate all access-granting purchases (subscriptions, non-consumables, promotional grants) into a single boolean. Also, FutureBuilder recreates the Future on every rebuild — cache getCustomerInfo() result or use a StreamBuilder with Purchases.customerInfoStream for reactive updates.

Explain like I'm 5

Making money from your app is like running a lemonade stand with different options: sell a glass (one-time purchase), sell a monthly membership card (subscription), or let someone put a sign near your stand for money (ads). RevenueCat is your cashier who handles all the payments, remembers who has a membership card, and makes sure no one sneaks past without paying.

Fun fact

RevenueCat processes over 1 billion USD in annual in-app revenue across its platform and is used by apps in over 30,000 companies. Its SDK typically saves 4-8 weeks of engineering time per app compared to implementing StoreKit and Play Billing directly.

Hands-on challenge

Implement a complete paywall flow: (1) Initialize RevenueCat on app start with platform-specific API keys. (2) After login, identify the user. (3) Build a PaywallScreen that fetches the current offering and displays monthly/annual packages with prices. (4) Handle the purchase flow including cancellation and error states. (5) Gate a premium feature behind an entitlement check. (6) Add a Restore Purchases button.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery