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 oggettiPaymentIntent.
Il flusso corretto è quindi:
- l'app chiede al tuo backend di creare un
PaymentIntentper un certo ordine; - il backend calcola l'importo (mai fidarsi dell'importo inviato dal client) e restituisce il
client_secret; - l'app presenta la PaymentSheet passando quel
client_secret; - Stripe notifica il backend via webhook che il pagamento è riuscito;
- 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) inandroid/app/build.gradle;- l'Activity deve estendere
FlutterFragmentActivity, nonFlutterActivity; - 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
PaymentIntentconsetup_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-defineper flavor. - Importi in centesimi calcolati lato backend,
idempotencyKeysulle create. FlutterFragmentActivity+ tema Material su Android, iOS 13+.- Fulfillment guidato dal webhook, mai dal ritorno di
presentPaymentSheet(). - Gestione esplicita di
FailureCode.Canceledper 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.