# How to Troubleshoot Common FlutterFlow Payments Problems

- Tool: FlutterFlow
- Difficulty: Beginner
- Time required: 20-30 min
- Compatibility: FlutterFlow Free+ with Stripe via Cloud Functions (Firebase Blaze plan required)
- Last updated: March 2026

## TL;DR

Most FlutterFlow payment problems fall into six categories: Stripe test/live key mismatch, CORS errors from calling Stripe on the client, webhook signature failures, card_declined test card confusion, blank checkout redirects, and double charges from non-idempotent webhook handlers. The key insight is that payments happen in Cloud Functions and Stripe — not in FlutterFlow itself — so that is where you debug.

## Why FlutterFlow Payment Debugging Is Different

When a payment fails in your FlutterFlow app the error rarely comes from FlutterFlow itself. Stripe processes the charge, Firebase Cloud Functions handle the server-side logic, and FCM or Firestore carries the result back to the UI. Checking FlutterFlow's Run Mode logs will show you the API call failed, but not why. You need three tabs open simultaneously: Stripe Dashboard > Logs, Firebase Console > Functions > Logs, and Firebase Console > Functions > Health. This guide walks through each common failure category, shows you exactly where to look, and gives you the fix.

## Before you start

- Stripe account with API keys (publishable and secret)
- Firebase Cloud Function handling the Stripe charge or Checkout session creation
- Webhook endpoint configured in Stripe Dashboard pointing to your Cloud Function URL
- FlutterFlow project calling the Cloud Function via a custom API call or Cloud Function action

## Step-by-step guide

### 1. Check Stripe Dashboard logs before touching FlutterFlow

Open Stripe Dashboard > Developers > Logs. Every API call Stripe receives is listed here with status code, request body, and response. If your charge attempt does not appear at all, the problem is upstream — your Cloud Function never called Stripe, likely due to a CORS error or a function crash before the Stripe call. If it appears with a 402 or 400 error, read the error.code field in the response — it will be one of the strings listed below. Filter by your test API key (starts with sk_test) vs live key (sk_live) to make sure you are looking at the right environment.

**Expected result:** You can see whether the charge request reached Stripe and what error code was returned.

### 2. Fix CORS errors by moving Stripe calls to a Cloud Function

If you see a CORS or network error in your Flutter debug console when tapping the pay button, you are calling the Stripe API directly from the app. Flutter (like a browser) cannot call stripe.com from client code with a secret key — and doing so would also expose your secret key in the app binary. The fix is to create a Firebase Cloud Function that accepts the charge parameters, calls Stripe server-side, and returns the result. In FlutterFlow, update your payment action to call the Cloud Function instead of Stripe directly. Pass only the amount and currency from the client; keep the secret key in the function's environment variables.

```
// functions/createPaymentIntent.js
const { onCall } = require('firebase-functions/v2/https');
const Stripe = require('stripe');

exports.createPaymentIntent = onCall(async (request) => {
  const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
  const { amount, currency } = request.data;
  const paymentIntent = await stripe.paymentIntents.create({
    amount,       // in smallest currency unit, e.g. cents
    currency,
    automatic_payment_methods: { enabled: true },
  });
  return { clientSecret: paymentIntent.client_secret };
});
```

**Expected result:** The CORS error disappears. The Cloud Function returns a client secret your app uses to confirm the payment.

### 3. Resolve test vs live mode mismatches

One of the most common silent failures is using a test publishable key on the client but a live secret key in the Cloud Function, or vice versa. Stripe keys are environment-specific and cannot be mixed. Publishable keys start with pk_test_ or pk_live_; secret keys with sk_test_ or sk_live_. In FlutterFlow check your API call headers or custom action where you pass the publishable key. In Firebase Console check the Cloud Function's environment variable. Both must use the same prefix (test or live). Also verify that your Stripe webhook endpoint's signing secret matches the environment — Stripe issues separate webhook secrets for test and live mode.

**Expected result:** Keys are consistent across client, function, and webhook. Charges appear in the correct Stripe Dashboard section.

### 4. Fix webhook signature verification failures

If your Cloud Function receives the webhook event but returns a 400 error, open Firebase Functions logs and look for 'No signatures found matching the expected signature for payload' or 'Webhook signature verification failed.' This happens for three reasons: the wrong webhook signing secret is set in the function's environment, the request body has been parsed as JSON before verification (signature requires the raw bytes), or the timestamp is more than 5 minutes off (clock skew). Fix: ensure your Cloud Function reads the raw body using express.raw({ type: '*/*' }) before the verification step, and paste the correct webhook secret from Stripe Dashboard > Developers > Webhooks > your endpoint > Signing secret.

```
// Correct webhook handler — preserve raw body
const express = require('express');
const { onRequest } = require('firebase-functions/v2/https');
const Stripe = require('stripe');

const app = express();
app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
  const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
  const sig = req.headers['stripe-signature'];
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
  if (event.type === 'payment_intent.succeeded') {
    // update Firestore order status
  }
  res.json({ received: true });
});
exports.stripeWebhook = onRequest(app);
```

**Expected result:** Webhook events are verified and processed. Orders update in Firestore after successful payment.

### 5. Prevent double charges with idempotency keys

If users are being charged twice, the likely cause is that your webhook handler processes payment_intent.succeeded events multiple times. Stripe can deliver the same event more than once if your endpoint returns a non-2xx status or times out. Fix this by making your handler idempotent: check whether the order has already been marked paid in Firestore before processing. Use the Stripe event ID as the Firestore document ID for processed events, and use a Firestore transaction to set status to 'paid' only if it is currently 'pending'. Also pass an idempotency key when creating the PaymentIntent so Stripe deduplicates server-side retries.

```
// Idempotent webhook handler
if (event.type === 'payment_intent.succeeded') {
  const pi = event.data.object;
  const db = getFirestore();
  const eventRef = db.collection('processed_webhook_events').doc(event.id);
  await db.runTransaction(async (t) => {
    const eventDoc = await t.get(eventRef);
    if (eventDoc.exists) return; // already processed
    t.set(eventRef, { processedAt: FieldValue.serverTimestamp() });
    const orderRef = db.collection('orders').doc(pi.metadata.orderId);
    t.update(orderRef, { status: 'paid', paidAt: FieldValue.serverTimestamp() });
  });
}
```

**Expected result:** Each payment_intent.succeeded event is processed exactly once, even if Stripe delivers it multiple times.

## Complete code example

File: `functions/payments.js`

```javascript
const { onCall, onRequest } = require('firebase-functions/v2/https');
const { getFirestore, FieldValue } = require('firebase-admin/firestore');
const { initializeApp } = require('firebase-admin/app');
const Stripe = require('stripe');
const express = require('express');

initializeApp();

// Create PaymentIntent — called from FlutterFlow
exports.createPaymentIntent = onCall(async (request) => {
  const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
  const { amount, currency, orderId } = request.data;
  const paymentIntent = await stripe.paymentIntents.create({
    amount,
    currency: currency || 'usd',
    automatic_payment_methods: { enabled: true },
    metadata: { orderId, userId: request.auth?.uid },
    // Idempotency: pass the orderId so retries reuse the same intent
  }, { idempotencyKey: orderId });
  return { clientSecret: paymentIntent.client_secret };
});

// Webhook handler — receives Stripe events
const webhookApp = express();
webhookApp.post('/', express.raw({ type: '*/*' }), async (req, res) => {
  const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      req.headers['stripe-signature'],
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
  if (event.type === 'payment_intent.succeeded') {
    const pi = event.data.object;
    const db = getFirestore();
    const eventRef = db.collection('processed_webhook_events').doc(event.id);
    await db.runTransaction(async (t) => {
      const snap = await t.get(eventRef);
      if (snap.exists) return;
      t.set(eventRef, { processedAt: FieldValue.serverTimestamp() });
      if (pi.metadata?.orderId) {
        t.update(db.collection('orders').doc(pi.metadata.orderId), {
          status: 'paid',
          paidAt: FieldValue.serverTimestamp(),
          stripePaymentIntentId: pi.id,
        });
      }
    });
  }
  res.json({ received: true });
});
exports.stripeWebhook = onRequest(webhookApp);
```

## Common mistakes

- **Debugging payment issues by looking only at FlutterFlow logs** — FlutterFlow only shows that the API call returned an error. The actual payment processing happens in Stripe and Cloud Functions, where the real error details are. Fix: Open Stripe Dashboard > Developers > Logs and Firebase Console > Functions > Logs simultaneously. These are your primary debugging tools for payment failures.
- **Using test card 4242 4242 4242 4242 in live mode** — Stripe test cards are only valid in test mode. Charging them in live mode returns card_declined with decline_code: card_not_supported. Fix: Verify you are in test mode (Stripe Dashboard shows 'Test mode' banner in orange) when using test cards. Switch to live mode only with real card details.
- **Not setting a minimum charge amount** — Stripe enforces a minimum charge of 50 cents (USD). Passing amount: 0 or amount: 10 returns 'Amount must convert to at least 50 cents.' Fix: Validate the charge amount on the client before calling the Cloud Function, and add a server-side check in the function that rejects amounts below 50.
- **Parsing the webhook request body as JSON before signature verification** — Stripe's signature is computed over the raw bytes of the request body. Parsing it to JSON changes the string, making the computed signature not match. Fix: Use express.raw({ type: '*/*' }) on the webhook route so req.body contains the raw Buffer, not a parsed object.
- **Forgetting to add the webhook endpoint to Stripe Dashboard** — Cloud Functions have a unique URL per project and environment. If you redeploy or change the function name, the URL changes and Stripe stops sending events. Fix: After every deployment, verify the webhook URL in Stripe Dashboard > Developers > Webhooks matches your current Cloud Function URL.

## Best practices

- Keep Stripe secret keys exclusively in Cloud Function environment secrets — never in FlutterFlow API configuration or app code.
- Always use Stripe's test card set (4242, 4000000000000002 for decline, 4000002760003184 for 3DS) rather than guessing error scenarios.
- Log every payment attempt to a Firestore payments collection with timestamp, amount, status, and Stripe PaymentIntent ID for reconciliation.
- Implement idempotency keys on both the PaymentIntent creation and the webhook handler to prevent duplicate charges.
- Set up Stripe radar rules to automatically block suspicious payments before they reach your app logic.
- Use Stripe's built-in Checkout page for your first integration — it handles 3DS, Apple Pay, and Google Pay automatically without custom code.
- Monitor your Cloud Function error rate in Firebase Console > Functions > Health and set up alerting for spikes after deployments.

## Frequently asked questions

### Why does my payment work in Run Mode but fail after deployment?

The most common cause is environment variable differences. Run Mode in FlutterFlow uses your test configuration, while the deployed app may be missing the Stripe publishable key in its build environment. Check that all required keys are set in Firebase Function secrets and in FlutterFlow's production API configuration.

### What does 'card_declined' with no decline_code mean?

A generic decline without a sub-code means Stripe received a decline from the card network without a specific reason — often a soft decline. In test mode, use card 4000000000000002 to simulate a generic decline. In live mode, ask the customer to try a different card or contact their bank.

### How do I test webhooks locally without deploying Cloud Functions?

Install the Stripe CLI and run 'stripe listen --forward-to http://localhost:5001/YOUR-PROJECT/us-central1/stripeWebhook'. This tunnels Stripe events to your local emulator so you can debug without deploying.

### My Stripe Checkout shows a blank white screen. What is wrong?

A blank Checkout page usually means the session creation failed or returned an invalid URL. Check Firebase Functions logs — the most common causes are a missing or incorrect Stripe key, an amount below the minimum, or a currency mismatch between the session and your Stripe account's supported currencies.

### Can I add Apple Pay and Google Pay to my FlutterFlow app?

Yes, through Stripe. Use Stripe's Payment Request Button in a WebView, or integrate the flutter_stripe package via custom code export. Both Apple Pay and Google Pay are supported in Stripe Checkout automatically with no extra code.

### Why are customers being charged twice?

Double charges almost always come from non-idempotent webhook handlers. Stripe retries webhook delivery if your endpoint returns a non-2xx status. If your handler processes the event each time, orders get updated (or charged) on every retry. Fix by storing processed event IDs in Firestore and checking before processing.

---

Source: https://www.rapidevelopers.com/flutterflow-tutorials/how-can-i-troubleshoot-common-flutterflow-payments-problems
© RapidDev — https://www.rapidevelopers.com/flutterflow-tutorials/how-can-i-troubleshoot-common-flutterflow-payments-problems
