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
/storefront/stripe/intentJWT/storefront/checkout-cartJWT/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:
/storefront/stripe/configNo authNever 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
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
Create the payment intent
Call
/storefront/stripe/intentwith the total and currency. The response contains aclientSecretlikepi_3NX..._secret_abc. - 3
Present the Stripe payment sheet
flutter_stripeships a native sheet that handles card entry, Apple Pay, and Google Pay. - 4
Confirm the order
When the sheet closes successfully, call
/storefront/checkout-cartwith 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
StripeExceptionwithcode: 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:
/storefront/stripe/subscription-sessionJWTThis 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:
| Number | Behavior |
|---|---|
4242 4242 4242 4242 | Always succeeds |
4000 0025 0000 3155 | Requires 3D Secure |
4000 0000 0000 9995 | Insufficient funds |
4000 0000 0000 0002 | Card 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.