Secure payment data in FlutterFlow by never storing card numbers — use Stripe's tokenization so the FlutterFlow app only ever touches a `pm_xxx` token. Put the Stripe secret key in a Cloud Function Secret, never in FlutterFlow's API Manager. Verify webhook signatures server-side. Store only `last4`, `brand`, and `expiry` in Firestore. This achieves PCI SAQ-A compliance — the simplest PCI tier.
PCI Compliance Is Easier Than You Think With Stripe
Payment Card Industry (PCI) compliance sounds intimidating, but Stripe's architecture makes the hardest part automatic. When a user enters their card number into a Stripe-hosted element (Stripe Checkout or Stripe Elements), the number goes directly from the user's browser to Stripe's servers — it never touches your app, your Firestore, or your Cloud Functions. What you receive is a payment method token (`pm_xxx`) or a charge ID — opaque strings that are useless without Stripe API access. This is the foundation of PCI SAQ-A compliance: the card data is handled entirely by a PCI-certified third party (Stripe). Your only responsibility is to protect your Stripe API keys and webhook secrets. This tutorial shows how to do that correctly in FlutterFlow.
Prerequisites
- A Stripe account with Checkout or Elements enabled
- FlutterFlow project with Firebase Authentication and Cloud Functions (Blaze plan)
- Basic understanding of how Stripe Checkout and webhook events work
- Firebase console access for setting Cloud Function Secrets
Step-by-step guide
Use Stripe Checkout instead of building a custom card form
Use Stripe Checkout instead of building a custom card form
The safest way to collect payment data is to redirect users to Stripe Checkout — a Stripe-hosted payment page where Stripe handles all card data collection. No card number ever enters your FlutterFlow app. In FlutterFlow, create a Cloud Function named `createCheckoutSession` that uses the Stripe Node.js SDK to create a checkout session with success_url and cancel_url pointing back to your app. Call this Cloud Function from FlutterFlow and open the returned `sessionUrl` in an in-app browser (use the Launch URL action with the `launchUrl` Custom Action for Flutter's url_launcher package). When the user completes payment, Stripe redirects them to your success URL. Never attempt to build a raw card number input form in FlutterFlow — even if you do not log the number, any form that collects raw card data requires the full PCI SAQ-D audit.
1// Cloud Function: createCheckoutSession2const { onCall, HttpsError } = require('firebase-functions/v2/https');3const { defineSecret } = require('firebase-functions/params');4const { initializeApp } = require('firebase-admin/app');5const { getFirestore } = require('firebase-admin/firestore');6const Stripe = require('stripe');78initializeApp();9const stripeSecret = defineSecret('STRIPE_SECRET_KEY');1011exports.createCheckoutSession = onCall(12 { secrets: [stripeSecret] },13 async (request) => {14 const uid = request.auth?.uid;15 if (!uid) throw new HttpsError('unauthenticated', 'Sign in required');1617 const { priceId, successUrl, cancelUrl } = request.data;18 const stripe = new Stripe(stripeSecret.value());1920 const db = getFirestore();21 const userDoc = await db.collection('users').doc(uid).get();22 let customerId = userDoc.data()?.stripeCustomerId;2324 // Create or reuse Stripe customer25 if (!customerId) {26 const customer = await stripe.customers.create({27 metadata: { firebaseUid: uid },28 });29 customerId = customer.id;30 await db.collection('users').doc(uid).update({ stripeCustomerId: customerId });31 }3233 const session = await stripe.checkout.sessions.create({34 customer: customerId,35 mode: 'payment',36 line_items: [{ price: priceId, quantity: 1 }],37 success_url: successUrl,38 cancel_url: cancelUrl,39 });4041 return { sessionUrl: session.url, sessionId: session.id };42 }43);Expected result: Clicking Pay in your FlutterFlow app opens the Stripe Checkout page. No card data passes through FlutterFlow or Firestore.
Store the Stripe secret key in Firebase Function Secrets
Store the Stripe secret key in Firebase Function Secrets
Your Stripe secret key (sk_live_xxx or sk_test_xxx) must never appear in FlutterFlow's API Manager, as a Firestore document value, in a Flutter environment variable, or hardcoded in a Custom Action. Any of these locations can expose the key. The correct location is a Firebase Function Secret. Run `firebase functions:secrets:set STRIPE_SECRET_KEY` and paste your key when prompted. Access it in the Cloud Function using `defineSecret('STRIPE_SECRET_KEY')` from `firebase-functions/params`. The secret is encrypted at rest and only injected into the Cloud Function's runtime environment — it never appears in logs, function code, or Firebase console UI.
Expected result: Running `firebase functions:secrets:list` shows STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET. Neither key appears anywhere in your FlutterFlow project.
Handle Stripe webhooks with signature verification
Handle Stripe webhooks with signature verification
Stripe sends webhook events to your Cloud Function to notify you of completed payments, subscription renewals, and failed charges. Without signature verification, anyone can send a fake webhook to your endpoint claiming a payment succeeded. Create a Cloud Function named `stripeWebhook` as an HTTP function (not callable). Read the raw request body as a Buffer — this is critical because signature verification fails if the body is parsed as JSON first. Call `stripe.webhooks.constructEvent(rawBody, sig, webhookSecret.value())`. If verification fails, return HTTP 400. If it succeeds, handle the event type (e.g., `checkout.session.completed`) by updating Firestore.
1const { onRequest } = require('firebase-functions/v2/https');2const { defineSecret } = require('firebase-functions/params');3const { getFirestore } = require('firebase-admin/firestore');4const Stripe = require('stripe');56const stripeSecret = defineSecret('STRIPE_SECRET_KEY');7const webhookSecret = defineSecret('STRIPE_WEBHOOK_SECRET');89exports.stripeWebhook = onRequest(10 { secrets: [stripeSecret, webhookSecret], rawBody: true },11 async (req, res) => {12 const sig = req.headers['stripe-signature'];13 const stripe = new Stripe(stripeSecret.value());14 let event;1516 try {17 event = stripe.webhooks.constructEvent(18 req.rawBody,19 sig,20 webhookSecret.value()21 );22 } catch (err) {23 console.error('Webhook signature verification failed:', err.message);24 return res.status(400).send(`Webhook Error: ${err.message}`);25 }2627 const db = getFirestore();2829 if (event.type === 'checkout.session.completed') {30 const session = event.data.object;31 const customerId = session.customer;32 // Look up user by Stripe customer ID33 const usersRef = db.collection('users');34 const query = await usersRef35 .where('stripeCustomerId', '==', customerId)36 .limit(1).get();37 if (!query.empty) {38 await query.docs[0].ref.update({39 hasPaid: true,40 lastPaymentAt: new Date(),41 lastPaymentAmount: session.amount_total,42 });43 }44 }4546 res.json({ received: true });47 }48);Expected result: The Stripe Dashboard Webhook Logs show successful deliveries with 200 responses. Firestore updates correctly when a test payment is completed.
Store only safe payment metadata in Firestore
Store only safe payment metadata in Firestore
After a successful payment, you need to store some payment information in Firestore for display in your app — but you must be selective. Safe to store: `last4` (last 4 digits of the card), `brand` (Visa, Mastercard), `expMonth`, `expYear`, `chargeId` (ch_xxx), `paymentIntentId` (pi_xxx), `amount`, `currency`, `paidAt`. Never store: full card number, CVV/CVC, magnetic stripe data, PIN. The Stripe webhook handler can safely extract `last4` and `brand` from the `payment_method_details` object on a charge event. Write these fields to the user's Firestore document or a `payments` subcollection.
Expected result: The user's Firestore document shows last4, brand, and paidAt. No sensitive card data is stored anywhere in Firestore.
Audit your Cloud Function logs for accidental card data exposure
Audit your Cloud Function logs for accidental card data exposure
The most common source of card data leaks is logging full Stripe API response objects, which can contain payment method details. Review all your Cloud Functions for `console.log(response)` or `console.log(data)` statements that log entire Stripe objects. Replace these with targeted logs: `console.log('Session created:', session.id)` instead of `console.log(session)`. In the Google Cloud console, go to Logging > Log Explorer and search for your Cloud Function logs. Filter for any log entries containing `card_number`, `cvc`, or `number` to catch accidental leaks. Set up a Cloud Logging alert to notify you if these patterns appear in future logs.
Expected result: Log review shows no payment method details, card numbers, or CVV values in Cloud Function logs.
Complete working example
1const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https');2const { defineSecret } = require('firebase-functions/params');3const { initializeApp } = require('firebase-admin/app');4const { getFirestore } = require('firebase-admin/firestore');5const Stripe = require('stripe');67initializeApp();89const stripeSecretKey = defineSecret('STRIPE_SECRET_KEY');10const stripeWebhookSecret = defineSecret('STRIPE_WEBHOOK_SECRET');1112// Create Stripe Checkout session13exports.createCheckoutSession = onCall(14 { secrets: [stripeSecretKey] },15 async (request) => {16 const uid = request.auth?.uid;17 if (!uid) throw new HttpsError('unauthenticated', 'Sign in required');18 const { priceId, successUrl, cancelUrl } = request.data;19 const stripe = new Stripe(stripeSecretKey.value());20 const db = getFirestore();2122 const userDoc = await db.collection('users').doc(uid).get();23 let customerId = userDoc.data()?.stripeCustomerId;24 if (!customerId) {25 const customer = await stripe.customers.create({ metadata: { firebaseUid: uid } });26 customerId = customer.id;27 await db.collection('users').doc(uid).update({ stripeCustomerId: customerId });28 }2930 const session = await stripe.checkout.sessions.create({31 customer: customerId,32 mode: 'payment',33 line_items: [{ price: priceId, quantity: 1 }],34 success_url: successUrl,35 cancel_url: cancelUrl,36 payment_intent_data: { metadata: { firebaseUid: uid } },37 });3839 // Log only safe identifiers, never full session object40 console.log('Checkout session created:', session.id, 'for user:', uid);41 return { sessionUrl: session.url, sessionId: session.id };42 }43);4445// Handle Stripe webhooks with signature verification46exports.stripeWebhook = onRequest(47 { secrets: [stripeSecretKey, stripeWebhookSecret], rawBody: true },48 async (req, res) => {49 const sig = req.headers['stripe-signature'];50 if (!sig) return res.status(400).send('Missing stripe-signature header');5152 const stripe = new Stripe(stripeSecretKey.value());53 let event;54 try {55 event = stripe.webhooks.constructEvent(56 req.rawBody, sig, stripeWebhookSecret.value()57 );58 } catch (err) {59 console.error('Webhook verification failed:', err.message);60 return res.status(400).send(`Webhook Error: ${err.message}`);61 }6263 const db = getFirestore();6465 if (event.type === 'checkout.session.completed') {66 const session = event.data.object;67 const customerId = session.customer;68 const amountTotal = session.amount_total;69 const currency = session.currency;7071 // Retrieve payment method details (only safe metadata)72 let last4 = null;73 let brand = null;74 if (session.payment_intent) {75 const pi = await stripe.paymentIntents.retrieve(session.payment_intent, {76 expand: ['payment_method'],77 });78 last4 = pi.payment_method?.card?.last4 || null;79 brand = pi.payment_method?.card?.brand || null;80 }8182 const usersRef = db.collection('users');83 const query = await usersRef84 .where('stripeCustomerId', '==', customerId).limit(1).get();8586 if (!query.empty) {87 const userRef = query.docs[0].ref;88 await userRef.update({ hasPaid: true, lastPaymentAt: new Date() });89 await userRef.collection('payments').add({90 sessionId: session.id,91 amount: amountTotal,92 currency,93 last4,94 brand,95 paidAt: new Date(),96 });97 }9899 console.log('Payment completed for customer:', customerId, 'amount:', amountTotal);100 }101102 res.json({ received: true });103 }104);Common mistakes
Why it's a problem: Logging full Stripe API responses which may contain card details
How to avoid: Only log specific safe fields: `console.log('Payment created:', paymentIntent.id)`. Never log the full Stripe response object. Run a search of your Cloud Logging logs for 'card_number' and 'cvc' to audit for existing leaks.
Why it's a problem: Storing the Stripe secret key in FlutterFlow's API Manager header fields
How to avoid: Store the Stripe secret key exclusively as a Firebase Function Secret. The Cloud Function is the only layer that should ever call the Stripe API. FlutterFlow only calls your Cloud Function, not Stripe directly.
Why it's a problem: Processing Stripe webhooks without verifying the signature
How to avoid: Always call `stripe.webhooks.constructEvent(rawBody, sig, webhookSecret)` before processing any webhook event. This cryptographically verifies the event came from Stripe. Use the raw request body, not a JSON-parsed body, for signature verification.
Best practices
- Use Stripe Checkout or Stripe Elements — never build a raw card number input form, which requires full PCI SAQ-D compliance.
- Store all Stripe API keys and webhook secrets as Firebase Function Secrets, never in FlutterFlow API Manager, Firestore, or app code.
- Always verify webhook signatures with `stripe.webhooks.constructEvent` before trusting any webhook payload.
- Store only last4, brand, expMonth, and expYear in Firestore — never store full card numbers, CVV, or raw magnetic stripe data.
- Use targeted logging in Cloud Functions — log only Stripe IDs (session.id, customer.id), never full API response objects.
- Test your entire payment flow with Stripe test mode (4242 4242 4242 4242) before going live — the webhook signing secret is different in test and live mode.
- Enable Stripe Radar fraud protection rules in the Stripe Dashboard — it blocks known fraudulent cards automatically at no extra cost.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I am building a FlutterFlow app with Stripe payments. I need a Firebase Cloud Function that creates a Stripe Checkout session and another that handles Stripe webhooks with signature verification. The webhook handler should update a Firestore user document when checkout.session.completed fires, storing only safe metadata (last4, brand, amount) and never logging full Stripe response objects. Write both Cloud Functions using Firebase Functions v2 with the Stripe secret key stored as a Firebase Function Secret.
In my FlutterFlow app, I call a Firebase Cloud Function that returns a Stripe Checkout session URL. I then need to open that URL in an in-app browser so the user can complete payment and be redirected back to my app's success page. How do I open an external URL in FlutterFlow — should I use the Launch URL action, a Custom Action with url_launcher, or a WebView widget? What are the tradeoffs for each approach?
Frequently asked questions
Does using Stripe Checkout make my app PCI compliant?
Yes — using Stripe Checkout qualifies you for PCI SAQ-A, the simplest PCI Self-Assessment Questionnaire. This requires no security auditor, just an annual self-assessment form. The card data never touches your systems — Stripe handles all PCI-sensitive data on their certified infrastructure.
Can I store a user's card for future payments?
Yes, but through Stripe's saved payment method system, not by storing card data in Firestore. In the Checkout session, set `setup_future_usage: 'off_session'`. Stripe saves the payment method and returns a payment method ID (pm_xxx). Store only the payment method ID and metadata (last4, brand) in Firestore. For future charges, pass the payment method ID to `stripe.paymentIntents.create`.
What is the difference between the Stripe publishable key and the secret key?
The publishable key (pk_test_xxx or pk_live_xxx) is safe to use in client-side code — it can only create tokens and initialize Stripe Elements. The secret key (sk_xxx) can perform any API operation including issuing refunds, listing all customers, and accessing financial data. The secret key must only ever appear in your Cloud Function Secrets.
Why does Stripe say my webhook signature verification failed?
The most common cause is the request body being parsed as JSON before being passed to `constructEvent`. Signature verification requires the raw bytes of the request body. In Firebase Functions v2, set `rawBody: true` in the function configuration and use `req.rawBody` instead of `req.body`. Also verify you are using the correct webhook secret for the environment — test and live webhooks have separate signing secrets.
How do I handle payment failures in the webhook?
Listen for the `payment_intent.payment_failed` event in your webhook handler. When it fires, update the user's Firestore document to reflect the failed status and optionally trigger a notification. Stripe automatically retries failed subscription payments according to your configured retry rules in the Stripe Dashboard > Billing > Retry rules.
Should I use Stripe Checkout or Stripe Elements in my FlutterFlow app?
Stripe Checkout is strongly recommended for FlutterFlow apps because it opens a Stripe-hosted page — no card data touches your app at all. Stripe Elements (embedded card form) requires more complex integration, needs a custom Flutter package, and still qualifies for PCI SAQ-A as long as you use Stripe.js. For most FlutterFlow founders, Checkout's simplicity and security outweigh the branding limitations.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation