Skip to content
    Field notes

    Flutter subscriptions with RevenueCat and Superwall, without the rewrite

    How to wire in-app subscriptions so the paywall design and the entitlement logic can change independently — and what breaks if you let them touch.

    4 min read

    Almost every subscription bug I have had to untangle in a Flutter app came from the same root cause: the code that decides whether the user has access and the code that decides what the paywall looks like were the same code.

    Keep them apart and both problems get easy. Here is what that looks like in practice.

    The two jobs, and why they are different jobs

    RevenueCat answers one question: does this user have the entitlement? It talks to StoreKit and Google Play Billing, validates receipts server-side, handles restores, deals with grace periods and billing retries, and gives you a boolean you can trust across devices and reinstalls.

    Superwall answers a different one: which paywall should this user see right now? Design, copy, pricing display, which A/B variant, whether to show one at all. All of it remotely configurable, so changing your paywall does not mean an App Store release.

    They are not competitors, and using both is not redundancy. The failure mode is using one to do the other's job — putting entitlement checks in paywall code, or hardcoding paywall layout behind a RevenueCat offering.

    One gate, everywhere

    Every access check in the app goes through a single provider. Not a mixin, not a helper on BuildContext, not a static — one provider, because you need to be able to override it in tests and in the simulator.

    @Riverpod(keepAlive: true)
    Stream<bool> hasProAccess(Ref ref) {
      return Purchases.addCustomerInfoUpdateListener
          .asStream() // see note below
          .map((info) => info.entitlements.active.containsKey('pro'));
    }
    

    In practice you will wrap RevenueCat's listener in a small repository so this stays a one-line provider, but the shape is the point: one string literal for the entitlement id, in one file. 'pro' appearing in fourteen widgets is how you end up with a screen that stays locked for paying customers after someone renames an entitlement in the dashboard.

    Note the keepAlive. This is the rare provider that genuinely should outlive every screen — entitlement state is app-scoped, and re-fetching it on every navigation is both slow and pointless.

    Never gate on the purchase call

    This is the mistake that produces support emails.

    // Wrong. The user paid; the UI has no idea.
    final result = await Purchases.purchase(package);
    if (result != null) unlockTheFeature();
    

    The purchase call returning successfully is not the same event as the entitlement becoming active, and the two can diverge: a purchase made on another device, a restore, a family-sharing grant, a subscription that renews while the app is backgrounded, or a refund that revokes access. If your UI only unlocks in response to the purchase call, none of those work.

    Gate on the stream. The purchase call's only job is to start the transaction; the listener is what tells you the state actually changed.

    // Right. The purchase starts a transaction; the gate reacts to the outcome.
    ref.watch(hasProAccessProvider).maybeWhen(
      data: (hasAccess) => hasAccess ? const ProFeature() : const LockedFeature(),
      orElse: () => const LoadingView(),
    );
    

    Present paywalls by intent, not by screen

    Superwall's placements work best when the string describes why the paywall is showing, not where.

    Superwall.shared.registerPlacement(
      'export_document',
      feature: () => exportDocument(),
    );
    

    'export_document' survives a redesign. 'settings_screen_upgrade_button' does not — and the moment you move that button, your conversion history for that placement becomes meaningless.

    The feature callback is the part worth understanding: Superwall only runs it if the user is entitled, either because they already were or because they just converted on the paywall it showed. You do not check anything. That is the whole point — the check is centralised in the gate, and Superwall consults it.

    Configure both before the first frame that could need them

    Future<void> main() async {
      WidgetsFlutterBinding.ensureInitialized();
    
      await Purchases.configure(
        PurchasesConfiguration(revenueCatApiKey),
      );
    
      // Superwall reads entitlement state from RevenueCat, so order matters.
      await Superwall.configure(superwallApiKey);
    
      runApp(const ProviderScope(child: App()));
    }
    

    If Superwall configures first, its first placement evaluation can run against unknown entitlement state and show a paywall to someone who already pays. Once is enough to get a one-star review.

    Testing without the store

    You cannot buy anything in a widget test, and you should not need to. Because access is one provider, the override is one line:

    ProviderScope(
      overrides: [hasProAccessProvider.overrideWith((ref) => Stream.value(true))],
      child: const App(),
    )
    

    Wire this to a debug menu too. Being able to flip entitlement on and off in a running debug build, without a sandbox account, is worth the twenty minutes it takes to add — it is the difference between testing your locked states routinely and testing them never.

    The checks worth doing before you ship

    • Restore purchases exists and is findable. App Review rejects for this specifically, and it is usually the one screen nobody remembered to build.
    • Locked states are designed. Not a disabled button — a state that explains what is behind the gate and offers a way through it.
    • Sandbox renewals tested. Sandbox subscriptions renew in minutes, not months. Leave one running through several cycles and watch what your gate does when it expires.
    • Price display comes from the store. Never hardcode "$4.99". It is wrong in every other currency, and Apple rejects mismatched pricing.