The Flutter Kit logoThe Flutter Kit
Tutorial

Flutter Stripe Integration 2026: Accept Payments in Cross-Platform Apps

The 2026 Flutter Stripe guide. Payment Sheet, Apple Pay, Google Pay, subscriptions, webhooks, SCA, security, and when Stripe is actually allowed in mobile apps.

Ahmed GaganAhmed Gagan
16 min read

Stripe is the best payments API on the planet, but it is not always legal to use inside your Flutter app. In 2026, after the DMA in Europe, the Korean and Dutch antitrust rulings, and the long Epic vs Apple fallout in the US, the rules for external payments are clearer than ever but still nuanced. Digital goods consumed in the app generally still need IAP on iOS. Physical goods, services consumed outside the app, marketplace transactions, and real-world bookings are perfectly fine to charge through Stripe. This guide covers the full 2026 setup: flutter_stripe Payment Sheet, Apple Pay, Google Pay, subscriptions, webhooks, SCA, and the security patterns that keep your secret key off the device.

Short version: use the flutter_stripe package, create a PaymentIntent on your server, return the client secret to the app, present Stripe.instance.initPaymentSheet followed by presentPaymentSheet, and confirm fulfilment via webhook. Never put your secret key in the app. For digital subscriptions to in-app features on iOS, default to IAP and RevenueCat unless you have a specific carve-out (EU, Korea, US reader-app, etc.).

The 2026 App Store rules for external payments

The landscape changed three times between 2024 and 2026, so it pays to be precise:

  • European Union (DMA). Apple must allow alternative payment providers and external links inside iOS apps distributed in the EU. You can charge through Stripe for digital goods in the EU but you must use the StoreKit External Purchase Link entitlement and accept Apple's Core Technology Fee.
  • United States. Following the 2024 Epic vs Apple injunction and the 2025 enforcement orders, US apps can include external payment links without Apple taking a commission on those transactions. Apple still requires the StoreKit External Purchase Link entitlement and a scare sheet, but Stripe checkout via an external browser link is allowed.
  • South Korea and the Netherlands. Both jurisdictions require Apple to allow third-party payments for specific app categories (dating apps in NL, all apps in KR). Reduced commission still applies on Apple-billed transactions.
  • Everywhere else. Default Apple policy applies. Digital goods consumed in the app must use IAP. Physical goods, services consumed outside the app, ads, donations to non-profits, and person-to-person payments can use Stripe.
  • Google Play. User Choice Billing rolled out globally in 2025. You can offer Stripe alongside Google Play Billing for digital goods in most markets, with a reduced 11 to 26 percent service fee on Google Play Billing transactions.

The practical rule for indie developers in 2026: if you sell a digital subscription that unlocks in-app features, default to IAP plus RevenueCat. If you sell physical goods, services consumed outside the app, bookings, or run a marketplace, use Stripe. If you want to offer external payments on iOS as the EU/US rules allow, budget two extra weeks for the entitlement application and the scare-sheet flow.

When to use Stripe vs IAP vs RevenueCat

The decision tree is genuinely simple once you know the rules. Here is the matrix I use for every new Flutter project:

ScenarioStripeNative IAPRevenueCat + IAP
Digital subscription unlocking app featuresOnly in EU / US / KR with entitlementAllowed, painful to manageBest default in 2026
One-off digital unlock (premium tier)Same EU / US carve-outsAllowed, simpleBest for cross-platform parity
Physical goods (ecommerce)Required, IAP forbiddenForbidden by AppleNot applicable
Services consumed outside the app (rideshare, food)RequiredForbidden by AppleNot applicable
Marketplace (Etsy, Airbnb style)Required (Stripe Connect)ForbiddenNot applicable
Booking real-world service (massage, classes)RequiredForbiddenNot applicable
Donations to non-profitsAllowedNot required (and Apple takes 30 percent if you use IAP)Not applicable
Person-to-person payments (Venmo style)Required (Stripe Connect)ForbiddenNot applicable
Reader app (Spotify, Kindle pattern)Allowed via external link entitlementOptionalOptional

The most common indie mistake is reaching for Stripe to dodge Apple's 30 percent on a subscription app, then getting rejected at review. Read the rules for your specific category before you write any code.

Setup: flutter_stripe package, publishable/secret keys, server setup

The official flutter_stripe package (maintained by FlutterCommunity and sponsored by Stripe in 2026) wraps the native iOS and Android SDKs. It supports the Payment Sheet, Apple Pay, Google Pay, SCA-compliant 3D Secure, saved cards, and subscriptions. Setup takes five minutes.

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_stripe: ^11.0.0
  http: ^1.2.0
  flutter_dotenv: ^5.2.0

Add your publishable key to .env (never the secret key) and initialise Stripe in main.dart:

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_stripe/flutter_stripe.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: '.env');

  Stripe.publishableKey = dotenv.env['STRIPE_PUBLISHABLE_KEY']!;
  Stripe.merchantIdentifier = 'merchant.com.yourapp';
  Stripe.urlScheme = 'flutterstripe';
  await Stripe.instance.applySettings();

  runApp(const MyApp());
}

Two platform-specific bits you must not skip. On iOS, set the minimum deployment target to 13.0 in ios/Podfile. On Android, your MainActivity must extend FlutterFragmentActivity (not FlutterActivity) because Payment Sheet renders as a bottom sheet that requires the AndroidX FragmentManager.

// android/app/src/main/kotlin/.../MainActivity.kt
import io.flutter.embedding.android.FlutterFragmentActivity

class MainActivity : FlutterFragmentActivity()

For the server, the standard pattern is a small Node.js, Cloud Functions, or Supabase Edge Functions backend that holds the secret key and exposes two endpoints:/create-payment-intent and a webhook receiver. We will write both below.

Building a Payment Sheet flow end-to-end

Payment Sheet is the prebuilt UI Stripe ships. It renders cards, Apple Pay, Google Pay, Link, and any local payment methods you enable in the Stripe dashboard. You hand it a client secret and it handles the rest including SCA.

The flow is three steps: call your backend to create a PaymentIntent, init the sheet with the returned client secret, present the sheet, and confirm fulfilment via the webhook (not by trusting the client's success callback).

// lib/services/payment_service.dart
import 'dart:convert';
import 'package:flutter_stripe/flutter_stripe.dart';
import 'package:http/http.dart' as http;

class PaymentService {
  static const _backend = 'https://api.yourapp.com';

  Future<void> checkout({required int amountCents, required String currency}) async {
    // 1. Ask your backend to create a PaymentIntent.
    final res = await http.post(
      Uri.parse('$_backend/create-payment-intent'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'amount': amountCents, 'currency': currency}),
    );
    final data = jsonDecode(res.body) as Map<String, dynamic>;
    final clientSecret = data['clientSecret'] as String;

    // 2. Init the Payment Sheet.
    await Stripe.instance.initPaymentSheet(
      paymentSheetParameters: SetupPaymentSheetParameters(
        paymentIntentClientSecret: clientSecret,
        merchantDisplayName: 'Your App',
        applePay: const PaymentSheetApplePay(merchantCountryCode: 'US'),
        googlePay: const PaymentSheetGooglePay(merchantCountryCode: 'US', testEnv: false),
        style: ThemeMode.system,
      ),
    );

    // 3. Present it. Throws StripeException on cancel or error.
    await Stripe.instance.presentPaymentSheet();
    // Do NOT mark the order paid here. Wait for the webhook.
  }
}

The corresponding backend endpoint. This is plain Node.js Express but the shape is identical in Cloud Functions or Supabase Edge:

// server/index.js
import express from 'express';
import Stripe from 'stripe';

const app = express();
app.use(express.json());
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.post('/create-payment-intent', async (req, res) => {
  const { amount, currency } = req.body;
  // ALWAYS recompute amount server-side from your DB. Never trust the client.
  const intent = await stripe.paymentIntents.create({
    amount,
    currency,
    automatic_payment_methods: { enabled: true },
    metadata: { userId: req.user?.id ?? 'anonymous' },
  });
  res.json({ clientSecret: intent.client_secret });
});

app.listen(3000);

The single most important security rule on this entire page: recompute the amount on the server from your database, never trust the amount the client posts. A user can edit your app traffic and send amount: 1 for a $1000 product. Look up the product by ID server-side and use the canonical price.

Apple Pay and Google Pay through Stripe

Apple Pay and Google Pay are not separate integrations when you use the Payment Sheet, they are buttons inside it. You enable them with two extra config fields and one platform setup task each.

Apple Pay setup. In Apple Developer, create a Merchant ID (e.g. merchant.com.yourapp). In Xcode, add the Apple Pay capability and select your merchant ID. In main.dart, set Stripe.merchantIdentifier to match. The Apple Pay button appears automatically in the sheet when the device has a provisioned card.

Google Pay setup. Add the Google Pay metadata to your AndroidManifest.xml and set testEnv: false in production. Google Pay test mode shows the dialog but does not move real money, so wire it up early.

<!-- android/app/src/main/AndroidManifest.xml -->
<application ...>
  <meta-data
    android:name="com.google.android.gms.wallet.api.enabled"
    android:value="true" />
</application>

A small but important production tweak: hide the credit card input when Apple Pay or Google Pay is available and the user has a card on file. Conversion on one-tap wallets is dramatically higher than card entry on mobile, often by 30 to 60 percent. Configure this via allowsDelayedPaymentMethods: false and a custom primaryButtonLabel like "Pay with Apple Pay" when wallet detection succeeds.

Subscriptions with Stripe (price IDs, customer portal, webhooks)

Stripe subscriptions are powered by Products and Prices. Each Price has a recurring interval (month, year, week) and a Price ID like price_1Q9z.... Your Flutter app sends a Price ID to the backend, the backend creates a Subscription tied to a Customer, and the Payment Sheet collects the payment method.

// server/subscriptions.js
app.post('/create-subscription', async (req, res) => {
  const { priceId, userId } = req.body;
  // 1. Get or create the Stripe Customer for this user.
  let customer = await getCustomerForUser(userId);
  if (!customer) {
    customer = await stripe.customers.create({ metadata: { userId } });
    await saveCustomerIdForUser(userId, customer.id);
  }

  // 2. Create the subscription with default_incomplete so we can collect payment.
  const subscription = await stripe.subscriptions.create({
    customer: customer.id,
    items: [{ price: priceId }],
    payment_behavior: 'default_incomplete',
    payment_settings: { save_default_payment_method: 'on_subscription' },
    expand: ['latest_invoice.payment_intent'],
  });

  const clientSecret = subscription.latest_invoice.payment_intent.client_secret;
  res.json({
    subscriptionId: subscription.id,
    clientSecret,
    customerId: customer.id,
  });
});

On the Flutter side, the flow is identical to one-off Payment Sheet, you just pass the subscription's invoice client secret. After the user pays, Stripe activates the subscription and fires customer.subscription.created on the webhook. Your webhook handler updates your database with the active entitlement.

The customer portal is Stripe's prebuilt subscription management screen (cancel, update card, view invoices). Create a portal session on the backend and open the URL in a WebView or url_launcher:

app.post('/create-portal-session', async (req, res) => {
  const { customerId } = req.body;
  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: 'https://yourapp.com/account',
  });
  res.json({ url: session.url });
});

Webhooks: keeping your DB in sync (with Cloud Functions example)

Webhooks are the source of truth for "did the user actually pay." The client callback after Payment Sheet success is just a UX hint. The user might lose connectivity right after paying, refresh the app, or kill the process. The webhook always fires once Stripe charges the card and is the only event you should trust to grant entitlement.

// functions/src/stripe-webhook.ts (Firebase Cloud Functions v2)
import { onRequest } from 'firebase-functions/v2/https';
import { defineSecret } from 'firebase-functions/params';
import Stripe from 'stripe';
import { getFirestore } from 'firebase-admin/firestore';

const stripeSecret = defineSecret('STRIPE_SECRET_KEY');
const webhookSecret = defineSecret('STRIPE_WEBHOOK_SECRET');

export const stripeWebhook = onRequest(
  { secrets: [stripeSecret, webhookSecret] },
  async (req, res) => {
    const stripe = new Stripe(stripeSecret.value());
    const signature = req.headers['stripe-signature'] as string;
    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(req.rawBody, signature, webhookSecret.value());
    } catch (err) {
      res.status(400).send('Invalid signature');
      return;
    }

    const db = getFirestore();
    switch (event.type) {
      case 'payment_intent.succeeded': {
        const pi = event.data.object as Stripe.PaymentIntent;
        await db.collection('orders').doc(pi.id).set({
          status: 'paid',
          amount: pi.amount,
          userId: pi.metadata.userId,
          paidAt: new Date(),
        }, { merge: true });
        break;
      }
      case 'customer.subscription.updated':
      case 'customer.subscription.created': {
        const sub = event.data.object as Stripe.Subscription;
        await db.collection('entitlements').doc(sub.customer as string).set({
          status: sub.status,
          priceId: sub.items.data[0].price.id,
          currentPeriodEnd: sub.current_period_end,
        }, { merge: true });
        break;
      }
      case 'customer.subscription.deleted': {
        const sub = event.data.object as Stripe.Subscription;
        await db.collection('entitlements').doc(sub.customer as string).update({
          status: 'canceled',
        });
        break;
      }
    }
    res.status(200).send('ok');
  },
);

Three webhook rules I enforce on every project: always verify the signature with the raw body (never req.body parsed), make every handler idempotent because Stripe retries failed deliveries, and return 200 quickly then queue heavy work to a background job. Stripe considers anything over 30 seconds a failure.

SCA / 3D Secure and PaymentIntent confirmation flow

Strong Customer Authentication has been mandatory in Europe since 2021 and is now required in the UK, parts of LATAM, and India. Stripe's PaymentIntent API handles the entire flow automatically when you use Payment Sheet. The card is charged, Stripe determines whether SCA is required, and if so the sheet presents the bank's 3D Secure challenge inline (usually a one-tap biometric in the bank's app).

When Payment Sheet returns successfully, the PaymentIntent status can still berequires_action in rare edge cases (off-session retries, async payment methods like SEPA debit). Always re-fetch the PaymentIntent status in your webhook before marking the order as paid:

// In webhook handler for 'payment_intent.succeeded'
if (pi.status !== 'succeeded') return; // be paranoid
if (pi.amount_received !== pi.amount) return; // partial captures
// Now safe to fulfil.

Saving cards and one-tap purchases

For repeat purchases (re-orders, in-app top-ups), saving the customer's card after the first checkout is essential. Conversion on a saved-card one-tap purchase is roughly 2 to 3 times higher than re-entering details.

Two patterns: SetupIntent to save a card without charging, or PaymentIntent with setup_future_usage to save during a real charge. Setup Future Usage is what most ecommerce apps want:

const intent = await stripe.paymentIntents.create({
  amount,
  currency,
  customer: customerId,
  setup_future_usage: 'off_session',
  automatic_payment_methods: { enabled: true },
});

For subsequent off-session charges (e.g. auto-reorder), create a PaymentIntent withoff_session: true and confirm: true, passing the savedpayment_method. If the bank requires SCA, Stripe returnsrequires_action and you bounce the user back into the app to re-authenticate.

Testing: Stripe CLI, test cards, sandbox mode

Stripe's test mode is excellent. Use the test publishable key (starts with pk_test_) in main.dart for dev and CI builds. Apple Pay and Google Pay both work in test mode with provisioned test cards.

Useful test cards:

  • 4242 4242 4242 4242 succeeds with no SCA challenge
  • 4000 0027 6000 3184 triggers 3D Secure 2 (succeeds on confirm)
  • 4000 0000 0000 9995 declines with insufficient funds
  • 4000 0000 0000 0341 succeeds on first charge then fails on off-session

For local webhook testing, the Stripe CLI is mandatory. It forwards events from Stripe to a local URL so you can debug webhook handlers end-to-end without deploying:

# Install the Stripe CLI, then:
stripe login
stripe listen --forward-to localhost:3000/stripe-webhook
# In another terminal, trigger an event:
stripe trigger payment_intent.succeeded

Security: never put secret key in app, always proxy

The most common Stripe mistake I see in indie Flutter apps is putting the secret key (sk_live_...) somewhere in the client to dodge the backend. The Stripe SDK will warn you. Do not bypass the warning. Anyone with a network proxy can extract the key from a built APK or IPA in five minutes.

The non-negotiable rules:

  • Only the publishable key (pk_) ever ships in the app.
  • Every charge amount is recomputed server-side from product/price IDs.
  • Every webhook signature is verified with the raw body and the webhook secret.
  • Every authenticated endpoint requires a real user token, not a client-provided userId.
  • Rate-limit /create-payment-intent to prevent enumeration attacks.
  • Rotate webhook signing secrets after every team change.

Anti-patterns to avoid

  • Do not trust the client's success callback. Always confirm the charge via the webhook before granting access or shipping the order.
  • Do not pass the amount from the client. Pass the product/price ID and look up the canonical amount in your database.
  • Do not use Stripe for in-app digital subscriptions on iOS unless you have the EU/US/KR external purchase entitlement. Apple will reject you.
  • Do not put the secret key in your Flutter app, env file shipped with the app, or remote config. Anything that ends up on the device is leaked.
  • Do not skip FlutterFragmentActivity on Android. Payment Sheet silently fails to render with the wrong base activity.
  • Do not test Apple Pay only in simulator. Simulator Apple Pay never calls the real card-validation path. Always test on a device with a real provisioned card in test mode.
  • Do not retry webhook handlers manually in your own code. Stripe already retries with exponential backoff. Just make handlers idempotent.
  • Do not forget VAT, GST, and US sales tax. Stripe Tax handles registration and calculation for a small fee. Build it in from day one.

What The Flutter Kit ships

The Flutter Kit ships a complete Stripe integration so you skip the two-week setup: flutter_stripe wired with Payment Sheet, Apple Pay, and Google Pay; a Cloud Functions backend with create-payment-intent, create-subscription,create-portal-session, and a signed webhook handler that updates Firestore and Supabase entitlements; saved card support with setup_future_usage; one-tap re-purchase flow; and a side-by-side IAP path via RevenueCat so you can choose per platform per category. The Android MainActivity already extends FlutterFragmentActivity, the iOS Merchant ID is templated, and the Stripe CLI webhook config is in the README.

$69 one-time, unlimited commercial projects. See every integration on the features page or jump to checkout.

Final recommendation

For a Flutter app in 2026, the right mental model is: Stripe for real-world money, IAP plus RevenueCat for digital subscriptions, and the new external-purchase entitlements when you want both. Start with the Payment Sheet flow, get the webhook handler right before anything else, and never put a secret key on the device. Build it once, build it properly, and you will not touch it again until you decide to add a new payment method.

Share this article

Ready to ship your Flutter app faster?

The Flutter Kit gives you a production-ready Flutter codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Flutter Kit