← 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 Localizing Dynamic Content in Flutter: A Guide to Backend-Driven Translations

Localizing Dynamic Content in Flutter: A Guide to Backend-Driven Translations

Many Flutter apps need to display content that changes based on user locale, but also comes from a backend (like Firebase). This post will explore best practices for fetching and integrating dynamic, localized content from a backend, ensuring a seamless user experience across different languages and regions without hardcoding translations.

Cover image for Unraveling Type Mismatch Errors in Flutter: A Guide to 'X can't be assigned to Y' and '_InternalLinkedHashMap' Issues

Unraveling Type Mismatch Errors in Flutter: A Guide to 'X can't be assigned to Y' and '_InternalLinkedHashMap' Issues

Developers frequently encounter cryptic type mismatch errors like 'The argument type X can't be assigned to the parameter type Y' or '_InternalLinkedHashMap has no instance method 'cast''. This post will demystify these common Flutter/Dart type errors, explain their root causes (e.g., conflicting imports, dynamic typing pitfalls, JSON deserialization issues), and provide practical solutions to diagnose and fix them, improving code robustness and reducing debugging time.