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:
- Single codebase, three platforms — Flutter compiles to native ARM on iOS and Android, plus progressive web apps. One checkout flow, one cart implementation, one product page — everywhere.
- Stripe handles PCI compliance — When you use flutter_stripe, card data never touches your server. You get SAQ A scope — the lightest PCI burden possible.
- Pre-built Payment Sheet — Stripe's Payment Sheet renders a production-ready checkout UI with card form, Apple Pay, Google Pay, link-based payment, and saved cards — all in one widget.
- Global reach — Stripe supports 135+ currencies and dozens of payment methods (SEPA, iDEAL, Klarna, Afterpay, Alipay, and more).
- Subscription-first — Stripe Billing handles proration, trial periods, metered pricing, coupons, and usage-based billing natively.
- Radar fraud detection — Stripe Radar uses machine learning trained on billions of transactions to block fraud before it happens.
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/intlRun 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.comThen 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:
- Never retry card declines — the card network has already rejected the charge. Retrying won't help and may trigger velocity blocks.
- Always retry network timeouts — with exponential backoff. The payment may or may not have gone through, so check idempotency.
- Use idempotency keys — pass
idempotencyKeywhen creating PaymentIntents to prevent duplicate charges on retries. - Map Stripe decline codes to human-readable messages. The raw codes like
card_velocity_exceededmean nothing to users.
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 ONLY2. 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
- Use
flutter_stripe— never collect raw card numbers in your own widgets - Keep secret keys on the server only
- Use HTTPS for all API communication
- Verify webhook signatures
- Validate amounts server-side
- Implement rate limiting on payment endpoints
- Log payment events for audit trails (never log card numbers)
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.dartFor 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
- Architecture first — separate client, backend, and Stripe responsibilities clearly. Payment logic belongs on the server.
- Payment Sheet is your best friend — it handles card forms, 3D Secure, Apple Pay, Google Pay, and saved cards in a single widget.
- Webhooks are mandatory — the Payment Sheet confirmation is not enough. Webhooks are Stripe's source of truth.
- Security is non-negotiable — validate amounts server-side, verify webhook signatures, use HTTPS, and never expose secret keys.
- Test with real test cards — cover success, decline, 3D Secure, and dispute scenarios before launch.
- Handle errors like a pro — map every Stripe decline code to a human-readable message. Never show raw exception strings.
- 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.