← Back to posts Cover image for Mastering Flutter Billing: Beyond Stripe Payments to Robust Subscription Logic

Mastering Flutter Billing: Beyond Stripe Payments to Robust Subscription Logic

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

So you’ve integrated Stripe (or another payment gateway) into your Flutter app. The payment sheet works beautifully, the checkout completes, and the money arrives. But now the real challenge begins: how do you reliably grant, update, and revoke access based on that payment? How do you handle subscription renewals, cancellations, and one-time credits without leaving users stranded or giving away paid features for free?

The hard part isn’t processing the payment—it’s building the robust logic that manages user entitlements after the payment succeeds. Let’s break down a practical approach.

The Core Problem: State Synchronization

Your payment gateway (e.g., Stripe) and your application backend live in different worlds. A user’s subscription status can change at any moment: a renewal succeeds, a payment fails, a user cancels, or a refund is issued. Your app needs to reflect these changes accurately and immediately.

The naive approach is to rely solely on webhook events from your payment provider to update your database. This is fragile. Webhooks can be delayed, sent out of order, or even fail to arrive. If your webhook handler crashes while processing an invoice.paid event, the user might never get their premium access.

A Robust Pattern: The Source of Truth Check

The solution is to treat webhooks as signals, not commands. When a webhook arrives, use it as a prompt to verify the current state with the payment gateway’s API, then update your system accordingly. This pattern ensures consistency even if webhooks are missed or processed incorrectly.

Here’s a conceptual flow:

  1. User completes a purchase in your Flutter app.
  2. Your backend creates a record (e.g., PurchaseIntent with status processing).
  3. A webhook arrives (e.g., checkout.session.completed).
  4. Your webhook handler fetches the latest subscription data directly from Stripe’s API using the ID from the webhook.
  5. Your backend reconciles this data with your user’s access record, updating their isActive, planId, credits, etc.
  6. Your Flutter app reflects this state, either via polling, WebSockets, or by fetching fresh user data on key app events.

Implementing the Reconciliation Logic

Let’s look at a simplified backend service (in Dart, suitable for a server-side Shelf or Dart Frog backend) that handles a subscription webhook. Notice how it calls the Stripe API to get the definitive status.

class SubscriptionService {
  final StripeApi stripeApi;
  final UserRepository userRepo;

  Future<void> handleSubscriptionUpdated(String stripeSubscriptionId) async {
    // 1. Fetch the true current state from the source of truth (Stripe)
    final Subscription subscription = await stripeApi.fetchSubscription(stripeSubscriptionId);

    // 2. Find the associated user in your database
    // You should store the stripeSubscriptionId and/or stripeCustomerId in your user model.
    final user = await userRepo.findByStripeSubscriptionId(stripeSubscriptionId);
    if (user == null) {
      // Log error: This should not happen if your data linking is set up correctly.
      return;
    }

    // 3. Reconcile the status
    final String newPlanId = subscription.items.data.first.price.id;
    final bool isCurrentlyActive = subscription.status == 'active' || subscription.status == 'trialing';

    // 4. Update your database with the verified state
    await userRepo.updateSubscription(
      userId: user.id,
      isActive: isCurrentlyActive,
      currentPlanId: newPlanId,
      subscriptionPeriodEnd: DateTime.fromMillisecondsSinceEpoch(subscription.currentPeriodEnd * 1000),
    );

    // 5. Trigger any side effects (e.g., grant cloud credits, send welcome email)
    if (isCurrentlyActive && user.currentPlanId != newPlanId) {
      await grantPlanBenefits(user.id, newPlanId);
    }
  }
}

Flutter App State Management

In your Flutter app, you need a way to fetch and listen to this subscription state. Don’t store the access state only locally; always verify with your backend.

class UserAuthProvider with ChangeNotifier {
  User? _currentUser;
  SubscriptionInfo? _subscription;

  Future<void> loadUserProfile() async {
    // Fetch the latest user data, including subscription info, from your backend
    final profile = await ApiService.fetchUserProfile();
    _currentUser = profile.user;
    _subscription = profile.subscription;

    // If the UI depends on subscription access, notify listeners
    notifyListeners();
  }

  bool get hasActivePremium => _subscription?.isActive == true && _subscription?.planId == 'premium_monthly';

  // Call this on app start and after any purchase flow
  Future<void> refreshSubscriptionStatus() async {
    await loadUserProfile();
  }
}

Call refreshSubscriptionStatus() at strategic points: on app startup, when the app resumes from the background, and after returning from your checkout flow.

Common Mistakes to Avoid

  • Trusting Webhook Payloads Blindly: Never update your database based only on the webhook JSON. Use the IDs provided to fetch fresh data via API.
  • Forgetting Edge Cases: What happens on a past_due status? What about a canceled subscription that is still within its paid period? Your logic must handle all subscription statuses.
  • Client-Side Only Checks: Never gate feature access solely on a boolean stored in SharedPreferences. A determined user can alter this. Your backend must enforce all access rules.
  • Poor Data Linking: Ensure you reliably link Stripe objects (customer.id, subscription.id) to your internal user IDs, often via metadata when creating the Checkout Session.

Handling Credits and Usage Tracking

For features like monthly renewable credits, your backend should manage the counter. Reset credits on the renewal date (which you get from the subscription’s current_period_end), and decrement them on use. Track usage in your database, not in the payment gateway.

Building robust subscription logic requires shifting your mindset. The payment is just the beginning. By using webhooks as triggers for a verification process and keeping your backend as the single manager of entitlements, you can create a billing system that is both reliable and scalable. This approach, while requiring more upfront work, saves endless support headaches and ensures your users have a seamless experience.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Flutter App Size Optimization: Strategies to Shrink Your APK/IPA Below 10MB

Flutter App Size Optimization: Strategies to Shrink Your APK/IPA Below 10MB

Flutter apps are often criticized for their larger-than-native binary sizes. This post will dive into practical, actionable strategies to significantly reduce your Flutter app's footprint, including build configurations, asset optimization, and dependency management, demonstrating how to achieve app sizes under 10MB.

Cover image for Mastering In-App Subscriptions in Flutter: A Guide to Avoiding Common Pitfalls

Mastering In-App Subscriptions in Flutter: A Guide to Avoiding Common Pitfalls

Implementing in-app subscriptions in Flutter can be surprisingly complex, especially when dealing with cross-platform nuances, receipt validation, and managing different store configurations. This post will delve into the common challenges developers face, compare solutions like RevenueCat with custom implementations, and provide best practices for setting up and maintaining a robust subscription system.

Cover image for Taming Android Emulator RAM: A Developer's Guide to Reducing Memory Usage for Flutter

Taming Android Emulator RAM: A Developer's Guide to Reducing Memory Usage for Flutter

Android emulators are notorious for hogging RAM, often pushing developer machines into swap and slowing down Flutter development. This post will explore the reasons behind high emulator memory consumption and provide practical, actionable strategies and tools (including a custom-built solution) to significantly reduce RAM usage, ensuring a smoother development experience without sacrificing functionality.