Development

Building E-Commerce Apps with Flutter & Stripe — The Complete 2026 Guide

Muhammad Shakil Muhammad Shakil
Feb 21, 2026
30 min read
Building E-Commerce Apps with Flutter and Stripe
Back to Blog

Building E-Commerce Apps with Flutter & Stripe — The Complete 2026 Guide

Mobile e-commerce revenue crossed $2.5 trillion globally in 2025, and the number keeps climbing. If you're building a shopping app, marketplace, or any product that accepts money, Flutter + Stripe is one of the most battle-tested stacks you can choose. Flutter gives you a single codebase that runs on iOS, Android, and the web. Stripe gives you a PCI-Level-1-certified payments infrastructure that handles everything from simple card charges to complex subscriptions, multi-currency payouts, and fraud detection.

This guide is not a quick-start tutorial. It's the complete reference we wish existed when we started building e-commerce apps at Flutter Studio — covering architecture decisions, production code patterns, Apple Pay and Google Pay, webhooks, refunds, multi-currency, testing, and the gotchas that only surface in production. Every code sample has been extracted from live apps processing real transactions.

🚀 What You'll Build

By the end of this guide you'll have a production-grade Flutter e-commerce checkout flow with a Node.js backend, Stripe Payment Sheet, Apple Pay / Google Pay, subscriptions, webhooks, saved cards, and comprehensive error handling.

1. Why Flutter + Stripe for E-Commerce?

Before we write a single line of code, let's establish why this stack wins:

Compared to alternatives like pay (which only wraps native Pay APIs) or razorpay_flutter (limited to India), flutter_stripe gives you the broadest coverage with the least custom code.

2. Architecture Overview — Client vs Server

The most common mistake we see in Flutter payment tutorials is putting too much logic on the client. Stripe's model is server-driven by design:

┌──────────────────┐ ┌──────────────────┐ ┌────────────────┐
│ Flutter Client │──────▶│ Your Backend │──────▶│ Stripe API │
│ (flutter_stripe) │◀──────│ (Node / Dart) │◀──────│ │
└──────────────────┘ └──────────────────┘ └────────────────┘
 ▲ │ ▲
 │ clientSecret │ │ webhooks
 └───────────────────────────┘ │
 ▼
 ┌──────────────┐
 │ Database │
 └──────────────┘

Key responsibilities at each layer:

Layer Responsibilities
Flutter Client Product UI, cart, call your backend for payment intents, show Stripe's Payment Sheet or CardField, display results
Your Backend Create PaymentIntents, Customers, Subscriptions; validate amounts; manage orders; receive webhooks
Stripe Process payments, handle 3D Secure, manage fraud, send webhook events, store payment methods

⚠️ Golden Rule

Never create PaymentIntents from the Flutter app. The client only receives a clientSecret from your backend. If you call stripe.paymentIntents.create() from the client, you've exposed your secret key — game over for security.

3. Project Setup & Dependencies

Create a new Flutter project and add these dependencies to your pubspec.yaml:

dependencies:
 flutter:
 sdk: flutter

 # Stripe — official Flutter SDK
 flutter_stripe: ^11.2.0 # https://pub.dev/packages/flutter_stripe

 # Networking
 dio: ^5.7.0 # https://pub.dev/packages/dio

 # State management
 flutter_riverpod: ^2.6.1 # https://pub.dev/packages/flutter_riverpod

 # Environment variables
 flutter_dotenv: ^5.2.1 # https://pub.dev/packages/flutter_dotenv

 # Local storage for cart persistence
 hive_flutter: ^1.1.0 # https://pub.dev/packages/hive_flutter

 # Image loading
 cached_network_image: ^3.4.1 # https://pub.dev/packages/cached_network_image

 # Currency formatting
 intl: ^0.19.0 # https://pub.dev/packages/intl

Run flutter pub get and ensure your minimum SDK constraints:

environment:
 sdk: ">=3.5.0 <4.0.0"
 flutter: ">=3.24.0"

Platform-Specific Configuration

iOS — ios/Runner/Info.plist

<!-- Required for Apple Pay -->
<key>com.apple.developer.in-app-payments</key>
<array>
 <string>merchant.com.yourcompany.app</string>
</array>

Also set your minimum iOS deployment target to 13.0 in ios/Podfile:

platform :ios, '13.0'

Android — android/app/build.gradle

android {
 compileSdk 35
 defaultConfig {
 minSdk 21
 targetSdk 35
 }
}

dependencies {
 // Stripe requires this for Google Pay
 implementation 'com.google.android.gms:play-services-wallet:19.4.0'
}

And add the Google Pay meta-data to android/app/src/main/AndroidManifest.xml:

<application>
 <meta-data
 android:name="com.google.android.gms.wallet.api.enabled"
 android:value="true" />
</application>

4. Initializing Stripe in Flutter

Create a .env file at your project root (add it to .gitignore!):

STRIPE_PUBLISHABLE_KEY=pk_test_51ABC...
BACKEND_URL=https://api.yourstore.com

Then initialize Stripe before runApp():

import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_stripe/flutter_stripe.dart';

Future<void> main() async {
 WidgetsFlutterBinding.ensureInitialized();

 // Load environment variables
 await dotenv.load(fileName: '.env');

 // Configure Stripe
 Stripe.publishableKey = dotenv.env['STRIPE_PUBLISHABLE_KEY']!;
 Stripe.merchantIdentifier = 'merchant.com.yourcompany.app';
 Stripe.urlScheme = 'yourapp';
 await Stripe.instance.applySettings();

 runApp(
 const ProviderScope(child: MyApp()),
 );
}

The Stripe class is a singleton. Call applySettings() once and you're good for the entire app lifecycle. The urlScheme is needed for 3D Secure return URLs on iOS — it must match the URL scheme registered in your Info.plist.

5. Building the Backend Payment API

Your backend is the brain of the payment flow. Here's a minimal but production-ready Node.js server using Express:

// server.js
const express = require('express');
const Stripe = require('stripe');

const app = express();
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);

app.use(express.json());

// ─── Create or retrieve a Stripe Customer ───
app.post('/customers', async (req, res) => {
 try {
 const { email, name } = req.body;

 const customer = await stripe.customers.create({
 email,
 name,
 metadata: { source: 'flutter_app' },
 });

 res.json({ customerId: customer.id });
 } catch (err) {
 res.status(400).json({ error: err.message });
 }
});

// ─── Create a PaymentIntent ───
app.post('/payment-intents', async (req, res) => {
 try {
 const { amount, currency, customerId } = req.body;

 // Validate amount server-side — never trust the client
 if (!amount || amount < 50) {
 return res.status(400).json({ error: 'Amount must be at least 50 cents' });
 }

 const paymentIntent = await stripe.paymentIntents.create({
 amount, // in smallest currency unit (cents)
 currency: currency || 'usd',
 customer: customerId,
 automatic_payment_methods: { enabled: true },
 metadata: { integration: 'flutter_ecommerce' },
 });

 res.json({
 clientSecret: paymentIntent.client_secret,
 paymentIntentId: paymentIntent.id,
 });
 } catch (err) {
 res.status(400).json({ error: err.message });
 }
});

// ─── Create an Ephemeral Key (for Payment Sheet) ───
app.post('/ephemeral-keys', async (req, res) => {
 try {
 const { customerId } = req.body;

 const ephemeralKey = await stripe.ephemeralKeys.create(
 { customer: customerId },
 { apiVersion: '2024-12-18.acacia' },
 );

 res.json({ ephemeralKey: ephemeralKey.secret });
 } catch (err) {
 res.status(400).json({ error: err.message });
 }
});

app.listen(4242, () => console.log('Server running on port 4242'));

Three endpoints is all you need for a basic checkout. The PaymentIntent tells Stripe how much to charge and which customer to charge. The Ephemeral Key grants the Flutter app temporary access to the customer's saved payment methods.

💡 Dart Backend Alternative

Prefer Dart everywhere? Use shelf or dart_frog for your backend and call the Stripe API directly via dio. Stripe doesn't have an official Dart SDK, but the REST API is straightforward.

6. Product Catalog UI

A clean product catalog is the storefront of your app. Here's a responsive grid using GridView and cached_network_image:

class ProductGridScreen extends ConsumerWidget {
 const ProductGridScreen({super.key});

 @override
 Widget build(BuildContext context, WidgetRef ref) {
 final products = ref.watch(productsProvider);

 return Scaffold(
 appBar: AppBar(
 title: const Text('Shop'),
 actions: [
 // Cart icon with badge
 Stack(
 children: [
 IconButton(
 icon: const Icon(Icons.shopping_cart_outlined),
 onPressed: () => Navigator.pushNamed(context, '/cart'),
 ),
 Positioned(
 right: 4,
 top: 4,
 child: Consumer(
 builder: (_, ref, __) {
 final count = ref.watch(cartItemCountProvider);
 if (count == 0) return const SizedBox.shrink();
 return CircleAvatar(
 radius: 9,
 backgroundColor: Colors.red,
 child: Text(
 '$count',
 style: const TextStyle(fontSize: 11, color: Colors.white),
 ),
 );
 },
 ),
 ),
 ],
 ),
 ],
 ),
 body: products.when(
 data: (items) => GridView.builder(
 padding: const EdgeInsets.all(16),
 gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
 maxCrossAxisExtent: 220,
 childAspectRatio: 0.65,
 crossAxisSpacing: 12,
 mainAxisSpacing: 12,
 ),
 itemCount: items.length,
 itemBuilder: (_, i) => ProductCard(product: items[i]),
 ),
 loading: () => const Center(child: CircularProgressIndicator()),
 error: (e, _) => Center(child: Text('Error: $e')),
 ),
 );
 }
}

The ProductCard widget handles image loading, price display, and the "Add to Cart" button:

class ProductCard extends ConsumerWidget {
 final Product product;
 const ProductCard({required this.product, super.key});

 @override
 Widget build(BuildContext context, WidgetRef ref) {
 return Card(
 clipBehavior: Clip.antiAlias,
 child: InkWell(
 onTap: () => Navigator.pushNamed(
 context, '/product/${product.id}',
 ),
 child: Column(
 crossAxisAlignment: CrossAxisAlignment.start,
 children: [
 // Product image with cache
 Expanded(
 child: CachedNetworkImage(
 imageUrl: product.imageUrl,
 fit: BoxFit.cover,
 width: double.infinity,
 placeholder: (_, __) => Container(
 color: Colors.grey.shade200,
 child: const Center(child: CircularProgressIndicator()),
 ),
 errorWidget: (_, __, ___) => const Icon(Icons.broken_image),
 ),
 ),
 Padding(
 padding: const EdgeInsets.all(8),
 child: Column(
 crossAxisAlignment: CrossAxisAlignment.start,
 children: [
 Text(
 product.name,
 maxLines: 2,
 overflow: TextOverflow.ellipsis,
 style: const TextStyle(fontWeight: FontWeight.w600),
 ),
 const SizedBox(height: 4),
 Text(
 '\$${(product.priceInCents / 100).toStringAsFixed(2)}',
 style: TextStyle(
 color: Theme.of(context).primaryColor,
 fontWeight: FontWeight.bold,
 ),
 ),
 const SizedBox(height: 8),
 SizedBox(
 width: double.infinity,
 child: ElevatedButton.icon(
 icon: const Icon(Icons.add_shopping_cart, size: 16),
 label: const Text('Add'),
 onPressed: () {
 ref.read(cartProvider.notifier).addItem(product);
 ScaffoldMessenger.of(context).showSnackBar(
 SnackBar(content: Text('${product.name} added to cart')),
 );
 },
 ),
 ),
 ],
 ),
 ),
 ],
 ),
 ),
 );
 }
}

We use Riverpod for state management because its AsyncNotifierProvider handles loading / error / data states out of the box. See our Bloc vs Riverpod comparison for the full analysis.

7. Shopping Cart with State Management

The cart is the central nervous system of every e-commerce app. Here's a Riverpod-based cart that persists across app restarts using Hive:

Cart Model

class CartItem {
 final String productId;
 final String name;
 final int priceInCents;
 final String imageUrl;
 int quantity;

 CartItem({
 required this.productId,
 required this.name,
 required this.priceInCents,
 required this.imageUrl,
 this.quantity = 1,
 });

 int get totalCents => priceInCents * quantity;
}

Cart Notifier

class CartNotifier extends Notifier<List<CartItem>> {
 @override
 List<CartItem> build() => [];

 void addItem(Product product) {
 final existing = state.indexWhere((i) => i.productId == product.id);
 if (existing != -1) {
 state[existing].quantity++;
 state = [...state]; // trigger rebuild
 } else {
 state = [
 ...state,
 CartItem(
 productId: product.id,
 name: product.name,
 priceInCents: product.priceInCents,
 imageUrl: product.imageUrl,
 ),
 ];
 }
 }

 void removeItem(String productId) {
 state = state.where((i) => i.productId != productId).toList();
 }

 void updateQuantity(String productId, int quantity) {
 if (quantity <= 0) return removeItem(productId);
 final index = state.indexWhere((i) => i.productId == productId);
 if (index == -1) return;
 state[index].quantity = quantity;
 state = [...state];
 }

 void clear() => state = [];

 int get totalCents => state.fold(0, (sum, item) => sum + item.totalCents);
 int get itemCount => state.fold(0, (sum, item) => sum + item.quantity);
}

final cartProvider = NotifierProvider<CartNotifier, List<CartItem>>(
 CartNotifier.new,
);

final cartItemCountProvider = Provider<int>((ref) {
 return ref.watch(cartProvider.notifier).itemCount;
});

final cartTotalProvider = Provider<int>((ref) {
 return ref.watch(cartProvider.notifier).totalCents;
});

Cart Screen

class CartScreen extends ConsumerWidget {
 const CartScreen({super.key});

 @override
 Widget build(BuildContext context, WidgetRef ref) {
 final items = ref.watch(cartProvider);
 final total = ref.watch(cartTotalProvider);

 if (items.isEmpty) {
 return Scaffold(
 appBar: AppBar(title: const Text('Cart')),
 body: const Center(
 child: Column(
 mainAxisSize: MainAxisSize.min,
 children: [
 Icon(Icons.shopping_cart_outlined, size: 64, color: Colors.grey),
 SizedBox(height: 16),
 Text('Your cart is empty'),
 ],
 ),
 ),
 );
 }

 return Scaffold(
 appBar: AppBar(title: Text('Cart (${items.length} items)')),
 body: ListView.separated(
 padding: const EdgeInsets.all(16),
 itemCount: items.length,
 separatorBuilder: (_, __) => const Divider(),
 itemBuilder: (_, i) => CartItemTile(item: items[i]),
 ),
 bottomNavigationBar: SafeArea(
 child: Padding(
 padding: const EdgeInsets.all(16),
 child: Column(
 mainAxisSize: MainAxisSize.min,
 children: [
 Row(
 mainAxisAlignment: MainAxisAlignment.spaceBetween,
 children: [
 const Text('Total', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
 Text(
 '\$${(total / 100).toStringAsFixed(2)}',
 style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
 ),
 ],
 ),
 const SizedBox(height: 12),
 SizedBox(
 width: double.infinity,
 child: ElevatedButton(
 onPressed: () => Navigator.pushNamed(context, '/checkout'),
 child: const Text('Proceed to Checkout'),
 ),
 ),
 ],
 ),
 ),
 ),
 );
 }
}

8. Checkout — Using Stripe Payment Sheet

The Payment Sheet is Stripe's drop-in checkout UI. It handles card entry, validation, 3D Secure authentication, Apple Pay, Google Pay, and saved payment methods — all in a single bottom sheet.

Step 1 — Fetch Payment Parameters from Your Backend

class PaymentService {
 final Dio _dio;

 PaymentService(this._dio);

 /// Fetches everything needed to init the Payment Sheet
 Future<PaymentSheetParams> createCheckoutSession({
 required int amountInCents,
 required String currency,
 required String customerId,
 }) async {
 // 1. Get ephemeral key
 final ekResponse = await _dio.post('/ephemeral-keys', data: {
 'customerId': customerId,
 });

 // 2. Create PaymentIntent
 final piResponse = await _dio.post('/payment-intents', data: {
 'amount': amountInCents,
 'currency': currency,
 'customerId': customerId,
 });

 return PaymentSheetParams(
 clientSecret: piResponse.data['clientSecret'],
 ephemeralKey: ekResponse.data['ephemeralKey'],
 customerId: customerId,
 );
 }
}

class PaymentSheetParams {
 final String clientSecret;
 final String ephemeralKey;
 final String customerId;

 const PaymentSheetParams({
 required this.clientSecret,
 required this.ephemeralKey,
 required this.customerId,
 });
}

Step 2 — Initialize and Present the Payment Sheet

class CheckoutScreen extends ConsumerStatefulWidget {
 const CheckoutScreen({super.key});

 @override
 ConsumerState<CheckoutScreen> createState() => _CheckoutScreenState();
}

class _CheckoutScreenState extends ConsumerState<CheckoutScreen> {
 bool _isProcessing = false;

 Future<void> _handleCheckout() async {
 setState(() => _isProcessing = true);

 try {
 final cart = ref.read(cartProvider.notifier);
 final paymentService = ref.read(paymentServiceProvider);

 // 1. Get parameters from backend
 final params = await paymentService.createCheckoutSession(
 amountInCents: cart.totalCents,
 currency: 'usd',
 customerId: ref.read(currentUserProvider).stripeCustomerId,
 );

 // 2. Initialize Payment Sheet
 await Stripe.instance.initPaymentSheet(
 paymentSheetParameters: SetupPaymentSheetParameters(
 paymentIntentClientSecret: params.clientSecret,
 customerEphemeralKeySecret: params.ephemeralKey,
 customerId: params.customerId,
 merchantDisplayName: 'Your Store Name',
 style: ThemeMode.system,
 appearance: const PaymentSheetAppearance(
 colors: PaymentSheetAppearanceColors(
 primary: Color(0xFF0D1B2A),
 ),
 ),
 // Enable Apple Pay
 applePay: const PaymentSheetApplePay(
 merchantCountryCode: 'US',
 ),
 // Enable Google Pay
 googlePay: const PaymentSheetGooglePay(
 merchantCountryCode: 'US',
 testEnv: true, // set false in production
 ),
 ),
 );

 // 3. Present Payment Sheet
 await Stripe.instance.presentPaymentSheet();

 // 4. Payment succeeded!
 if (mounted) {
 cart.clear();
 Navigator.pushReplacementNamed(context, '/order-confirmation');
 }
 } on StripeException catch (e) {
 _handleStripeError(e);
 } catch (e) {
 _showError('Something went wrong. Please try again.');
 } finally {
 if (mounted) setState(() => _isProcessing = false);
 }
 }

 void _handleStripeError(StripeException e) {
 switch (e.error.code) {
 case FailureCode.Canceled:
 // User dismissed the sheet — do nothing
 break;
 case FailureCode.Failed:
 _showError('Payment failed. Please try a different card.');
 break;
 case FailureCode.Timeout:
 _showError('Connection timed out. Check your internet and try again.');
 break;
 default:
 _showError(e.error.localizedMessage ?? 'An error occurred.');
 }
 }

 void _showError(String message) {
 if (!mounted) return;
 ScaffoldMessenger.of(context).showSnackBar(
 SnackBar(content: Text(message), backgroundColor: Colors.red),
 );
 }

 @override
 Widget build(BuildContext context) {
 final total = ref.watch(cartTotalProvider);

 return Scaffold(
 appBar: AppBar(title: const Text('Checkout')),
 body: Padding(
 padding: const EdgeInsets.all(24),
 child: Column(
 children: [
 // Order summary
 const OrderSummaryWidget(),
 const Spacer(),
 // Pay button
 SizedBox(
 width: double.infinity,
 height: 52,
 child: ElevatedButton(
 onPressed: _isProcessing ? null : _handleCheckout,
 child: _isProcessing
 ? const SizedBox(
 width: 20, height: 20,
 child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
 )
 : Text('Pay \$${(total / 100).toStringAsFixed(2)}'),
 ),
 ),
 ],
 ),
 ),
 );
 }
}

That's the entire checkout flow. The Payment Sheet handles card validation, CVC checks, postal code, and 3D Secure challenges automatically. No custom form building required.

9. Custom Card Form with CardField

If you need full control over the payment UI — for example, an inline card input within your own checkout page — use CardField:

class InlineCardPayment extends StatefulWidget {
 final int amountInCents;
 final String clientSecret;

 const InlineCardPayment({
 required this.amountInCents,
 required this.clientSecret,
 super.key,
 });

 @override
 State<InlineCardPayment> createState() => _InlineCardPaymentState();
}

class _InlineCardPaymentState extends State<InlineCardPayment> {
 CardFieldInputDetails? _cardDetails;
 bool _isProcessing = false;

 Future<void> _pay() async {
 if (_cardDetails == null || !_cardDetails!.complete) return;

 setState(() => _isProcessing = true);

 try {
 // Confirm the payment with the card details
 final result = await Stripe.instance.confirmPayment(
 paymentIntentClientSecret: widget.clientSecret,
 data: const PaymentMethodParams.card(
 paymentMethodData: PaymentMethodData(),
 ),
 );

 if (result.status == PaymentIntentsStatus.Succeeded) {
 // Payment successful
 if (mounted) {
 Navigator.pushReplacementNamed(context, '/order-confirmation');
 }
 }
 } on StripeException catch (e) {
 ScaffoldMessenger.of(context).showSnackBar(
 SnackBar(content: Text(e.error.localizedMessage ?? 'Payment failed')),
 );
 } finally {
 if (mounted) setState(() => _isProcessing = false);
 }
 }

 @override
 Widget build(BuildContext context) {
 return Column(
 children: [
 // Stripe's secure card input field
 CardField(
 onCardChanged: (details) {
 setState(() => _cardDetails = details);
 },
 decoration: InputDecoration(
 labelText: 'Card Details',
 border: OutlineInputBorder(
 borderRadius: BorderRadius.circular(8),
 ),
 ),
 ),
 const SizedBox(height: 16),
 SizedBox(
 width: double.infinity,
 child: ElevatedButton(
 onPressed: (_cardDetails?.complete == true && !_isProcessing)
 ? _pay
 : null,
 child: _isProcessing
 ? const CircularProgressIndicator(strokeWidth: 2)
 : Text('Pay \$${(widget.amountInCents / 100).toStringAsFixed(2)}'),
 ),
 ),
 ],
 );
 }
}

CardField is a native platform view — card data stays within Stripe's SDK and never enters your Dart code. This is what makes PCI compliance effortless.

10. Apple Pay & Google Pay

If you used Payment Sheet in Section 8, Apple Pay and Google Pay are already enabled with the applePay and googlePay parameters. But if you want to trigger them directly (e.g., an "Express Checkout" button), here's how:

Apple Pay — Direct

Future<void> payWithApplePay({
 required String clientSecret,
 required int amountInCents,
}) async {
 // Check availability
 final isAvailable = await Stripe.instance.checkApplePaySupport();
 if (!isAvailable) throw Exception('Apple Pay not available');

 // Present Apple Pay sheet
 await Stripe.instance.presentApplePay(
 params: ApplePayPresentParams(
 cartItems: [
 ApplePayCartSummaryItem.immediate(
 label: 'Your Store',
 amount: (amountInCents / 100).toStringAsFixed(2),
 ),
 ],
 country: 'US',
 currency: 'usd',
 ),
 );

 // Confirm the payment
 await Stripe.instance.confirmApplePayPayment(clientSecret);
}

Google Pay — Direct

Future<void> payWithGooglePay({
 required String clientSecret,
 required int amountInCents,
 required String currency,
}) async {
 // Check availability
 final isAvailable = await Stripe.instance.isGooglePaySupported(
 const IsGooglePaySupportedParams(),
 );
 if (!isAvailable) throw Exception('Google Pay not available');

 // Present Google Pay sheet
 await Stripe.instance.initGooglePay(
 GooglePayInitParams(
 testEnv: true, // false in production
 merchantName: 'Your Store',
 countryCode: 'US',
 ),
 );

 await Stripe.instance.presentGooglePay(
 PresentGooglePayParams(
 clientSecret: clientSecret,
 forSetupIntent: false,
 currencyCode: currency,
 ),
 );
}

🛠️ Testing Tip

Apple Pay requires a real device (not a simulator) with a card in the Wallet. Google Pay works in the emulator with testEnv: true. In both cases, use Stripe test mode — no real charges are made. See the Stripe testing docs for details.

11. Subscription & Recurring Payments

Stripe Billing handles the heavy lifting for recurring payments — trial periods, proration, dunning (failed-payment retries), invoicing, and more. Here's how to wire it into Flutter:

Backend — Create a Subscription

// POST /subscriptions
app.post('/subscriptions', async (req, res) => {
 try {
 const { customerId, priceId } = req.body;

 const subscription = await stripe.subscriptions.create({
 customer: customerId,
 items: [{ price: priceId }],
 payment_behavior: 'default_incomplete',
 payment_settings: {
 save_default_payment_method: 'on_subscription',
 },
 expand: ['latest_invoice.payment_intent'],
 });

 res.json({
 subscriptionId: subscription.id,
 clientSecret: subscription.latest_invoice.payment_intent.client_secret,
 });
 } catch (err) {
 res.status(400).json({ error: err.message });
 }
});

Flutter — Subscribe Flow

class SubscriptionScreen extends ConsumerStatefulWidget {
 const SubscriptionScreen({super.key});

 @override
 ConsumerState<SubscriptionScreen> createState() => _SubscriptionScreenState();
}

class _SubscriptionScreenState extends ConsumerState<SubscriptionScreen> {
 String? _selectedPriceId;
 bool _isProcessing = false;

 final _plans = [
 PricingPlan(
 id: 'price_monthly',
 name: 'Monthly',
 amount: 999,
 interval: 'month',
 ),
 PricingPlan(
 id: 'price_yearly',
 name: 'Yearly',
 amount: 9999,
 interval: 'year',
 badge: 'Save 17%',
 ),
 ];

 Future<void> _subscribe() async {
 if (_selectedPriceId == null) return;
 setState(() => _isProcessing = true);

 try {
 final user = ref.read(currentUserProvider);
 final response = await ref.read(dioProvider).post('/subscriptions', data: {
 'customerId': user.stripeCustomerId,
 'priceId': _selectedPriceId,
 });

 final clientSecret = response.data['clientSecret'] as String;

 // Use Payment Sheet to collect payment
 await Stripe.instance.initPaymentSheet(
 paymentSheetParameters: SetupPaymentSheetParameters(
 paymentIntentClientSecret: clientSecret,
 customerId: user.stripeCustomerId,
 merchantDisplayName: 'Your Store',
 ),
 );

 await Stripe.instance.presentPaymentSheet();

 if (mounted) {
 ScaffoldMessenger.of(context).showSnackBar(
 const SnackBar(content: Text('Subscription activated!')),
 );
 Navigator.pop(context);
 }
 } on StripeException catch (e) {
 if (e.error.code != FailureCode.Canceled) {
 _showError(e.error.localizedMessage ?? 'Subscription failed');
 }
 } finally {
 if (mounted) setState(() => _isProcessing = false);
 }
 }

 void _showError(String msg) {
 ScaffoldMessenger.of(context).showSnackBar(
 SnackBar(content: Text(msg), backgroundColor: Colors.red),
 );
 }

 @override
 Widget build(BuildContext context) {
 return Scaffold(
 appBar: AppBar(title: const Text('Choose a Plan')),
 body: ListView.builder(
 padding: const EdgeInsets.all(16),
 itemCount: _plans.length,
 itemBuilder: (_, i) {
 final plan = _plans[i];
 final isSelected = plan.id == _selectedPriceId;
 return Card(
 shape: RoundedRectangleBorder(
 borderRadius: BorderRadius.circular(12),
 side: BorderSide(
 color: isSelected ? Theme.of(context).primaryColor : Colors.grey.shade300,
 width: isSelected ? 2 : 1,
 ),
 ),
 child: ListTile(
 title: Text(plan.name),
 subtitle: Text('\$${(plan.amount / 100).toStringAsFixed(2)} / ${plan.interval}'),
 trailing: plan.badge != null
 ? Chip(label: Text(plan.badge!))
 : null,
 onTap: () => setState(() => _selectedPriceId = plan.id),
 ),
 );
 },
 ),
 bottomNavigationBar: SafeArea(
 child: Padding(
 padding: const EdgeInsets.all(16),
 child: ElevatedButton(
 onPressed: _selectedPriceId != null && !_isProcessing
 ? _subscribe
 : null,
 child: _isProcessing
 ? const CircularProgressIndicator(strokeWidth: 2)
 : const Text('Subscribe'),
 ),
 ),
 ),
 );
 }
}

class PricingPlan {
 final String id;
 final String name;
 final int amount;
 final String interval;
 final String? badge;

 const PricingPlan({
 required this.id,
 required this.name,
 required this.amount,
 required this.interval,
 this.badge,
 });
}

Once the subscriber's first payment succeeds, Stripe Billing handles all future charges. Your webhook listener (Section 13) updates your database when payments succeed or fail.

12. Customer Management & Saved Cards

Returning customers expect to pay with one tap. Stripe's saved payment methods make this possible.

Backend — List Customer's Payment Methods

app.get('/payment-methods/:customerId', async (req, res) => {
 try {
 const methods = await stripe.paymentMethods.list({
 customer: req.params.customerId,
 type: 'card',
 });

 res.json({
 paymentMethods: methods.data.map(pm => ({
 id: pm.id,
 brand: pm.card.brand,
 last4: pm.card.last4,
 expMonth: pm.card.exp_month,
 expYear: pm.card.exp_year,
 })),
 });
 } catch (err) {
 res.status(400).json({ error: err.message });
 }
});

Flutter — Saved Cards UI

class SavedCardsScreen extends ConsumerWidget {
 const SavedCardsScreen({super.key});

 @override
 Widget build(BuildContext context, WidgetRef ref) {
 final cards = ref.watch(savedCardsProvider);

 return Scaffold(
 appBar: AppBar(title: const Text('Payment Methods')),
 body: cards.when(
 data: (methods) => ListView.builder(
 padding: const EdgeInsets.all(16),
 itemCount: methods.length + 1,
 itemBuilder: (_, i) {
 if (i == methods.length) {
 return OutlinedButton.icon(
 icon: const Icon(Icons.add),
 label: const Text('Add New Card'),
 onPressed: () => _addNewCard(context, ref),
 );
 }
 final card = methods[i];
 return Card(
 child: ListTile(
 leading: Icon(_brandIcon(card.brand)),
 title: Text('•••• •••• •••• ${card.last4}'),
 subtitle: Text('Expires ${card.expMonth}/${card.expYear}'),
 trailing: IconButton(
 icon: const Icon(Icons.delete_outline, color: Colors.red),
 onPressed: () => _detachCard(ref, card.id),
 ),
 ),
 );
 },
 ),
 loading: () => const Center(child: CircularProgressIndicator()),
 error: (e, _) => Center(child: Text('Error: $e')),
 ),
 );
 }

 IconData _brandIcon(String brand) => switch (brand) {
 'visa' => Icons.credit_card,
 'mastercard' => Icons.credit_card,
 'amex' => Icons.credit_card,
 _ => Icons.payment,
 };

 Future<void> _addNewCard(BuildContext context, WidgetRef ref) async {
 // Use SetupIntent to save a card without charging
 final response = await ref.read(dioProvider).post('/setup-intents', data: {
 'customerId': ref.read(currentUserProvider).stripeCustomerId,
 });

 await Stripe.instance.initPaymentSheet(
 paymentSheetParameters: SetupPaymentSheetParameters(
 setupIntentClientSecret: response.data['clientSecret'],
 customerId: ref.read(currentUserProvider).stripeCustomerId,
 merchantDisplayName: 'Your Store',
 ),
 );

 await Stripe.instance.presentPaymentSheet();
 ref.invalidate(savedCardsProvider); // refresh the list
 }

 Future<void> _detachCard(WidgetRef ref, String paymentMethodId) async {
 await ref.read(dioProvider).post('/detach-payment-method', data: {
 'paymentMethodId': paymentMethodId,
 });
 ref.invalidate(savedCardsProvider);
 }
}

💡 SetupIntent vs PaymentIntent

Use a SetupIntent when you want to save a card without charging it (e.g., during signup). Use a PaymentIntent when you want to charge immediately. Both support 3D Secure authentication.

13. Webhooks — Handling Payment Events

Webhooks are the most important part of any Stripe integration — and the most commonly skipped in tutorials. Without webhooks, you have no reliable way to know whether a payment actually succeeded.

Why? Because the user might close the app mid-payment, the 3D Secure redirect might happen in a browser, or the network might drop between Stripe confirming and your app receiving the result. Webhooks are Stripe's guarantee:

Backend — Webhook Handler

const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

// IMPORTANT: Use raw body for signature verification
app.post('/webhooks/stripe',
 express.raw({ type: 'application/json' }),
 async (req, res) => {
 const sig = req.headers['stripe-signature'];

 let event;
 try {
 event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
 } catch (err) {
 console.error('Webhook signature verification failed:', err.message);
 return res.status(400).send(`Webhook Error: ${err.message}`);
 }

 // Handle the event
 switch (event.type) {
 case 'payment_intent.succeeded': {
 const pi = event.data.object;
 await db.orders.updateOne(
 { paymentIntentId: pi.id },
 { $set: { status: 'paid', paidAt: new Date() } },
 );
 // Send confirmation email, update inventory, etc.
 break;
 }

 case 'payment_intent.payment_failed': {
 const pi = event.data.object;
 await db.orders.updateOne(
 { paymentIntentId: pi.id },
 { $set: { status: 'failed', error: pi.last_payment_error?.message } },
 );
 break;
 }

 case 'invoice.paid': {
 // Subscription payment succeeded
 const invoice = event.data.object;
 await db.subscriptions.updateOne(
 { stripeSubscriptionId: invoice.subscription },
 { $set: { status: 'active', currentPeriodEnd: new Date(invoice.period_end * 1000) } },
 );
 break;
 }

 case 'invoice.payment_failed': {
 // Subscription payment failed
 const invoice = event.data.object;
 await db.subscriptions.updateOne(
 { stripeSubscriptionId: invoice.subscription },
 { $set: { status: 'past_due' } },
 );
 // Notify user to update payment method
 break;
 }

 case 'customer.subscription.deleted': {
 const sub = event.data.object;
 await db.subscriptions.updateOne(
 { stripeSubscriptionId: sub.id },
 { $set: { status: 'canceled', canceledAt: new Date() } },
 );
 break;
 }

 default:
 console.log(`Unhandled event type: ${event.type}`);
 }

 res.json({ received: true });
 },
);

Register your webhook endpoint in the Stripe Dashboard → Webhooks. For local development, use the Stripe CLI:

stripe listen --forward-to localhost:4242/webhooks/stripe

⚠️ Critical

Always verify the webhook signature. Without constructEvent(), anyone could POST fake payment events to your endpoint. And use express.raw() — if you parse JSON first, the signature check will fail.

14. Refunds & Disputes

Handling refunds gracefully builds trust with customers. Here's how to implement full and partial refunds:

Backend — Refund Endpoint

app.post('/refunds', async (req, res) => {
 try {
 const { paymentIntentId, amount, reason } = req.body;

 const refundParams = {
 payment_intent: paymentIntentId,
 reason: reason || 'requested_by_customer',
 };

 // Partial refund if amount specified, full refund otherwise
 if (amount) {
 refundParams.amount = amount; // in cents
 }

 const refund = await stripe.refunds.create(refundParams);

 // Update order status
 await db.orders.updateOne(
 { paymentIntentId },
 {
 $set: { status: amount ? 'partially_refunded' : 'refunded' },
 $push: { refunds: { id: refund.id, amount: refund.amount, createdAt: new Date() } },
 },
 );

 res.json({ refundId: refund.id, status: refund.status });
 } catch (err) {
 res.status(400).json({ error: err.message });
 }
});

Flutter — Refund Request UI

Future<void> requestRefund({
 required String orderId,
 required String paymentIntentId,
 int? partialAmountCents,
}) async {
 final confirmed = await showDialog<bool>(
 context: context,
 builder: (_) => AlertDialog(
 title: const Text('Request Refund'),
 content: Text(partialAmountCents != null
 ? 'Refund \$${(partialAmountCents / 100).toStringAsFixed(2)}?'
 : 'Refund the full amount?'),
 actions: [
 TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
 TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Confirm')),
 ],
 ),
 );

 if (confirmed != true) return;

 try {
 await _dio.post('/refunds', data: {
 'paymentIntentId': paymentIntentId,
 if (partialAmountCents != null) 'amount': partialAmountCents,
 });

 ScaffoldMessenger.of(context).showSnackBar(
 const SnackBar(content: Text('Refund processed successfully')),
 );
 _refreshOrders();
 } catch (e) {
 ScaffoldMessenger.of(context).showSnackBar(
 const SnackBar(content: Text('Refund failed. Contact support.')),
 );
 }
}

For disputes (chargebacks), Stripe sends a charge.dispute.created webhook event. You should respond with evidence via the Disputes API within the deadline — typically 7–21 days depending on the card network.

15. Multi-Currency & International Payments

If you sell to customers outside the US, multi-currency support is essential. Stripe makes this straightforward:

/// Detects user's locale and maps to supported currency
String detectCurrency(BuildContext context) {
 final locale = Localizations.localeOf(context);
 return switch (locale.countryCode) {
 'US' => 'usd',
 'GB' => 'gbp',
 'EU' || 'DE' || 'FR' || 'IT' || 'ES' => 'eur',
 'JP' => 'jpy',
 'IN' => 'inr',
 'PK' => 'pkr',
 'AE' => 'aed',
 'CA' => 'cad',
 'AU' => 'aud',
 _ => 'usd', // fallback
 };
}

/// Formats amount for display with correct currency symbol
String formatPrice(int amountInSmallestUnit, String currency) {
 final format = NumberFormat.simpleCurrency(
 name: currency.toUpperCase(),
 decimalDigits: _zeroDecimalCurrencies.contains(currency) ? 0 : 2,
 );

 final amount = _zeroDecimalCurrencies.contains(currency)
 ? amountInSmallestUnit.toDouble()
 : amountInSmallestUnit / 100;

 return format.format(amount);
}

/// Currencies that don't use fractional units
const _zeroDecimalCurrencies = {'jpy', 'krw', 'vnd', 'bif', 'clp'};

On the backend, simply pass the currency parameter when creating the PaymentIntent. Stripe handles currency conversion if your Stripe account's settlement currency differs.

💰 Zero-Decimal Currencies

Some currencies like JPY, KRW, and VND don't have fractional units. For JPY, amount: 1000 means ¥1,000 (not ¥10.00). Always check the zero-decimal list.

16. Error Handling & Retry Logic

Payment flows demand rock-solid error handling. Here's a comprehensive approach that covers every failure mode:

/// Centralized payment error handler
class PaymentErrorHandler {
 static String userFriendlyMessage(Object error) {
 if (error is StripeException) {
 return switch (error.error.code) {
 FailureCode.Canceled => 'Payment was cancelled.',
 FailureCode.Failed => _mapDeclineCode(error.error.declineCode),
 FailureCode.Timeout => 'Connection timed out. Please check your internet.',
 _ => error.error.localizedMessage ?? 'An unexpected error occurred.',
 };
 }

 if (error is DioException) {
 return switch (error.type) {
 DioExceptionType.connectionTimeout => 'Server is unreachable. Try again later.',
 DioExceptionType.receiveTimeout => 'Server took too long to respond.',
 DioExceptionType.connectionError => 'No internet connection.',
 _ => 'Network error. Please try again.',
 };
 }

 return 'Something went wrong. Please try again.';
 }

 static String _mapDeclineCode(String? code) => switch (code) {
 'insufficient_funds' => 'Insufficient funds. Try a different card.',
 'lost_card' || 'stolen_card' => 'This card has been reported. Use another card.',
 'expired_card' => 'Your card has expired. Please update it.',
 'incorrect_cvc' => 'Incorrect CVC. Please check and try again.',
 'processing_error' => 'Processing error. Please try again.',
 'card_velocity_exceeded' => 'Too many attempts. Wait a moment and try again.',
 _ => 'Your card was declined. Try a different payment method.',
 };
}

/// Retry wrapper for transient errors
Future<T> retryPaymentOperation<T>(
 Future<T> Function() operation, {
 int maxRetries = 3,
}) async {
 int attempt = 0;
 while (true) {
 try {
 return await operation();
 } catch (e) {
 attempt++;
 if (attempt >= maxRetries || !_isRetryable(e)) rethrow;
 // Exponential backoff: 1s, 2s, 4s
 await Future.delayed(Duration(seconds: 1 << (attempt - 1)));
 }
 }
}

bool _isRetryable(Object error) {
 if (error is DioException) {
 return error.type == DioExceptionType.connectionTimeout ||
 error.type == DioExceptionType.receiveTimeout;
 }
 // Stripe card declines should NOT be retried
 return false;
}

Key rules:

17. Security Best Practices

Payments demand the highest security standards. Follow these non-negotiable rules:

1. Never Expose Secret Keys

// WRONG — secret key in client code
final stripe = Stripe('sk_live_...');

// RIGHT — only publishable key in client
Stripe.publishableKey = dotenv.env['STRIPE_PUBLISHABLE_KEY']!;
// Secret key stays on your backend ONLY

2. Validate Amounts Server-Side

// Backend — always recalculate from your product database
app.post('/payment-intents', async (req, res) => {
 const { items } = req.body;

 // Recalculate total from your database — never trust client amount
 const products = await db.products.find({ id: { $in: items.map(i => i.id) } });
 const amount = products.reduce((sum, p) => {
 const item = items.find(i => i.id === p.id);
 return sum + (p.priceInCents * item.quantity);
 }, 0);

 const pi = await stripe.paymentIntents.create({
 amount,
 currency: 'usd',
 // ...
 });
 // ...
});

3. HTTPS Everywhere

All API calls between your Flutter app and backend must use HTTPS. Never disable certificate validation — even in development. Use tools like ngrok to get HTTPS for local testing.

4. Implement Rate Limiting

const rateLimit = require('express-rate-limit');

const paymentLimiter = rateLimit({
 windowMs: 15 * 60 * 1000, // 15 minutes
 max: 10, // limit each IP to 10 payment attempts per window
 message: { error: 'Too many payment attempts. Try again later.' },
});

app.post('/payment-intents', paymentLimiter, async (req, res) => {
 // ... payment logic
});

5. Secure API Communication

/// Dio client with security headers
final dioProvider = Provider<Dio>((ref) {
 final dio = Dio(BaseOptions(
 baseUrl: dotenv.env['BACKEND_URL']!,
 connectTimeout: const Duration(seconds: 10),
 receiveTimeout: const Duration(seconds: 15),
 headers: {
 'Content-Type': 'application/json',
 },
 ));

 // Add auth token interceptor
 dio.interceptors.add(InterceptorsWrapper(
 onRequest: (options, handler) {
 final token = ref.read(authTokenProvider);
 if (token != null) {
 options.headers['Authorization'] = 'Bearer $token';
 }
 handler.next(options);
 },
 ));

 return dio;
});

🔒 PCI Compliance Checklist

18. Testing Stripe Payments

Stripe provides an excellent testing toolkit. Here are the essential test card numbers:

Card Number Scenario
4242 4242 4242 4242 Successful payment (Visa)
5555 5555 5555 4444 Successful payment (Mastercard)
4000 0000 0000 9995 Declined — insufficient funds
4000 0000 0000 0002 Declined — generic decline
4000 0025 0000 3155 Requires 3D Secure authentication
4000 0000 0000 3220 3D Secure 2 — required on all transactions
4000 0000 0000 0077 Charge succeeds, dispute created later

Widget Tests for Payment UI

void main() {
 group('CartNotifier', () {
 late ProviderContainer container;
 late CartNotifier cart;

 setUp(() {
 container = ProviderContainer();
 cart = container.read(cartProvider.notifier);
 });

 tearDown(() => container.dispose());

 test('starts empty', () {
 expect(container.read(cartProvider), isEmpty);
 });

 test('adds items correctly', () {
 final product = Product(id: '1', name: 'Widget', priceInCents: 999, imageUrl: '');
 cart.addItem(product);
 expect(container.read(cartProvider).length, 1);
 expect(container.read(cartTotalProvider), 999);
 });

 test('increments quantity for duplicate products', () {
 final product = Product(id: '1', name: 'Widget', priceInCents: 999, imageUrl: '');
 cart.addItem(product);
 cart.addItem(product);
 expect(container.read(cartProvider).length, 1);
 expect(container.read(cartProvider).first.quantity, 2);
 expect(container.read(cartTotalProvider), 1998);
 });

 test('removes items', () {
 final product = Product(id: '1', name: 'Widget', priceInCents: 999, imageUrl: '');
 cart.addItem(product);
 cart.removeItem('1');
 expect(container.read(cartProvider), isEmpty);
 });

 test('clears all items', () {
 cart.addItem(Product(id: '1', name: 'A', priceInCents: 100, imageUrl: ''));
 cart.addItem(Product(id: '2', name: 'B', priceInCents: 200, imageUrl: ''));
 cart.clear();
 expect(container.read(cartProvider), isEmpty);
 });
 });
}

Integration Tests with Stripe CLI

# Forward webhooks to local server
stripe listen --forward-to localhost:4242/webhooks/stripe

# Trigger a test event
stripe trigger payment_intent.succeeded

# Run Flutter integration tests
flutter test integration_test/checkout_flow_test.dart

For end-to-end testing, the Stripe CLI is indispensable. It lets you forward webhook events to your local machine and trigger specific events for testing.

19. Performance Optimization

Payment screens need to be fast — every 100ms of latency reduces conversion by roughly 1%. Here are our battle-tested optimizations:

1. Lazy-Load Stripe

/// Only initialize Stripe when the user reaches a payment screen
class LazyStripeInit {
 static bool _initialized = false;

 static Future<void> ensureInitialized() async {
 if (_initialized) return;
 Stripe.publishableKey = dotenv.env['STRIPE_PUBLISHABLE_KEY']!;
 Stripe.merchantIdentifier = 'merchant.com.yourcompany.app';
 await Stripe.instance.applySettings();
 _initialized = true;
 }
}

2. Pre-fetch the Payment Sheet

/// Start fetching payment parameters while the user reviews their cart
class CheckoutController {
 Future<PaymentSheetParams>? _prefetchFuture;

 void prefetchCheckout(int amountCents, String customerId) {
 _prefetchFuture ??= _paymentService.createCheckoutSession(
 amountInCents: amountCents,
 currency: 'usd',
 customerId: customerId,
 );
 }

 Future<PaymentSheetParams> getParams() => _prefetchFuture!;
}

3. Optimize Product Images

CachedNetworkImage(
 imageUrl: product.imageUrl,
 // Resize on the CDN — never load full-res for thumbnails
 memCacheWidth: 300,
 memCacheHeight: 300,
 fadeInDuration: const Duration(milliseconds: 200),
 placeholder: (_, __) => const ShimmerPlaceholder(),
)

4. Minimize State Rebuilds

// WRONG — entire widget tree rebuilds when any cart item changes
final cart = ref.watch(cartProvider);

// RIGHT — only watch what you need
final itemCount = ref.watch(cartItemCountProvider);
final total = ref.watch(cartTotalProvider);

For more optimization strategies, check our animations masterclass which covers RepaintBoundary, const constructors, and widget rebuild analysis.

20. Common Pitfalls & Solutions

After building 15+ Flutter e-commerce apps, these are the traps we've seen developers fall into most often:

Pitfall Impact Solution
Trusting client-side amounts Attackers modify prices to $0 Always recalculate amounts from your database server-side
Skipping webhooks Orders stuck in "processing" forever Implement webhooks for payment_intent.succeeded and payment_intent.payment_failed
Hardcoding API keys Keys in version control, instant compromise Use flutter_dotenv and never commit .env
Not handling 3D Secure Payments fail silently in EU / SCA-required regions Use automatic_payment_methods — Stripe handles it for you
Ignoring zero-decimal currencies ¥100 becomes ¥1 (100x undercharge) Check Stripe's list and adjust amount calculation
Not testing on real devices Apple Pay crashes, Google Pay doesn't appear Test Apple Pay on physical iPhone. Google Pay works on emulator with testEnv
Missing mounted checks setState() on unmounted widget → crash Always check if (mounted) after any async Stripe call
Poor error messages Users see "StripeException: card_declined" and leave Map every Stripe error code to a friendly message

🚀 Pro Tip — Idempotency

Always pass an idempotency key when creating PaymentIntents. If a network hiccup causes a retry, Stripe returns the original response instead of creating a duplicate charge. Use { idempotencyKey: 'order_12345' } in your backend Stripe calls.

Key Takeaways

  1. Architecture first — separate client, backend, and Stripe responsibilities clearly. Payment logic belongs on the server.
  2. Payment Sheet is your best friend — it handles card forms, 3D Secure, Apple Pay, Google Pay, and saved cards in a single widget.
  3. Webhooks are mandatory — the Payment Sheet confirmation is not enough. Webhooks are Stripe's source of truth.
  4. Security is non-negotiable — validate amounts server-side, verify webhook signatures, use HTTPS, and never expose secret keys.
  5. Test with real test cards — cover success, decline, 3D Secure, and dispute scenarios before launch.
  6. Handle errors like a pro — map every Stripe decline code to a human-readable message. Never show raw exception strings.
  7. Optimize for conversion — lazy-load Stripe, pre-fetch payment parameters, and keep the checkout flow under 3 steps.

🚀 What's Next?

Ready to ship your e-commerce app? Check out our guides on Clean Architecture for Flutter to structure your project right, or Flutter Testing Strategy to build a comprehensive test suite. Need help with your e-commerce project? Contact our team for a free architecture review.

📚 Related Articles

Frequently Asked Questions

How do you integrate Stripe with a Flutter e-commerce app?

Add the flutter_stripe package to your project, initialize Stripe with your publishable key, create PaymentIntents on your backend server, then use Stripe.instance.initPaymentSheet() and presentPaymentSheet() on the client. Always create payment intents server-side — never pass secret keys to the Flutter app.

Does Flutter Stripe support Apple Pay and Google Pay?

Yes. The flutter_stripe package supports both Apple Pay and Google Pay out of the box. Configure your merchantIdentifier for Apple Pay and enable Google Pay in the PaymentSheet parameters. Both work with the same PaymentIntent flow. Apple Pay requires a real device for testing; Google Pay works on emulator with testEnv: true.

How do you handle Stripe webhooks in a Flutter e-commerce app?

Webhooks are handled on your backend server, not in Flutter. Register a webhook endpoint in the Stripe Dashboard, verify each event signature using your webhook secret, then update orders, inventory, and user records based on events like payment_intent.succeeded or invoice.paid. Use the Stripe CLI for local testing.

Can Flutter handle Stripe subscriptions and recurring payments?

Yes. Create Stripe Products and Prices in your dashboard, then use the Subscriptions API on your backend. Flutter handles the UI for plan selection and payment method collection. Stripe manages billing cycles, proration, trial periods, and failed payment retries automatically.

Is Stripe PCI compliant for Flutter mobile apps?

Yes. When you use flutter_stripe, card data never touches your server — it goes directly to Stripe's PCI Level 1 certified infrastructure. This reduces your PCI scope to SAQ A, the simplest compliance level. Never collect raw card numbers in your own TextField widgets.

How do you handle payment failures and retries in Flutter?

Catch StripeException in your payment flow to distinguish between user cancellation, card decline, network errors, and authentication failures. For transient network errors, implement exponential backoff retry logic with idempotency keys. For card declines, prompt users to try a different payment method — never retry a declined card.

What is the best state management for a Flutter e-commerce app?

Riverpod is our top choice for complex e-commerce apps because of its compile-safe providers, easy testing, and automatic disposal. For simpler apps, Provider works well. See our BLoC vs Riverpod comparison for the full analysis.

How do you test Stripe payments during Flutter development?

Use Stripe test mode with test API keys. Card 4242 4242 4242 4242 simulates success. Card 4000 0000 0000 9995 simulates a decline. Card 4000 0025 0000 3155 triggers 3D Secure authentication. Use the Stripe CLI to forward webhooks and trigger test events locally.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.