Flutter In-App Purchases and Subscriptions with Server-Side Validation

TQ
Tran Quang
October 30, 2026 · 7 min read
#Flutter#In-App Purchase#Subscriptions#.NET

Selling subscriptions in a Flutter app looks simple in the first tutorial and gets complicated in production. Receipts expire, users restore purchases on new devices, refunds arrive hours later and a rejected review can block a release. This guide shows the architecture that holds up: client purchase flow with the in_app_purchase package, server-side receipt validation, and store notifications that keep your backend as the source of truth.

The core rule: the server decides entitlement

The app should never be the final authority on whether a user is subscribed. A client can be tampered with, offline or out of date. Use this model:

  1. The app starts a purchase through the store.
  2. The store returns a purchase with proof (a receipt or purchase token).
  3. The app sends that proof to your backend.
  4. The backend validates it with Apple or Google and stores the entitlement.
  5. The app reads the entitlement from your backend and unlocks features.
  6. Store server notifications update the entitlement on renewals, cancellations and refunds.

Services such as RevenueCat implement this pipeline for you. If you build it yourself, the rest of this guide is what you need.

Step 1: Set up products

Create matching products in both stores and keep product IDs identical where possible, for example premium_monthly and premium_yearly:

  • App Store Connect: create an auto-renewable subscription group, add the products, localisation, price and a review screenshot.
  • Google Play Console: create subscriptions with base plans and offers.

Products must be approved or active before they appear in the app. Test with sandbox testers on iOS and licence testers on Android.

Step 2: Load products and start a purchase

Add the official plugin:

yaml
dependencies:
  in_app_purchase: ^3.2.0

Wrap the plugin behind your own service so the rest of the app does not depend on it:

dart
import 'dart:async';
import 'package:in_app_purchase/in_app_purchase.dart';

class BillingService {
  BillingService(this._api);

  final BillingApi _api; // talks to your backend
  final InAppPurchase _iap = InAppPurchase.instance;
  StreamSubscription<List<PurchaseDetails>>? _sub;

  static const _productIds = {'premium_monthly', 'premium_yearly'};

  Future<List<ProductDetails>> loadProducts() async {
    if (!await _iap.isAvailable()) return [];
    final response = await _iap.queryProductDetails(_productIds);
    return response.productDetails;
  }

  void listen() {
    _sub = _iap.purchaseStream.listen(
      _onPurchases,
      onError: (Object e) {
        // log to crash reporting
      },
    );
  }

  Future<void> buy(ProductDetails product) {
    final param = PurchaseParam(productDetails: product);
    // Subscriptions are bought with buyNonConsumable.
    return _iap.buyNonConsumable(purchaseParam: param);
  }

  Future<void> restore() => _iap.restorePurchases();

  void dispose() => _sub?.cancel();
}

Start listening to purchaseStream as soon as the app launches. Purchases can arrive when the app starts, for example when a payment completed while the app was closed.

Step 3: Handle purchase updates and always complete them

dart
// Needs: import 'dart:io' show Platform;
Future<void> _onPurchases(List<PurchaseDetails> purchases) async {
  for (final p in purchases) {
    switch (p.status) {
      case PurchaseStatus.pending:
        // Show a pending state; do not unlock anything yet.
        break;

      case PurchaseStatus.error:
        // Show a friendly message; log p.error for debugging.
        break;

      case PurchaseStatus.purchased:
      case PurchaseStatus.restored:
        final ok = await _api.verify(
          platform: Platform.isIOS ? 'ios' : 'android',
          productId: p.productID,
          proof: p.verificationData.serverVerificationData,
        );
        if (!ok) continue; // do not complete; the store will redeliver
        break;

      case PurchaseStatus.canceled:
        break;
    }

    if (p.pendingCompletePurchase) {
      await _iap.completePurchase(p);
    }
  }
}

Two details matter here:

  • Call completePurchase after you have processed the purchase. If you do not, the store treats it as unfinished and Google Play refunds unacknowledged purchases after three days.
  • Do not unlock on purchased alone. Unlock after your backend confirms the entitlement.

Step 4: Validate on the server

On iOS, send the receipt data to your backend, which validates it with the App Store Server API (preferred) and reads the transaction details. On Android, send the purchase token and verify it with the Google Play Developer API.

A backend endpoint in .NET could look like this (simplified):

csharp
app.MapPost("/billing/verify", async (VerifyRequest req, ClaimsPrincipal user,
    IStoreValidator validator, IEntitlementStore store) =>
{
    var userId = user.GetUserId();

    // Calls Apple or Google and returns product, expiry and a stable transaction id
    var result = await validator.ValidateAsync(req.Platform, req.ProductId, req.Proof);
    if (!result.IsValid) return Results.BadRequest();

    await store.UpsertAsync(new Entitlement
    {
        UserId = userId,
        ProductId = result.ProductId,
        ExpiresAt = result.ExpiresAt,
        OriginalTransactionId = result.OriginalTransactionId,
        Platform = req.Platform,
    });

    return Results.Ok(new { active = result.ExpiresAt > DateTimeOffset.UtcNow });
}).RequireAuthorization();

Key points:

  • Store the original transaction id (iOS) or purchase token (Android). It links later notifications to the right user.
  • Make the endpoint idempotent. The same purchase can be sent more than once.
  • Never trust the product ID or price sent by the client; read them from the store's response.

Step 5: Keep entitlements current with server notifications

Subscriptions change without the app being open. Configure store notifications so your backend learns about them:

  • Apple: App Store Server Notifications (V2) for renewals, expirations, billing retries, refunds and grace periods.
  • Google: Real-time developer notifications through Pub/Sub for the same lifecycle events.

On each notification, look up the entitlement by transaction id or purchase token, re-fetch the current state from the store API and update expiry. This is what makes refunds and cancellations reflect in the app correctly.

Step 6: Restore purchases and cross-device behaviour

Apple requires a visible Restore Purchases action. Call restorePurchases() and run restored purchases through the same verification path. Because the entitlement is tied to your user account on the server, a user who signs in on a new phone gets access without a new purchase, even before restoring.

Decide how you handle a subscription bought with one store account and used on another user account in your app. Common choices are linking the subscription to the first account that verified it, or allowing the user to transfer it explicitly.

Common pitfalls

  • Testing only the happy path. Test cancel, pending payments, an expired subscription, a failed renewal, a refund and a restore on a second device.
  • Unlocking from the client cache. Cache the entitlement for offline use, but refresh it from the server at launch and treat an expiry date as authoritative.
  • Different product IDs per platform with no mapping. Keep a single mapping table in the backend.
  • Not handling the "billing retry" and "grace period" states. Decide whether users keep access during grace periods and apply it consistently.
  • Forgetting store rules. Review the current requirements for subscription disclosure, price display and account deletion before you submit.
  • Letting an SDK leak everywhere. Keep billing behind one service so you can swap the plugin or move to a managed provider later.

When to use RevenueCat instead

Building validation and notification handling yourself is manageable, but it is real ongoing work: store API changes, edge cases and analytics. A managed provider is worth considering when you need:

  • Subscriptions on both stores and web quickly
  • Experiments with pricing and paywalls
  • Subscription analytics without building dashboards

Evaluate cost against the engineering time you would otherwise spend, and abstract the provider behind your own interface either way.

Conclusion

A reliable subscription system in Flutter has three parts: a thin client built on in_app_purchase that always completes purchases, a backend that validates proof with Apple and Google and stores entitlements, and store notifications that keep those entitlements correct over time. Treat the server as the source of truth and test the unhappy paths, and billing stops being the part of the app that breaks at midnight. If you need help designing or auditing a billing flow, see the services page.

References

Found this useful?

Share it with someone who might need it.

Related articles All articles