Payments in Flutter isn't just about rendering a card form—it's about understanding the rules, the platforms, and the backend confirmations that actually make money move safely. Over 5.5 years building production platforms, I've learned that the biggest payment failures come from skipping either the platform rules or the server-side validation, not from weak UI code.
Why Payment Strategy Matters
When you're building a Flutter app that handles money, your architecture decision happens before you write a single line of UI code. Are you selling physical goods, digital content, or services? The answer determines whether you use Stripe, PayPal, a local gateway, or your app store's native payment system. I've seen teams build beautiful card UIs only to discover their app store rejection or their regulatory requirements halfway through launch—that's expensive and avoidable.
The rule is simple: physical goods and services use payment processors like Stripe or local equivalents; digital goods inside apps must use the app store's in-app purchase system. This isn't a suggestion—it's store policy, and violations get your app rejected.
Physical Goods and Services — Use Stripe or Local Gateways
For anything tangible or location-based (food delivery, freelance services, ride-hailing), you'll integrate directly with a payment processor. I use Stripe on most projects because their mobile SDKs are solid, their webhooks are reliable, and their sandbox environment mirrors production closely.
Here's a practical setup. First, add the Stripe Flutter package to your pubspec:
dependencies:
flutter_stripe: ^9.0.0
http: ^1.1.0Initialize Stripe in your main app before you render any payment UI:
import 'package:flutter_stripe/flutter_stripe.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
Stripe.publishableKey = 'pk_test_...'; // sandbox key
Stripe.merchantIdentifier = 'merchant.example.app';
await Stripe.instance.applySettings();
runApp(const MyApp());
}Now, when a user wants to pay for a service (say, booking a ride), create a payment intent on your backend, fetch its client secret, and present the payment sheet:
Future processPayment(double amount) async {
try {
// 1. Backend creates payment intent
final response = await http.post(
Uri.parse('https://api.example.com/payment-intent'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'amount': (amount * 100).toInt()}),
);
if (response.statusCode != 200) {
throw Exception('Failed to create payment intent');
}
final data = jsonDecode(response.body);
final clientSecret = data['clientSecret'] as String;
// 2. Present Stripe payment sheet
await Stripe.instance.presentPaymentSheet();
// 3. Payment succeeded; confirm on backend
await _confirmPaymentOnBackend(data['paymentIntentId']);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Payment successful')),
);
} on StripeException catch (e) {
print('Stripe error: ${e.error.localizedMessage}');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Payment failed: ${e.error.localizedMessage}')),
);
}
}Notice that I'm creating the payment intent server-side. Never, ever expose your Stripe secret key in the client. The payment sheet handles the sensitive card details; your app never sees them.
For local gateways (common in Asia and Africa), the pattern is similar but the SDK differs. In Sri Lanka, I've worked with Payfort and Sslcommerz. They both provide mobile SDKs with similar flows: create a session on your backend, retrieve credentials from the gateway, initialize the native payment UI, then confirm the result.
Digital Goods — App Stores Require In-App Purchases
If you're selling digital content—a course, a premium filter, an unlock—the app store owns the transaction. Apple takes 30% (15% for smaller businesses), Google takes 15-30%, and they require you use their in-app purchase system. This is non-negotiable.
Use the in_app_purchase plugin:
dependencies:
in_app_purchase: ^3.1.0First, query available products from the store:
import 'package:in_app_purchase/in_app_purchase.dart';
Future initializeProducts() async {
final available = await InAppPurchase.instance.isAvailable();
if (!available) {
print('In-app purchase not available');
return;
}
const Set kIds = {'premium_subscription', 'unlock_advanced'};
final ProductDetailsResponse response =
await InAppPurchase.instance.queryProductDetails(kIds);
if (response.notFoundIDs.isNotEmpty) {
print('Products not found: ${response.notFoundIDs}');
}
for (var product in response.productDetails) {
print('${product.id}: ${product.title} - ${product.price}');
}
}When the user taps "Buy," initiate the purchase:
Future purchasePremium() async {
const productId = 'premium_subscription';
final response = await InAppPurchase.instance.queryProductDetails({productId});
if (response.productDetails.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Product not found')),
);
return;
}
final product = response.productDetails.first;
await InAppPurchase.instance.buyNonConsumable(
purchaseParam: PurchaseParam(productDetails: product),
);
}Listen for purchase updates in your app's init or main:
void setupPurchaseListener() {
InAppPurchase.instance.purchaseStream.listen(
(List purchaseDetailsList) {
for (final purchaseDetails in purchaseDetailsList) {
if (purchaseDetails.status == PurchaseStatus.pending) {
print('Purchase pending...');
} else if (purchaseDetails.status == PurchaseStatus.error) {
print('Purchase error: ${purchaseDetails.error}');
} else if (purchaseDetails.status == PurchaseStatus.purchased ||
purchaseDetails.status == PurchaseStatus.restored) {
// Unlock feature and confirm on backend
_unlockPremium(purchaseDetails.verificationData.localVerificationData);
}
}
},
);
}This is the key difference: the in-app purchase system handles the store payment; your app confirms ownership via the receipt. You still must validate receipts on your backend to prevent fraud.
Server Confirmation — Confirm Payments on Your Backend via Webhooks
Never trust the client. A savvy user can fake a success screen. Always confirm payments server-side, and for processors like Stripe, always listen to webhooks.
For Stripe, set up a webhook endpoint that listens for payment_intent.succeeded:
// Your NestJS or .NET backend
@Post('/webhooks/stripe')
async handleStripeWebhook(@Body() raw: any, @Headers('stripe-signature') sig: string) {
let event;
try {
event = stripe.webhooks.constructEvent(raw, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
throw new BadRequestException('Webhook signature failed');
}
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object;
await this.paymentsService.confirmPayment(paymentIntent.id, paymentIntent.metadata.userId);
}
return { received: true };
}For in-app purchases, validate the receipt on your backend by sending it to Apple or Google:
// Backend: validate iOS receipt
Future validateAppleReceipt(String receipt, String productId) async {
final response = await http.post(
Uri.parse('https://buy.itunes.apple.com/verifyReceipt'),
body: jsonEncode({'receipt-data': receipt, 'password': kAppleSharedSecret}),
);
final data = jsonDecode(response.body);
return data['status'] == 0; // 0 means valid
}Only after server confirmation should you unlock the feature, grant the subscription, or mark the order as paid. This prevents refund and replay attacks.
Test Thoroughly — Sandbox Accounts and Failure Scenarios
I always test three scenarios: success, decline, and timeout. Stripe provides test card numbers:
- Success: 4242 4242 4242 4242
- Decline: 4000 0000 0000 0002
- 3D Secure (requires authentication): 4000 0025 0000 3155
For in-app purchases, use TestFlight (iOS) and internal testing tracks (Android). Apple and Google provide sandbox accounts that don't charge real money.
Write integration tests that mock both success and failure:
test('payment success calls confirmPaymentOnBackend', () async {
// Mock Stripe returning success
when(mockStripe.presentPaymentSheet()).thenAnswer((_) async => null);
// Mock backend confirming payment
when(mockHttp.post(
Uri.parse('https://api.example.com/confirm-payment'),
body: any,
)).thenAnswer((_) async => http.Response('{"success": true}', 200));
await processPayment(50.0);
// Verify confirmation was called
verify(mockHttp.post(
Uri.parse('https://api.example.com/confirm-payment'),
body: any,
)).called(1);
});
test('payment decline shows error message', () async {
when(mockStripe.presentPaymentSheet()).thenThrow(
StripeException(error: StripeError(code: 'card_declined')),
);
await processPayment(50.0);
expect(find.byType(SnackBar), findsOneWidget);
});Common Mistakes
- Trusting the client payment response. A declined card can be faked. Always confirm on your backend.
- Exposing the API secret in your app. Use environment variables and a backend proxy for sensitive keys.
- Skipping webhook handling. Webhooks catch payments that succeed after the user closes the app. Without them, you'll have unpaid orders sitting in your database.
- Forgetting to handle currency and rounding. Stripe expects amounts in cents. Always multiply by 100 for USD, handle decimals carefully for currencies like JPY that don't use them.
- Not testing declined scenarios. Your success flow is obvious. Test what happens when a card is declined, the network drops, or the user closes the payment sheet halfway.
- Using store payment for physical goods. Never try to use in-app purchase for a food order or a taxi ride. The store policies are explicit.
Key Takeaways
- Physical goods and services use Stripe, PayPal, or local gateways; digital goods use the app store's in-app purchase system.
- Create payment intents server-side and pass the client secret to your Flutter UI. Never expose API secrets in the app.
- Always confirm payments on your backend via webhooks (Stripe) or receipt validation (in-app purchase).
- Test success, decline, and timeout scenarios using sandbox credentials before releasing to production.
- Handle concurrency carefully: a webhook might arrive before the user closes the payment sheet, so idempotent confirmation endpoints prevent double-crediting.






Comments
Loading comments…