Skip to content
Blog

Mobile E-Commerce: A Flutter Shop with Stripe

Build a production-ready mobile e-commerce checkout in Flutter with the flutter_stripe SDK: server-side PaymentIntent creation, the prebuilt PaymentSheet, and full success and error handling.

Published on August 18, 2026

AI Assistant

Every mobile shop eventually hits the same wall: checkout. You need PCI-compliant card handling, Strong Customer Authentication, Apple Pay and Google Pay, and a UX that does not bounce users out of your app. Stripe’s PaymentSheet solves all of that with a single prebuilt sheet, and the flutter_stripe SDK wires it into Flutter with a handful of method calls.

In this post we will build a complete mobile e-commerce flow: a server endpoint that creates a Stripe PaymentIntent, a Flutter product catalog with a cart, and a checkout that calls initPaymentSheet and presentPaymentSheet to collect and confirm the payment. By the end you will understand how the client secret flows from server to app, why the server stays the only place that touches secret keys, and how to handle every outcome of a charge.

Prerequisites

  • A Flutter SDK (any recent stable channel) and a configured editor.
  • A Stripe account with test-mode API keys from the dashboard.
  • Node.js 18+ for the example backend, or any language with an official Stripe library.
  • Android Studio / Xcode configured, depending on your target device.

Step 1: Add the flutter_stripe SDK

Add the dependency with the pub command, which pins the current version into your pubspec.yaml:

flutter pub add flutter_stripe

The resulting entry looks like this:

dependencies:
  flutter:
    sdk: flutter
  flutter_stripe: ^14.0.0

The plugin delegates to the native Stripe SDKs on each platform. That means a few one-time platform settings.

Android configuration

  • Use Android 5.0 (API 21) or newer, Kotlin 1.8.0+, and Android Gradle Plugin 8+.
  • Your activity must inherit from FlutterFragmentActivity instead of FlutterActivity, because the native PaymentSheet needs the Support Fragment Manager:
class MainActivity : FlutterFragmentActivity()
  • Your theme must descend from Theme.AppCompat (add the material theme if you use Material 3).
  • Add the Stripe keep rules to proguard-rules.pro if you enable shrinking:
-keep class com.stripe.** { *; }

iOS configuration

Bump the deployment target to iOS 13 and update the Podfile to match:

platform :ios, '13.0'

Initialize Stripe in main.dart

The SDK only needs your publishable key, which is safe to ship in the app. You also pass the merchantIdentifier when you enable Apple Pay, then commit the settings:

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

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  Stripe.publishableKey = const String.fromEnvironment(
    'STRIPE_PUBLISHABLE_KEY',
    defaultValue: 'pk_test_...',
  );
  Stripe.merchantIdentifier = 'merchant.com.example.shop';
  Stripe.urlScheme = 'flutterstripe';
  await Stripe.instance.applySettings();

  runApp(const ShopApp());
}

Never hardcode a secret key here. The publishable key is public by design; the sk_ secret key belongs exclusively on your server.

Step 2: Create a PaymentIntent server-side

Your mobile app cannot create a PaymentIntent directly. For security, the server owns the Stripe secret key and creates three objects: an optional Customer, an optional ephemeral key (needed only if you save payment methods for reuse), and the PaymentIntent itself. The endpoint returns the payment intent’s client secret, which the SDK uses to authenticate with Stripe directly from the device.

Here is a Node.js endpoint using the official stripe package:

import express from 'express';
import Stripe from 'stripe';

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

app.post('/create-payment-intent', async (req, res) => {
  const { amount, currency = 'usd' } = req.body;

  const paymentIntent = await stripe.paymentIntents.create({
    amount,
    currency,
    automatic_payment_methods: { enabled: true },
  });

  res.json({
    paymentIntentClientSecret: paymentIntent.client_secret,
  });
});

app.listen(4242, () => console.log('Stripe backend on :4242'));

The amount is in the currency’s smallest unit, so 5000 means $50.00 USD. Recalculate the total on the server from your authoritative product prices; never trust an amount sent by the client. If you allow users to reuse saved cards, add customer and customerEphemeralKeySecret to the response by creating a Customer and EphemeralKey server-side first.

You can now test the endpoint locally:

curl -s -X POST http://localhost:4242/create-payment-intent \
  -H "Content-Type: application/json" \
  -d '{"amount": 5000}'

Step 3: Build a product catalog

With the backend ready, build the storefront. A simple product model and a list screen give the checkout something to work with:

class Product {
  const Product({required this.id, required this.name, required this.price});

  final String id;
  final String name;

  // price in cents
  final int price;
}

const products = [
  Product(id: 'p1', name: 'Wireless Headphones', price: 12900),
  Product(id: 'p2', name: 'Mechanical Keyboard', price: 8900),
  Product(id: 'p3', name: 'USB-C Dock', price: 5900),
];

A CartItem tracks quantity, and a Cart model keeps the running total:

class Cart {
  final Map<String, int> quantities = {};

  int get total {
    var sum = 0;
    products.forEach((p) {
      sum += p.price * (quantities[p.id] ?? 0);
    });
    return sum;
  }
}

The catalog screen renders each product with an add-to-cart button and shows the cart total in a checkout bar:

class CatalogScreen extends StatelessWidget {
  const CatalogScreen({super.key, required this.cart});

  final Cart cart;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Mobile Shop')),
      body: ListView.builder(
        itemCount: products.length,
        itemBuilder: (context, index) {
          final product = products[index];
          return ListTile(
            title: Text(product.name),
            trailing: Text('\$${(product.price / 100).toStringAsFixed(2)}'),
            onTap: () => cart.quantities[product.id] =
                (cart.quantities[product.id] ?? 0) + 1,
          );
        },
      ),
      bottomNavigationBar: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: ElevatedButton(
            onPressed: () {
              Navigator.of(context).push(
                MaterialPageRoute(
                  builder: (_) => CheckoutScreen(cart: cart),
                ),
              );
            },
            child: Text('Checkout - \$${(cart.total / 100).toStringAsFixed(2)}'),
          ),
        ),
      ),
    );
  }
}

Step 4: Wire up the PaymentSheet checkout

PaymentSheet is Stripe’s recommended mobile UI. It collects the card number, runs native 3D Secure when SCA applies, and presents Apple Pay and Google Pay out of the box. The flow has two phases: initialize the sheet with the client secret, then present it when the user confirms.

class CheckoutScreen extends StatefulWidget {
  const CheckoutScreen({super.key, required this.cart});

  final Cart cart;

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

class _CheckoutScreenState extends State<CheckoutScreen> {
  bool _loading = true;

  @override
  void initState() {
    super.initState();
    _initPaymentSheet();
  }

  Future<void> _initPaymentSheet() async {
    final response = await http.post(
      Uri.parse('http://localhost:4242/create-payment-intent'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({
        'amount': widget.cart.total,
        'currency': 'usd',
      }),
    );
    final data = jsonDecode(response.body);

    await Stripe.instance.initPaymentSheet(
      paymentSheetParameters: SetupPaymentSheetParameters(
        paymentIntentClientSecret: data['paymentIntentClientSecret'],
        merchantDisplayName: 'Mobile Shop',
        returnURL: 'flutterstripe://redirect',
        primaryButtonLabel: 'Pay now',
        applePay: PaymentSheetApplePay(merchantCountryCode: 'US'),
        googlePay: PaymentSheetGooglePay(
          merchantCountryCode: 'US',
          testEnv: true,
        ),
      ),
    );

    setState(() => _loading = false);
  }

The paymentIntentClientSecret is the one returned by your backend. The returnURL matters for redirect-based payment methods (bank redirects, PayPal, Link): the SDK opens the browser to authenticate and then deep-links back into your app. The scheme must match the Stripe.urlScheme you set in main.dart, and on Android you must declare it in AndroidManifest.xml:

<intent-filter>
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="flutterstripe" android:host="redirect" />
</intent-filter>

Present the sheet and handle the outcome

When the user taps Pay, presentPaymentSheet shows Stripe’s native sheet. It throws a StripeException when the customer cancels or the payment fails, so you branch on the exception instead of inspecting the intent yourself:

  Future<void> _confirmPayment() async {
    try {
      await Stripe.instance.presentPaymentSheet();
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Payment succeeded')),
      );
      Navigator.of(context).popUntil((route) => route.isFirst);
    } on StripeException catch (e) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(e.error.localizedMessage)),
      );
    } catch (e) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Unexpected error: $e')),
      );
    }
  }

The on StripeException branch covers declined cards, failed 3D Secure, and user cancellation. localizedMessage is already human-readable, so you can surface it directly. Keep the broad catch as a safety net for network and serialization failures.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Checkout')),
      body: _loading
          ? const Center(child: CircularProgressIndicator())
          : Center(
              child: ElevatedButton(
                onPressed: _confirmPayment,
                child: const Text('Pay now'),
              ),
            ),
    );
  }
}

If your product needs a two-step flow (let the customer choose a payment method first, then confirm later), set customFlow: true in the parameters and call confirmPaymentSheetPayment() after the selection:

await Stripe.instance.presentPaymentSheet();
await Stripe.instance.confirmPaymentSheetPayment();

Putting It All Together

The complete integration is a loop of three collaborators:

  1. The Flutter app sends the cart total to your backend, receives the client_secret, initializes and presents the PaymentSheet, and renders the outcome.
  2. The backend holds the secret key, validates the total, creates the PaymentIntent, and returns the client secret. It never ships secret material to the device.
  3. Stripe handles card number collection, 3D Secure, wallets, and the charge. The app only ever sees Stripe’s own client secret.

For fulfillment, do not treat the client callback as the source of truth. The user can kill the app between payment and confirmation. Subscribe to the payment_intent.succeeded webhook event and run order creation, emails, and shipping from there. Forward local webhook events to your backend with the Stripe CLI:

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

Test with Stripe’s test cards to exercise every branch of your UI:

Card numberBehavior
4242 4242 4242 4242Succeeds immediately.
4000 0025 0000 3155Requires 3D Secure authentication.
4000 0000 0000 9995Declines with insufficient_funds.

Conclusion & Next Steps

You now have a complete mobile checkout: a Flutter catalog feeding a cart, a Node.js endpoint that creates a PaymentIntent and returns its client secret, and a PaymentSheet that collects and confirms card payments with Apple Pay, Google Pay, and 3D Secure handled natively. The architecture keeps your Stripe secret key server-side, which is what keeps the whole flow PCI-compliant.

From here you can add saved cards with Customer and ephemeral keys, subscribe to webhooks for order fulfillment, enable Apple Pay fully by registering a merchant ID and certificate in the Stripe dashboard, or swap in CardField for a fully custom card form if your design demands it. Happy shipping.