+1 (415) 480-3939

In-App Purchases and Subscriptions in Flutter: StoreKit 2, Play Billing, and Entitlements You Can Trust

Subscriptions are where a lot of otherwise-solid Flutter apps quietly leak money. The UI ships, the paywall renders, and then six months later someone notices that a few percent of users have entitlements they never paid for, a batch of renewals never unlocked anything, and nobody can answer "how many active subscribers do we have?" without exporting two different consoles and reconciling by hand.

Billing changed enough in 2024-2026 that a lot of the tutorials you will find are wrong: StoreKit 2 replaced receipt-file validation with signed transactions on iOS, Google Play Billing moved to the 7.x/8.x model with subscriptions split into base plans and offers, and the US App Store anti-steering ruling opened the door to external purchase links for some apps. This tutorial is how we wire monetization on client engagements: what to build in Dart, what must live on a server, and the failure modes to test before launch.

Decide first: in_app_purchase or RevenueCat?

Both are defensible. Pick on the basis of who will own the backend.

Use in_app_purchase (the official Flutter plugin) when you already have a backend team, you need entitlements to live in your own database next to the rest of your domain model, or you have compliance reasons not to send purchase data to a third party. You are signing up to write and operate a validation service and to handle App Store Server Notifications and Google Real-Time Developer Notifications yourself.

Use RevenueCat (or a similar service) when the team is small, you want cross-platform entitlement state and subscriber analytics on day one, and you would rather not run a webhook endpoint with retry semantics. The cost is a revenue share above a free tier and a vendor in your purchase path.

The rest of this article uses in_app_purchase because it shows the whole mechanism. Everything about entitlement modelling and testing applies either way.

The rule that keeps you out of trouble

The client never decides what a user owns. The client's job is to launch the purchase sheet and hand the resulting transaction to your server. The server validates it against Apple/Google, writes an entitlement row, and the app reads the entitlement back.

If your app unlocks Pro because purchaseDetails.status == PurchaseStatus.purchased, you have shipped a feature flag that anyone with a rooted device can flip. Worse, you have no record of the purchase, so when a renewal fails or a refund lands you find out from a support ticket.

App  --(buy)-->  Store sheet
App  --(signed transaction / purchase token)-->  Your API  --(verify)-->  Apple / Google
Your API  --(entitlement row)-->  App reads entitlements
Apple/Google  --(server notifications)-->  Your API   <- renewals, refunds, grace, expiry

That last arrow is the one teams skip, and it is the one that keeps state correct for the 95% of a subscription's life when the app is not running.

Setup

dependencies:
  in_app_purchase: ^3.2.0
  in_app_purchase_storekit: ^0.3.19
  in_app_purchase_android: ^0.3.6

On iOS, define products in App Store Connect and add a StoreKit configuration file to the Xcode scheme so you can test in the simulator without a sandbox account. On Android, create a subscription with base plans and offers in Play Console, and remember that the base plan ID — not the product ID alone — is what you buy.

Enable StoreKit 2 explicitly; the plugin still supports the older path and you do not want it:

import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart';
import 'package:in_app_purchase_storekit/store_kit_2_wrappers.dart';

Future<void> configureIap() async {
  if (defaultTargetPlatform == TargetPlatform.iOS) {
    InAppPurchaseStoreKitPlatform.enableStoreKit2();
  }
}

Loading products

Never hardcode prices in the UI. Store-returned localized price strings are a store requirement in several markets and they are the only way to get currency and tax presentation right.

class Products {
  static const monthly = 'pro_monthly';
  static const annual = 'pro_annual';
  static const ids = {monthly, annual};
}

Future<List<ProductDetails>> loadProducts() async {
  final iap = InAppPurchase.instance;
  if (!await iap.isAvailable()) {
    throw const StoreUnavailable();
  }
  final response = await iap.queryProductDetails(Products.ids);
  if (response.notFoundIDs.isNotEmpty) {
    // Nearly always a config mistake: wrong bundle id, product not
    // "Ready to Submit", or a build signed with the wrong profile.
    log('Missing products: ${response.notFoundIDs}');
  }
  return response.productDetails;
}

A paywall that shows a spinner forever is almost always notFoundIDs. Surface it in your logs on day one.

The purchase stream is not a request/response

purchaseStream is a replay of everything the store wants to tell you, including purchases made on another device, purchases that completed while your app was killed, and pending purchases waiting on a parent's approval. Subscribe once, at app start, above your widget tree — not inside the paywall screen.

class PurchaseService {
  PurchaseService(this._api);
  final BillingApi _api;
  late final StreamSubscription<List<PurchaseDetails>> _sub;

  void start() {
    _sub = InAppPurchase.instance.purchaseStream.listen(
      _onPurchases,
      onError: (e, s) => log('purchaseStream error', error: e, stackTrace: s),
    );
  }

  Future<void> _onPurchases(List<PurchaseDetails> purchases) async {
    for (final p in purchases) {
      switch (p.status) {
        case PurchaseStatus.pending:
          _showPendingBanner();
        case PurchaseStatus.error:
          _reportPurchaseError(p.error);
        case PurchaseStatus.purchased:
        case PurchaseStatus.restored:
          final ok = await _verifyWithServer(p);
          if (!ok) break; // do NOT complete: let the store re-deliver it
      }
      if (p.pendingCompletePurchase) {
        await InAppPurchase.instance.completePurchase(p);
      }
    }
  }
}

Two details in that block matter more than the rest of the file.

Only call completePurchase after your server has durably recorded the transaction. Completing acknowledges delivery. On Android, a purchase that is not acknowledged within three days is automatically refunded — which sounds like a safety net until you realise the reverse case: acknowledge before you have persisted anything and the transaction is gone from the queue forever, along with the user's money.

Never skip pendingCompletePurchase. Unfinished transactions are redelivered on every launch, so a bug here shows up as an infinite purchase loop, not as a silent failure.

Server-side verification

The client sends what it has; the server decides what it means.

Future<bool> _verifyWithServer(PurchaseDetails p) async {
  final payload = {
    'platform': Platform.isIOS ? 'ios' : 'android',
    'productId': p.productID,
    // StoreKit 2: JWS signed transaction. Play: purchase token.
    'token': p.verificationData.serverVerificationData,
    'localTxId': p.purchaseID,
  };
  final res = await _api.verify(payload).timeout(const Duration(seconds: 20));
  return res.entitlementActive;
}

On the server:

  • iOS — verify the JWS signature against Apple's root certificates, then call the App Store Server API to fetch the current subscription status. Do not trust the decoded payload alone; verify the chain.
  • Android — call purchases.subscriptionsv2.get with the purchase token using a service account, then acknowledge server-side if you did not acknowledge on the client.

Store the raw transaction, the derived entitlement (user_id, product, expires_at, state), and the original transaction ID. That last field is the stable key that ties every renewal of a subscription back to one purchase.

The part everyone forgets: server notifications

Renewals, cancellations, billing retries, grace periods, refunds and upgrades all happen while the app is closed. Register an App Store Server Notifications V2 endpoint and a Play RTDN Pub/Sub topic, and treat them as the primary source of entitlement truth. Client verification is just the fast path that unlocks features immediately after purchase.

Handle at minimum: DID_RENEW, EXPIRED, GRACE_PERIOD_EXPIRED, REFUND, DID_CHANGE_RENEWAL_STATUS on iOS; SUBSCRIPTION_RENEWED, SUBSCRIPTION_CANCELED, SUBSCRIPTION_EXPIRED, SUBSCRIPTION_IN_GRACE_PERIOD, SUBSCRIPTION_REVOKED on Android. Make the handler idempotent — both platforms redeliver.

Entitlements in the app

Expose entitlement state as a stream the whole app reads, cached locally so a cold start offline does not lock a paying user out of the product they bought.

@riverpod
Stream<Entitlement> entitlement(EntitlementRef ref) async* {
  yield await ref.watch(localStoreProvider).cachedEntitlement(); // instant
  yield* ref.watch(apiProvider).entitlementStream();             // authoritative
}

// In the UI
final ent = ref.watch(entitlementProvider).valueOrNull;
if (ent?.isActive ?? false) { ...pro feature... }

Give the cached entitlement a grace window — we typically honour a cached "active" state for up to 7 days offline, then degrade. Locking out a subscriber on a plane is a refund and a one-star review.

Restore, and why it is not optional

Apple rejects apps without a visible restore path. Restoring emits PurchaseStatus.restored events through the same stream:

await InAppPurchase.instance.restorePurchases();

If your entitlements are keyed to an account on your server, restore is mostly a no-op — signing in restores access. Key entitlements to accounts wherever the product allows it; store-only entitlements are unrecoverable when a user changes platform.

External payment links, carefully

Following the 2025 US ruling, App Store apps may in some cases link out to external purchase flows in the US storefront without Apple's commission, and Google has been ordered to relax similar restrictions. This is genuinely useful for subscription businesses, but it is jurisdiction- and entitlement-dependent, the rules keep moving, and getting it wrong is a review rejection or worse. If you go there: gate the link by storefront, keep an in-app purchase option available, and have someone read the current App Review Guidelines the week you submit — not the week you wrote the code.

Testing before you ship

  1. StoreKit config file in the simulator. Fast iteration for paywall UI, upgrade/downgrade flows, and renewal acceleration.
  2. Sandbox with time compression. An iOS sandbox monthly subscription renews every five minutes; watch renewals land in your database via notifications.
  3. Play internal testing track with licence testers. Test the deferred/pending path — Play's "slow test card" — which is where pending purchases actually appear.
  4. Kill the app mid-purchase. Relaunch and confirm the transaction is redelivered, verified and completed exactly once.
  5. Fail the server. Make /verify return 500 and confirm the app does not complete the purchase and recovers on the next launch.
  6. Refund. Issue one from the console and confirm the entitlement is revoked by webhook, not by a support ticket.

Automate what you can: put a fake BillingApi behind the same interface and write widget tests for paywall, pending, error and restored states so a paywall regression fails CI instead of production.

What good looks like

A monetization implementation is finished when you can answer three questions from your own database: who has an active entitlement right now, why (which transaction), and what will happen to it next (renews, expires, in grace). If any of those answers requires opening App Store Connect, the wiring is incomplete — and the gap will show up as revenue you cannot reconcile.

If you are building or auditing subscriptions in a Flutter app, our consultants do this end to end, client plugin through webhook handlers. Get in touch and tell us where the money is going missing.