Quando un'app deve vendere beni fisici, servizi o prenotazioni, gli acquisti in-app degli store non sono l'opzione obbligata (e spesso nemmeno consentita): serve un vero payment gateway. Stripe è oggi la soluzione più diffusa e mantiene un SDK ufficiale per Flutter, flutter_stripe, che incapsula gli SDK nativi iOS e Android e offre una UI pronta all'uso: la PaymentSheet.

In questa guida vediamo come costruire un flusso di pagamento completo e sicuro, con particolare attenzione al punto che più spesso viene sbagliato: la divisione delle responsabilità tra client e server.

Regola numero uno: la secret key non tocca mai il client

Un'app Flutter è codice che finisce sul dispositivo dell'utente: qualunque stringa compilata nel binario è, di fatto, pubblica. Per questo Stripe usa due chiavi:

  • publishable key (pk_test_... / pk_live_...): può stare nell'app, serve solo a tokenizzare dati e mostrare la UI;
  • secret key (sk_...): vive esclusivamente sul tuo backend, dove vengono creati gli oggetti PaymentIntent.

Il flusso corretto è quindi:

  1. l'app chiede al tuo backend di creare un PaymentIntent per un certo ordine;
  2. il backend calcola l'importo (mai fidarsi dell'importo inviato dal client) e restituisce il client_secret;
  3. l'app presenta la PaymentSheet passando quel client_secret;
  4. Stripe notifica il backend via webhook che il pagamento è riuscito;
  5. il backend marca l'ordine come pagato ed eroga il servizio.

Setup del progetto

Aggiungi la dipendenza:

dependencies:
  flutter_stripe: ^11.1.0
  http: ^1.2.2

Android

flutter_stripe richiede alcune modifiche obbligatorie, altrimenti l'app crasha all'apertura della PaymentSheet:

  • minSdkVersion 21 (o superiore) in android/app/build.gradle;
  • l'Activity deve estendere FlutterFragmentActivity, non FlutterActivity;
  • il tema dell'app deve derivare da un tema AppCompat/Material.
// android/app/src/main/kotlin/.../MainActivity.kt
import io.flutter.embedding.android.FlutterFragmentActivity

class MainActivity : FlutterFragmentActivity()
<!-- android/app/src/main/res/values/styles.xml -->
<style name="LaunchTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
  <!-- ... -->
</style>

iOS

Imposta la piattaforma minima a iOS 13 nel Podfile (platform :ios, '13.0') e, se vuoi Apple Pay, attiva la capability Apple Pay in Xcode e registra il merchant identifier.

Inizializzazione

La publishable key va impostata prima di runApp. Meglio non hardcodarla: usa --dart-define così puoi cambiarla tra flavor.

import 'package:flutter/material.dart';
import 'package:flutter_stripe/flutter_stripe.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  Stripe.publishableKey = const String.fromEnvironment('STRIPE_PK');
  Stripe.merchantIdentifier = 'merchant.com.acme.app'; // solo per Apple Pay
  Stripe.urlScheme = 'acmeapp'; // redirect per 3D Secure / wallet esterni
  await Stripe.instance.applySettings();

  runApp(const MyApp());
}

Lancio: flutter run --dart-define=STRIPE_PK=pk_test_123....

Il backend: creare il PaymentIntent

Il server è la fonte di verità per l'importo. Ecco un endpoint minimale in Node.js con Express, utile come riferimento anche se il tuo backend è in Dart (dart_frog, serverpod), Python o PHP.

app.post('/payments/intent', async (req, res) => {
  const { orderId } = req.body;
  const order = await db.orders.findById(orderId); // importo calcolato lato server

  const customer = await stripe.customers.create(); // o recupera quello esistente
  const ephemeralKey = await stripe.ephemeralKeys.create(
    { customer: customer.id },
    { apiVersion: '2024-06-20' },
  );

  const intent = await stripe.paymentIntents.create(
    {
      amount: order.totalCents, // in centesimi: 24.90 EUR -> 2490
      currency: 'eur',
      customer: customer.id,
      automatic_payment_methods: { enabled: true },
      metadata: { orderId: order.id },
    },
    { idempotencyKey: `order-${order.id}` }, // evita doppi addebiti sui retry
  );

  res.json({
    clientSecret: intent.client_secret,
    customerId: customer.id,
    ephemeralKey: ephemeralKey.secret,
  });
});

Due dettagli importanti: gli importi sono sempre interi in centesimi (mai double, per evitare errori di arrotondamento) e l'idempotencyKey protegge da doppie creazioni quando l'app ritenta la richiesta su rete instabile.

Lato Flutter: PaymentSheet in due passi

La PaymentSheet gestisce carte, wallet, SEPA, 3D Secure e validazioni: è la strada consigliata rispetto al CardField custom, perché riduce drasticamente il codice e i requisiti PCI.

class CheckoutService {
  CheckoutService(this._api);
  final PaymentsApi _api;

  Future<PaymentResult> pay({required String orderId}) async {
    try {
      final intent = await _api.createIntent(orderId);

      await Stripe.instance.initPaymentSheet(
        paymentSheetParameters: SetupPaymentSheetParameters(
          paymentIntentClientSecret: intent.clientSecret,
          customerId: intent.customerId,
          customerEphemeralKeySecret: intent.ephemeralKey,
          merchantDisplayName: 'Acme S.r.l.',
          style: ThemeMode.system,
          googlePay: const PaymentSheetGooglePay(
            merchantCountryCode: 'IT',
            currencyCode: 'EUR',
            testEnv: true,
          ),
          applePay: const PaymentSheetApplePay(
            merchantCountryCode: 'IT',
          ),
          billingDetailsCollectionConfiguration:
              const BillingDetailsCollectionConfiguration(
            email: CollectionMode.always,
            address: AddressCollectionMode.full,
          ),
        ),
      );

      await Stripe.instance.presentPaymentSheet();
      return PaymentResult.succeeded;
    } on StripeException catch (e) {
      if (e.error.code == FailureCode.Canceled) {
        return PaymentResult.canceled;
      }
      return PaymentResult.failed(e.error.localizedMessage ?? 'Errore');
    }
  }
}

Nota che presentPaymentSheet() completa senza errore solo se il pagamento è andato a buon fine: cancellazione e fallimenti arrivano come StripeException. Il campo localizedMessage contiene un messaggio già tradotto, adatto a mostrare in UI.

Personalizzare l'aspetto

appearance: PaymentSheetAppearance(
  colors: PaymentSheetAppearanceColors(
    primary: Theme.of(context).colorScheme.primary,
    background: Theme.of(context).colorScheme.surface,
  ),
  shapes: const PaymentSheetShape(borderRadius: 16),
  primaryButton: const PaymentSheetPrimaryButtonAppearance(
    shapes: PaymentSheetPrimaryButtonShape(blurRadius: 0),
  ),
),

Non fidarti del risultato lato client

Un errore molto comune: sbloccare il contenuto acquistato appena presentPaymentSheet() ritorna. L'utente potrebbe chiudere l'app, perdere la rete o (in scenari di abuso) manipolare il client. L'unica conferma affidabile arriva dal webhook Stripe:

app.post('/stripe/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body,
    req.headers['stripe-signature'],
    process.env.STRIPE_WEBHOOK_SECRET,
  );

  if (event.type === 'payment_intent.succeeded') {
    const intent = event.data.object;
    fulfillOrder(intent.metadata.orderId); // idempotente!
  }
  res.sendStatus(200);
});

Nell'app, dopo il pagamento, mostra una schermata "stiamo confermando il tuo ordine" e fai polling (o ascolta un Firestore/Supabase stream) sullo stato dell'ordine lato server. È una UX un po' più lunga, ma è l'unica corretta.

Salvare la carta per acquisti futuri

Se vuoi il classico "paga con un tap la prossima volta", hai due strade:

  • durante un pagamento, creare il PaymentIntent con setup_future_usage: 'off_session';
  • senza addebito immediato, creare un SetupIntent e presentarlo con Stripe.instance.initPaymentSheet(... setupIntentClientSecret: ...).

In entrambi i casi il metodo di pagamento viene agganciato al customer: passando customerId + customerEphemeralKeySecret alla PaymentSheet, le carte salvate compaiono automaticamente nell'elenco. Per gli abbonamenti ricorrenti, lato server userai le API Subscriptions con i tuoi price e passerai all'app il client_secret della prima invoice.

3D Secure e SCA

In Europa la PSD2 impone l'autenticazione forte. La buona notizia è che con la PaymentSheet non devi scrivere codice: se la banca richiede il 3DS, l'SDK apre il flusso di challenge e ritorna solo a esito ottenuto. L'unico requisito tecnico è aver configurato Stripe.urlScheme e, su Android, il tema corretto (vedi setup) per la WebView di challenge.

Testare senza spendere

Usa le chiavi pk_test/sk_test e le carte di test di Stripe:

  • 4242 4242 4242 4242 → pagamento riuscito;
  • 4000 0027 6000 3184 → richiede 3D Secure;
  • 4000 0000 0000 9995 → fondi insufficienti (card_declined).

Per i webhook in locale, stripe listen --forward-to localhost:3000/stripe/webhook della CLI ufficiale è indispensabile. Google Pay in test funziona con testEnv: true; Apple Pay richiede un dispositivo fisico con una carta di test nel Wallet sandbox.

Checklist finale

  • Secret key solo sul server, publishable key via --dart-define per flavor.
  • Importi in centesimi calcolati lato backend, idempotencyKey sulle create.
  • FlutterFragmentActivity + tema Material su Android, iOS 13+.
  • Fulfillment guidato dal webhook, mai dal ritorno di presentPaymentSheet().
  • Gestione esplicita di FailureCode.Canceled per non mostrare errori inutili.
  • Attenzione alle policy degli store: i contenuti digitali consumati nell'app devono comunque passare dagli in-app purchase; Stripe è per beni e servizi "reali".

Con questa struttura ottieni un checkout robusto in poche centinaia di righe, delegando a Stripe la parte più delicata (dati di carta, conformità PCI, SCA) e mantenendo nel tuo backend l'unica cosa che davvero deve rimanere sotto controllo: la verità sull'ordine.