Skip to content
ExponaBLOG
FLUTTER · DAY 068 / 100

Payments in Flutter: a practical guide for developers

Card payments and store rules.

Expona Admin7 min read

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:

dart
dependencies:
  flutter_stripe: ^9.0.0
  http: ^1.1.0

Initialize Stripe in your main app before you render any payment UI:

dart
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:

dart
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:

dart
dependencies:
  in_app_purchase: ^3.1.0

First, query available products from the store:

dart
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:

dart
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:

dart
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:

dart
// 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:

dart
// 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:

dart
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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles