← All posts

5 min read

In-App Subscriptions in Flutter with RevenueCat: A Practical Guide

How I set up auto-renewing subscriptions in Flutter apps with RevenueCat — entitlements, offerings, paywalls, restore purchases and the edge cases stores don't warn you about.

  • Flutter
  • RevenueCat
  • Subscriptions
  • Monetization
Cover illustration for In-App Subscriptions in Flutter with RevenueCat: A Practical Guide

Subscriptions are the business model behind most of the apps I’ve worked on recently. Dent Shop Manager sells monthly and yearly plans to auto-body shops, Range Buddy has a premium tier, and SafeMom gates features behind a paywall.

Implementing subscriptions directly against StoreKit and Google Play Billing is possible, but it’s a lot of work to do well: receipt validation, renewals, grace periods, cancellations, cross-platform status, refunds. For most apps I use RevenueCat, which wraps both stores in one SDK and one backend. This post is the setup I follow and the lessons I’ve picked up along the way.

The mental model: products, entitlements, offerings

RevenueCat has three concepts, and getting them right early saves a lot of confusion:

  • Products are what you create in App Store Connect and Google Play Console — for example pro_monthly and pro_yearly.
  • Entitlements are what the user gets — for example pro. Several products can unlock the same entitlement.
  • Offerings are what you show on the paywall — a set of packages (monthly, annual) that you can change remotely without an app release.

The rule I follow: app code only ever checks entitlements, never product IDs. The app asks “does this user have pro?”, not “did they buy pro_monthly?”. That way you can add a lifetime plan, run a promotional product or change prices without touching feature-gating code.

Step 1: Configure the SDK

Add purchases_flutter, then configure it at startup with the platform-specific public API key:

Future<void> initPurchases() async {
  await Purchases.setLogLevel(kDebugMode ? LogLevel.debug : LogLevel.warn);

  final apiKey = Platform.isIOS
      ? const String.fromEnvironment('RC_APPLE_KEY')
      : const String.fromEnvironment('RC_GOOGLE_KEY');

  await Purchases.configure(PurchasesConfiguration(apiKey));
}

If your app has its own accounts, log the user in to RevenueCat with your user ID after they sign in:

await Purchases.logIn(currentUser.id);

This links purchases to your user, so a subscription bought on an iPhone is recognized when the same user signs in on Android or on the web dashboard. Call Purchases.logOut() when they sign out.

Step 2: Expose subscription status as a stream

Subscription status changes outside your app’s control — renewals, expirations, refunds. I wrap it in a small service that the rest of the app can listen to:

class SubscriptionService {
  final _isPro = StreamController<bool>.broadcast();
  Stream<bool> get isPro => _isPro.stream;

  Future<void> start() async {
    Purchases.addCustomerInfoUpdateListener(_emit);
    _emit(await Purchases.getCustomerInfo());
  }

  void _emit(CustomerInfo info) {
    _isPro.add(info.entitlements.active.containsKey('pro'));
  }
}

Features then check isPro rather than calling RevenueCat directly. It also makes feature-gating trivial to fake in tests.

Step 3: Build the paywall from offerings

Fetch the current offering and render its packages:

final offerings = await Purchases.getOfferings();
final current = offerings.current;
if (current == null) {
  // Misconfigured dashboard or products not yet approved — show a fallback.
  return;
}

for (final package in current.availablePackages) {
  print('${package.storeProduct.title}: ${package.storeProduct.priceString}');
}

Always display priceString from the store product rather than a hard-coded price. It’s localized to the user’s currency and reflects any price changes you make in the store consoles.

RevenueCat also offers remotely configurable paywall templates, which are handy for experimenting with layouts without shipping an update. On one project I used Adapty for the same purpose — the concepts carry over almost exactly.

Step 4: Make the purchase

Future<void> buy(Package package) async {
  try {
    await Purchases.purchasePackage(package);
    // The customer info listener fires and the UI updates to "pro".
  } on PlatformException catch (e) {
    final code = PurchasesErrorHelper.getErrorCode(e);
    if (code == PurchasesErrorCode.purchaseCancelledError) return; // user backed out
    if (code == PurchasesErrorCode.paymentPendingError) {
      showMessage('Your payment is pending. We’ll unlock Pro as soon as it clears.');
      return;
    }
    showMessage('Purchase failed. Please try again.');
  }
}

Two cases people often forget:

  • Cancelled is not an error. Don’t show a red error dialog when the user simply closed the purchase sheet.
  • Pending payments exist. Some payment methods (and “Ask to Buy” for family accounts on iOS) leave a purchase pending for hours. The listener will fire when it completes.

Step 5: Restore purchases

Apple requires a visible Restore Purchases button, and your app will be rejected without one. It’s a single call:

final info = await Purchases.restorePurchases();
final restored = info.entitlements.active.containsKey('pro');

Tell the user the outcome either way. “No previous purchases found” is much better than a button that appears to do nothing.

Step 6: Keep your backend in sync

If your server also needs to know who’s subscribed — for example, to allow API access or to show status in an admin dashboard — use RevenueCat’s webhooks rather than trusting the app. The app can tell your server “I just subscribed”, but a webhook from RevenueCat is authoritative, and it also covers renewals, cancellations and refunds that happen while the app is closed.

Testing subscriptions without losing your mind

  • iOS: use a StoreKit configuration file in Xcode for fast local testing, then Sandbox testers for end-to-end runs. Sandbox subscriptions renew on an accelerated schedule, so a monthly plan renews every few minutes — useful for testing renewal and expiry.
  • Android: add license testers in Play Console and upload a build to an internal testing track. Purchases won’t work from a debug build that hasn’t been uploaded at least once.
  • RevenueCat dashboard: check the customer history for your test user after each purchase. If something looks wrong there, it’s wrong in the app too.
  • Test the expired state as carefully as the purchased state. The day a subscription lapses is when bugs in feature-gating show up.

Store review tips

Most of my subscription-related rejections have been about the paywall, not the code:

  • Show the price, billing period and that it auto-renews, clearly, near the purchase button.
  • Link to your Terms of Use and Privacy Policy from the paywall.
  • Include a Restore Purchases button.
  • If there’s a free trial, state what happens when it ends.

Takeaways

  • Model access with entitlements; keep product IDs out of your feature logic.
  • Log users in to RevenueCat so subscriptions follow them across devices.
  • Treat cancellation and pending payments as normal outcomes.
  • Use webhooks for anything your backend needs to know.
  • Test expiry and restore as carefully as purchase.

Monetization is one of the areas where getting the details right directly affects revenue. If you’re adding subscriptions to a Flutter app, I’m happy to help.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).