Documentation

Checkout with Stripe

Take a payment from a Flutter app using Stripe Payment Intents and AppEngine's storefront endpoints.

A checkout in AppMint is a two-side handshake. The Flutter client collects card details with Stripe's SDK; AppEngine creates the payment intent on the server (where the secret key lives) and confirms the order once the client succeeds. This recipe walks the full flow.

Endpoints

POST/storefront/stripe/intentJWT
POST/storefront/checkout-cartJWT
POST/storefront/checkout-buy-nowJWT

/storefront/stripe/intent returns a Stripe Payment Intent client secret you pass to the Stripe SDK on the device. After the user authorizes the payment, you call checkout-cart (or checkout-buy-now for a single product) with the confirmed payment intent ID, and AppEngine creates the order.

The Stripe publishable key is exposed via:

GET/storefront/stripe/configNo auth

Never embed the secret key in the app. The client only ever sees the publishable key and the per-intent client secret.

Setup

dependencies:
  flutter_stripe: ^11.1.0

In lib/main.dart, set the publishable key fetched from the server:

import 'package:flutter_stripe/flutter_stripe.dart';

Future<void> _bootstripeKey() async {
  final config = await appmintHttp.get('/storefront/stripe/config',
      requireAuth: false);
  Stripe.publishableKey = config['publishableKey'] as String;
  await Stripe.instance.applySettings();
}

iOS

In ios/Runner/Info.plist:

<key>NSCameraUsageDescription</key>
<string>Scan card to fill payment</string>

Set platform :ios, '13.0' in ios/Podfile.

Android

In android/app/build.gradle:

android {
  compileSdkVersion 34
  defaultConfig {
    minSdkVersion 21
    targetSdkVersion 34
  }
}

Apply the Material 3 theme to your MainActivity so the Stripe sheet renders correctly:

<style name="LaunchTheme" parent="Theme.AppCompat.Light.NoActionBar" />

CheckoutService

Wrap the two API calls in a service so the screen stays focused on UI.

// lib/services/checkout_service.dart
import 'http_client.dart';

class CheckoutService {
  final _http = appmintHttp;

  /// Create a Stripe Payment Intent on the server. Returns the client secret.
  Future<String> createIntent({
    required num amount,
    required String currency,
    String? description,
    Map<String, dynamic>? metadata,
  }) async {
    final response = await _http.post('/storefront/stripe/intent', body: {
      'amount': (amount * 100).round(), // Stripe uses cents
      'currency': currency.toLowerCase(),
      if (description != null) 'description': description,
      if (metadata != null) 'metadata': metadata,
    });
    return response['clientSecret'] as String;
  }

  /// Finalize the order after the payment intent has succeeded.
  Future<Map<String, dynamic>> checkoutCart({
    required String paymentIntentId,
    required List<Map<String, dynamic>> items,
    required Map<String, dynamic> shippingAddress,
  }) async {
    final response = await _http.post('/storefront/checkout-cart', body: {
      'paymentIntentId': paymentIntentId,
      'items': items,
      'shipping': shippingAddress,
    });
    return Map<String, dynamic>.from(response as Map);
  }
}

Checkout screen

The flow:

  1. 1

    Compute the cart total

    Sum quantities and prices on the client. Send the total to the server, which re-validates against the product records — the client number is just for the intent body.

  2. 2

    Create the payment intent

    Call /storefront/stripe/intent with the total and currency. The response contains a clientSecret like pi_3NX..._secret_abc.

  3. 3

    Present the Stripe payment sheet

    flutter_stripe ships a native sheet that handles card entry, Apple Pay, and Google Pay.

  4. 4

    Confirm the order

    When the sheet closes successfully, call /storefront/checkout-cart with the payment intent ID. AppEngine verifies the intent with Stripe's API, creates the order, decrements stock, and fires the post-purchase automations.

// lib/screens/checkout/checkout_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_stripe/flutter_stripe.dart';

import '../../services/checkout_service.dart';

class CheckoutScreen extends StatefulWidget {
  final num total;
  final List<Map<String, dynamic>> items;
  final Map<String, dynamic> shipping;

  const CheckoutScreen({
    super.key,
    required this.total,
    required this.items,
    required this.shipping,
  });

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

class _CheckoutScreenState extends State<CheckoutScreen> {
  final _service = CheckoutService();
  bool _busy = false;
  String? _error;
  String? _orderNumber;

  Future<void> _payAndPlace() async {
    setState(() {
      _busy = true;
      _error = null;
    });

    try {
      // 1. Create intent.
      final clientSecret = await _service.createIntent(
        amount: widget.total,
        currency: 'USD',
        description: 'Order with ${widget.items.length} items',
      );

      // 2. Present sheet.
      await Stripe.instance.initPaymentSheet(
        paymentSheetParameters: SetupPaymentSheetParameters(
          paymentIntentClientSecret: clientSecret,
          merchantDisplayName: 'My Store',
          style: ThemeMode.system,
        ),
      );
      await Stripe.instance.presentPaymentSheet();

      // 3. Confirm with AppEngine.
      final intentId = clientSecret.split('_secret_').first;
      final order = await _service.checkoutCart(
        paymentIntentId: intentId,
        items: widget.items,
        shippingAddress: widget.shipping,
      );

      setState(() => _orderNumber = order['orderNumber'] as String?);
    } on StripeException catch (e) {
      setState(() => _error = e.error.localizedMessage ?? 'Payment cancelled');
    } catch (e) {
      setState(() => _error = e.toString());
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Checkout')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text('Total: USD ${widget.total.toStringAsFixed(2)}',
                style: Theme.of(context).textTheme.titleLarge),
            const SizedBox(height: 24),
            if (_orderNumber != null)
              Text('Order placed: $_orderNumber',
                  style: const TextStyle(color: Colors.green))
            else
              FilledButton(
                onPressed: _busy ? null : _payAndPlace,
                child: _busy
                    ? const CircularProgressIndicator()
                    : const Text('Pay'),
              ),
            if (_error != null) ...[
              const SizedBox(height: 16),
              Text(_error!, style: const TextStyle(color: Colors.red)),
            ],
          ],
        ),
      ),
    );
  }
}

Apple Pay and Google Pay

flutter_stripe supports native wallets through the same payment sheet — pass applePay and googlePay parameters:

SetupPaymentSheetParameters(
  paymentIntentClientSecret: clientSecret,
  merchantDisplayName: 'My Store',
  applePay: const PaymentSheetApplePay(merchantCountryCode: 'US'),
  googlePay: const PaymentSheetGooglePay(
    merchantCountryCode: 'US',
    testEnv: false,
  ),
);

You'll need an Apple Pay merchant ID (configured via Xcode capabilities) and a Google Pay merchant configuration. Test on a real device — simulators don't fire wallet payments.

Errors and edge cases

  • User cancels the sheet: throws StripeException with code: cancelled. Treat it as a no-op; don't show a red error.
  • Card declined: throws with the decline reason. Show the reason inline; offer "Try a different card."
  • Network drops between intent creation and sheet completion: the intent is still on Stripe, with requires_payment_method. AppEngine cleans these up in a daily sweep. Don't try to recover them from the client.
  • AppEngine returns 400 on checkout-cart: the most common cause is a price mismatch (the cart was modified between intent creation and order confirmation). Re-create the intent with the new total.

Subscriptions

For recurring billing, swap /storefront/stripe/intent for:

POST/storefront/stripe/subscription-sessionJWT

This returns a Stripe Checkout Session URL — open it in WebView or url_launcher. Subscription state lives on the customer record and is updated by Stripe webhooks; the mobile client just kicks off the flow.

Testing

Stripe ships a pile of test cards:

NumberBehavior
4242 4242 4242 4242Always succeeds
4000 0025 0000 3155Requires 3D Secure
4000 0000 0000 9995Insufficient funds
4000 0000 0000 0002Card declined

Use any future expiry and any 3-digit CVC. Run against a Stripe test-mode key (configured server-side) — production keys reject test cards.

The next recipe — Chat with customer support — leaves payments and connects to AppEngine's WebSocket gateway.