Skip to content

· 7 min read · Nadeera Silva

Payments

Flutter Stripe Integration: PaymentSheet, Webhooks and Connect

Integrate Stripe in Flutter with flutter_stripe: PaymentSheet, Apple Pay and Google Pay, webhooks that fulfil orders, and Stripe Connect for marketplaces.

A card flows into a phone showing a payment sheet, then on to a ringing webhook bell that splits into three streams of coins dropping into separate wallets, illustrating PaymentSheet, webhooks and Connect payouts

A Flutter Stripe integration needs three pieces: one server endpoint, PaymentSheet in the app and one webhook. The parts people get wrong are who confirms the payment, when to fulfil the order and how money moves in a marketplace with Stripe Connect. This guide covers all of it with flutter_stripe, and the server code works the same for React Native and native apps.

Can you use Stripe instead of Apple in-app purchase?

Check this before writing code. Apple's App Review Guideline 3.1.1 says that if you unlock features or functionality in the app, such as subscriptions, premium content or a full version, you must use in-app purchase. Guideline 3.1.3(e) says the opposite for physical goods and services used outside the app: those must use other payment methods, such as Apple Pay or card entry.

You're sellingUse
Food, bookings, tickets, deliveries, marketplace itemsStripe
Services delivered in personStripe
Premium features, content or a subscription inside the appApple and Google in-app purchase

For the second case, see my Flutter in-app purchase guide.

Set up the flutter_stripe package

flutter_stripe (14.2.0 at the time of writing) is published by flutterstripe.io and wraps Stripe's native iOS and Android SDKs. Its README recommends PaymentSheet for new integrations, as Stripe is sunsetting CardField and CardForm.

  1. flutter pub add flutter_stripe
  2. Android: use FlutterFragmentActivity instead of FlutterActivity in MainActivity.kt, and a Theme.AppCompat descendant for the activity theme. The README lists a few more items (Kotlin and Gradle plugin versions, ProGuard rules). Skipping these is the usual reason PaymentSheet fails to open on Android.
  3. iOS: set the deployment target to 13.0 or higher.
  4. Set the publishable key at startup. Stripe lists publishable keys as safe to expose; the secret key never goes in the app.
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  Stripe.publishableKey = const String.fromEnvironment('STRIPE_PK');
  Stripe.merchantIdentifier = 'merchant.com.example.app'; // only for Apple Pay
  await Stripe.instance.applySettings();
  runApp(const App());
}

How the Stripe payment flow works in a mobile app

With PaymentSheet, the app confirms the PaymentIntent when the user taps pay, and the server fulfils the order when Stripe sends payment_intent.succeeded.

Diagram of the PaymentSheet flow: the app asks the server to start checkout, the server creates a PaymentIntent and returns its client secret, PaymentSheet confirms the payment, and the webhook marks the order paid
The app confirms the payment. The webhook decides when the order is paid.
  1. The app asks your server to start checkout for an order.
  2. The server works out the amount from its own data and creates a PaymentIntent with the secret key.
  3. The server returns the PaymentIntent's client secret.
  4. The app calls initPaymentSheet and presentPaymentSheet. PaymentSheet collects payment details, handles 3D Secure and confirms the payment.
  5. Stripe sends payment_intent.succeeded to your webhook. That's when you mark the order paid.

Server: create the PaymentIntent

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

app.post("/checkout", async (req, reply) => {
  const order = await loadOrderForUser(req.user.id, req.body.orderId);
  const intent = await stripe.paymentIntents.create(
    {
      amount: order.totalMinorUnits,   // 1099 = 10.99 in a two-decimal currency
      currency: order.currency,        // lowercase ISO code, e.g. "eur"
      automatic_payment_methods: { enabled: true },
      metadata: { orderId: order.id },
    },
    { idempotencyKey: `order-${order.id}` }
  );
  return { clientSecret: intent.client_secret };
});

The amount comes from your database, so a modified client can't change the price. The idempotency key stops a retried request from creating a second PaymentIntent.

Flutter: show PaymentSheet

import 'package:flutter_stripe/flutter_stripe.dart';

Future<void> pay(String orderId) async {
  final clientSecret = await api.startCheckout(orderId);
  await Stripe.instance.initPaymentSheet(
    paymentSheetParameters: SetupPaymentSheetParameters(
      paymentIntentClientSecret: clientSecret,
      merchantDisplayName: 'Your Shop',
      applePay: const PaymentSheetApplePay(merchantCountryCode: 'US'),
      googlePay: const PaymentSheetGooglePay(merchantCountryCode: 'US', testEnv: true),
    ),
  );
  try {
    await Stripe.instance.presentPaymentSheet();
    showProcessing(); // the webhook decides when the order is paid
  } on StripeException catch (e) {
    if (e.error.code == FailureCode.Canceled) return;
    showError(e.error.localizedMessage ?? 'Payment failed');
  }
}

A successful presentPaymentSheet means the user finished the sheet. It isn't proof of payment for your records: some payment methods settle later, and the app can be closed before it reports back. Set testEnv: false for Google Pay once Google has approved production access.

Stripe webhooks: fulfil the order on the server

app.post("/stripe/webhook", { config: { rawBody: true } }, async (req, reply) => {
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      req.rawBody!, req.headers["stripe-signature"] as string,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return reply.code(400).send();
  }

  if (await alreadyProcessed(event.id)) return reply.send();

  if (event.type === "payment_intent.succeeded") {
    const intent = event.data.object as Stripe.PaymentIntent;
    await markOrderPaid(intent.metadata.orderId, intent.id);
  }
  await recordProcessed(event.id);
  return reply.send();
});

Stripe's docs add two rules: the same event can arrive more than once, so track event IDs, and events aren't guaranteed to arrive in order. When unsure, fetch the current object from the API. Also handle payment_intent.processing (show "pending") and payment_intent.payment_failed (offer another attempt).

Stripe Connect for marketplace apps

Stripe Connect lets a platform take payments and pay sellers. Two things trip people up.

Connect isn't escrow. Stripe's docs say: "Escrow has a precise legal definition, and Stripe doesn't provide escrow services or support escrow accounts." What Connect controls is where a charge lands and when a seller is paid out.

Standard, Express and Custom are now legacy account types. Stripe's account types page says new Connect platforms should use the Accounts v2 API, or v1 Accounts with controller properties, instead of choosing one of the three. If you've read "Stripe Connect vs Express" comparisons, that's the context: existing Express platforms keep working, but new builds describe the account's responsibilities instead.

Diagram of a Connect fund flow: the customer pays, funds land on the platform account, a transfer moves the seller's share to the connected account, and a payout sends it to the seller's bank
Connect controls where funds land and when they're paid out. It doesn't hold them in escrow.
Charge typeWho the customer paysTypical use
Direct chargesThe connected accountSaaS platforms where sellers deal directly with their customers
Destination chargesYour platform, with the seller's share transferred automaticallyOne seller per order, like a booking or delivery app
Separate charges and transfersYour platform, with transfers you create laterOrders split between sellers, or paying out after delivery

A destination charge adds two fields to the PaymentIntent above:

application_fee_amount: 123,                 // your platform fee
transfer_data: { destination: sellerAccountId },

With separate charges and transfers, you create the transfer when you're ready, linked to the original charge:

await stripe.transfers.create({
  amount: 7000,
  currency: "eur",
  destination: sellerAccountId,
  source_transaction: chargeId, // from the PaymentIntent's latest_charge
});

Stripe integration checklist

  • The secret key and webhook secret live only on the server. My post on hiding API keys in Flutter explains why build-time injection doesn't protect them.
  • The server decides amount and currency.
  • Every retryable POST to Stripe sends an idempotency key.
  • Webhooks verify signatures, skip duplicate event IDs and tolerate out-of-order events.
  • Card details only enter Stripe's UI, so they never touch your servers.

Testing a Stripe integration before going live

  1. Run stripe listen --forward-to localhost:4242/stripe/webhook to send test events to your local server.
  2. Pay with 4242 4242 4242 4242 (succeeds), 4000 0025 0000 3155 (needs 3D Secure) and 4000 0000 0000 9995 (declined).
  3. Close the app straight after paying and check the order still becomes paid through the webhook.
  4. Resend the same event and check nothing is fulfilled twice.
  5. For Connect, onboard a test connected account and check the transfer and payout appear.
  6. Switch the publishable key, secret key and webhook secret to live mode together, and register the live endpoint.

Selling subscriptions on iOS as well? The StoreKit subscriptions post shows how to merge several payment sources into one access record.

Frequently asked questions

How do I integrate Stripe in Flutter?

Add flutter_stripe, set the publishable key, create a PaymentIntent on your server, then call initPaymentSheet and presentPaymentSheet in the app. Fulfil the order on the payment_intent.succeeded webhook.

Why does PaymentSheet not open on Android?

Usually because MainActivity still extends FlutterActivity. flutter_stripe requires FlutterFragmentActivity and a Theme.AppCompat descendant, among the other setup steps in its README.

How do I add Apple Pay and Google Pay with flutter_stripe?

Set Stripe.merchantIdentifier for Apple Pay and pass PaymentSheetApplePay and PaymentSheetGooglePay with your merchant country code to SetupPaymentSheetParameters.

Can I use Stripe instead of Apple in-app purchase?

For physical goods and services used outside the app, yes, and guideline 3.1.3(e) requires a method other than in-app purchase. For digital features or content inside the app, guideline 3.1.1 requires in-app purchase.

Who confirms the PaymentIntent with PaymentSheet?

The app does, when the user pays. Your server creates the PaymentIntent and fulfils the order when the webhook arrives.

What is the difference between Stripe Connect and Express?

Express is one of Connect's account types. Stripe now calls Standard, Express and Custom legacy account types and points new platforms to the Accounts v2 API.

Does Stripe Connect hold funds in escrow?

No. Stripe's docs say it doesn't provide escrow services. You can delay payouts with manual payouts, within Stripe's time limits.

Why did my Stripe webhook signature check fail?

Usually because the framework parsed the JSON body before verification. Stripe needs the raw request body.

Sources

  1. App Review Guidelines (3.1.1 and 3.1.3) (Apple)
  2. Accept a payment (mobile Payment Element) (Stripe)
  3. The Payment Intents API (Stripe)
  4. flutter_stripe (pub.dev)
  5. Receive Stripe events in your webhook endpoint (Stripe)
  6. stripe-node README (webhook signing) (GitHub)
  7. Idempotent requests (Stripe)
  8. API keys (Stripe)
  9. Charges in a Connect integration (Stripe)
  10. Create destination charges (Stripe)
  11. Create separate charges and transfers (Stripe)
  12. Manual payouts (Stripe)
  13. Connected account types (Stripe)
  14. Testing (Stripe)
SharePost on XLinkedIn

Comments

Share a question or note about this article.

Loading comments…

Flutter Stripe Integration: PaymentSheet, Webhooks, Connect | Nadeera Silva