Payments

Payment Flow

  1. Day 0: Create PaymentIntent (manual capture + setup_future_usage)

    • User sees amount + currency conversion in PaymentSheet
    • User authenticates (SCA/3DS if required)
    • Payment method saved to customer automatically
  2. Day 0: IMMEDIATELY cancel the PaymentIntent

    • Releases the auth hold (no money held on user’s card)
    • Saved payment method remains on the customer
  3. Day 1–45: Task lifecycle plays out (completion, confirmation, mediation)

  4. Day N: Create a BRAND NEW PaymentIntent with the saved payment method

    • Charge off-session using the stored pm_xxx

The original PaymentIntent is just a vehicle for three things:

  1. Displaying the amount to the user
  2. Triggering currency conversion
  3. Saving the payment method with proper SCA authentication

You never capture it. You cancel it immediately after the PaymentSheet succeeds.

Payment Intents

Two separate things happening in one PaymentIntent

MechanismWhat it doesExpiry
capture_method: "manual"Places an auth hold on the card7–31 days
setup_future_usage: "off_session"Saves the payment method to the customerNo expiry (until card itself expires)
  • These are independent side effects of the same PaymentIntent.
  • The setup_future_usage flag tells Stripe: “After this PaymentIntent succeeds, persist this payment method on the customer for future off-session charges.”
  • That saved payment method has nothing to do with the auth hold.

SetupIntents

Associated card information with SetupIntent

You’re asking an insightful question about SetupIntents in Flutter. Let me clarify this for you:

  • Purpose of SetupIntent: A SetupIntent is primarily a container for the process of setting up a payment method, rather than storing the payment method details itself.

  • Abstraction: The SetupIntent deliberately abstracts away the specific payment method details. This is for security reasons and to provide a consistent API across different payment methods.

  • Actual storage: The payment method details (like credit card information or Apple Pay/Google Pay tokens) are securely stored by Stripe, not directly in the SetupIntent.

  • Reference, not storage: What the SetupIntent “stores” is actually a reference to the payment method, not the details themselves.

  • Flutter implementation: In the Flutter SDK, you don’t directly configure the payment method in the SetupIntent. Instead, you:

    1. Create a SetupIntent
    2. Use the Stripe SDK to collect payment details
    3. Confirm the SetupIntent with those details
  • Example in Flutter:

    // Create SetupIntent
    final setupIntent = await Stripe.instance.createSetupIntent();
    
    // Collect payment details (this varies based on payment method)
    final paymentMethod = await Stripe.instance.createPaymentMethod(
      PaymentMethodParams.card(/* ... */),
    );
    
    // Confirm SetupIntent
    await Stripe.instance.confirmSetupIntent(
      setupIntent.clientSecret,
      PaymentMethodParams.card(/* ... */),
    );
    
  • Result: After confirmation, the SetupIntent will have a payment_method field with an ID referring to the saved payment method, but not the details themselves.

For more detailed information on implementing SetupIntents in Flutter, see Stripe’s Flutter SDK documentation.

Remember, this approach allows for a consistent API across different payment methods while maintaining security of sensitive payment information.

Implementation

  1. Backend (Python) — Collect card & show amount:
  • using capture_method: manual + setup_future_usage: off_session.
  • This gives you amount display, currency conversion, AND saves the payment method for later charging.
intent = stripe_client.v1.payment_intents.create(
    params={
        "amount": amount,
        "currency": currency,
        "customer": customer_id,
        "capture_method": "manual",          # Auth only — no immediate capture
        "setup_future_usage": "off_session", # Saves payment method to customer
        "payment_method_types": ["card"],
        # Adaptive Pricing (if enabled on your account):
        # "currency_options": { ... }
    }
)

# Also create ephemeral key as before
ephemeral_key = stripe_client.v1.ephemeral_keys.create(
    params={"customer": customer_id}
)
  1. Frontend (Flutter) — PaymentSheet now shows the amount:
await Stripe.instance.initPaymentSheet(
  paymentSheetParameters: SetupPaymentSheetParameters(
    paymentIntentClientSecret: clientSecret,  // ← PaymentIntent
    merchantDisplayName: 'Flipdare',
    customerId: customerId,
    customerEphemeralKeySecret: ephemeralKey,
    style: ThemeMode.dark,
    ...
  ),
);
  1. Backend — After successful authorization, cancel the hold:
# The payment method is already saved to the customer via setup_future_usage.
# Cancel the PaymentIntent to release the auth hold on the card.
stripe_client.v1.payment_intents.cancel(payment_intent_id)

# The payment method is already saved. Retrieve it:
intent = stripe_client.v1.payment_intents.retrieve(payment_intent_id)
saved_pm = intent.payment_method  # "pm_1abc..."
# Store saved_pm in your database for later charging
  1. Backend — When task completes (up to 45 days later), charge off-session:
charge = stripe_client.v1.payment_intents.create(
    params={
        "amount": amount,
        "currency": currency,
        "customer": customer_id,
        "payment_method": saved_payment_method_id,
        "off_session": True,
        "confirm": True,
        "on_behalf_of": account_id,
        "application_fee_amount": fee_amount,  # or application_fee_percent
        "transfer_data": {
            "destination": account_id,
        },
    }
)

Advantages

  • Amount displayed — PaymentSheet shows the actual charge amount
  • Currency conversion — Stripe’s Adaptive Pricing works with PaymentIntents, showing the customer’s local currency equivalent
  • Deferred charging — No time limit on when you charge the saved payment method
  • Eliminates subscription_schedule — No need for product/price/subscription machinery for what is fundamentally a one-time charge

Expiration

  • The auth hold does expire — but you don’t care, because you’re not relying on it.

  • You’re relying on the saved payment method, which persists independently.

  • The capture_method: "manual" is only used so that the user’s card isn’t actually charged during the initial PaymentSheet flow — it just authorizes and you immediately cancel.

  • The one risk remains what I noted before: the saved card could be revoked, expired, or require re-authentication by the time you charge off-session (day 45).

  • You need to handle requires_action on the off-session charge and potentially bring the user back to re-authenticate.

Reauthentication

  • The main driver is Strong Customer Authentication (SCA) regulation in the EU/UK/EEA. Outside those regions, re-auth is rare.

Commonly triggers re-auth:

SituationWhy
Issuer-requested challengeThe cardholder’s bank decides the transaction is risky and demands 3DS
This is the most common cause and is unpredictable.
Amount significantly higher than originalIf the off-session charge amount differs substantially from what was
originally authenticated, some issuers will soft-decline
Different currency than originally authedCharging in a different currency than the one used during the
initial SCA authentication
Regulatory threshold changesEU regulators periodically adjust SCA exemption thresholds
Card network mandate changesVisa/Mastercard update their rules on which transactions require 3DS

Rarely triggers re-auth (but possible):

SituationWhy
Card re-issued (same account, new number)Most issuers support automatic card updater so the saved pm_ stays valid,
but some don’t, and the new card may require fresh SCA
Customer changes bank/issuer settingsUser tightens security preferences with their bank
Stripe Radar flags the chargeIf Stripe’s fraud system elevates the risk score

Practical reality

  • Non-EU/UK customers:
    • Re-auth is extremely rare. Most off-session charges with a saved payment method just work.
  • EU/UK customers:
    • ~5-10% of off-session charges may get soft-declined with requires_action, mostly from issuer-requested challenges.
  • Your scenario (same amount, same currency, within 45 days):
    • The risk is low because:
      • The initial PaymentSheet already performed SCA with the correct amount
      • Stripe stores the SCA authentication as a “mandate” on the saved payment method
      • Same amount + same currency = issuers are less likely to challenge

How to handle the edge case

When the off-session charge returns requires_action:

charge = stripe_client.v1.payment_intents.create(
    params={
        "amount": amount,
        "currency": currency,
        "customer": customer_id,
        "payment_method": saved_pm,
        "off_session": True,
        "confirm": True,
        ...
    }
)

if charge.status == "requires_action":
    # Store charge.id, notify User A to re-authenticate
    # Send push notification / in-app prompt
    # User opens app → present the PaymentIntent for on-session confirmation

Flutter side (when user comes back):

await Stripe.instance.handleNextAction(clientSecret);
  • That’s the entire recovery path — a single call.
  • Given your 45-day window and mediation period, you have plenty of time to bring the user back if needed.

For Apple/Google Pay

The PaymentSheet handles Apple Pay / Google Pay as payment method options automatically. When the user selects Apple Pay or Google Pay:

  1. Stripe creates a tokenized card (a pm_card_... payment method) behind the scenes
  2. setup_future_usage: "off_session" saves that tokenized card to the customer — just like a manually entered card
  3. You cancel the auth hold, keep the saved payment method, and charge later

What you need

  • Backend — no changes needed.
  • The payment_method_types: ["card"] already covers Apple Pay and Google Pay since they produce card tokens.

One caveat

  • Apple Pay and Google Pay tokens saved via setup_future_usage behave slightly differently than raw cards:
AspectRegular cardApple Pay / Google Pay
Saved for off-session
Off-session charging✅ (uses the underlying card network token)
SCA re-auth riskLowEven lower — network tokens have higher auth rates
Card updater (expiry/reissue)✅ (network tokens update automatically)
  • Apple Pay / Google Pay actually improve your off-session success rate because Stripe stores a network token rather than a PAN, and issuers trust network tokens more.
  • So no changes to your backend flow — just add the applePay and googlePay parameters to the PaymentSheet config on the Flutter side.

FEES

Express Accounts

  • For Express accounts with platform-handled fees the only option for fees-collector is
    fees_collector: "application"

Process

  1. Initial charge - The funds are charged to the customer
  2. Platform receives funds - The entire payment amount is deposited to your platform’s Stripe balance first.
  3. Stripe fee deduction - Stripe fees are deducted from your platform balance
  4. Transfer to connected account - A transfer is created to move funds to the connected account

Since the money flows through your platform first, you can:

  • Wait for the payment to be successful (listen for payment_intent.succeeded webhook)
  • Check the balance transaction to see the exact fee
  • Calculate the correct amount to transfer
  • Create a transfer for the appropriate amount

After the payment is complete, retrieve the balance transaction

payment_intent = stripe_client.v1.payment_intents.retrieve("pi_123456789")
charge_id = payment_intent.latest_charge

# Get the charge's balance transaction
charge = stripe_client.v1.charges.retrieve(charge_id)
balance_transaction = stripe_client.v1.balance_transactions.retrieve(
    charge.balance_transaction
)

# Extract the fee amount
stripe_fee = balance_transaction.fee  # This is in cents/smallest currency unit
  • When your platform handles the fees (fees_collector: "application"), the payment flow works differently than you might expect:
    • Initial charge: The funds are charged to the customer
    • Platform receives funds: The entire payment amount is deposited to your platform’s Stripe balance first
    • Stripe fee deduction: Stripe fees are deducted from your platform balance
    • Transfer to connected account: A transfer is created to move funds to the connected account

Webhook handler for payment_intent.succeeded

def handle_payment_succeeded(payment_intent_id):
    # 1. Get the payment details
    payment_intent = stripe_client.v1.payment_intents.retrieve(payment_intent_id)
    charge_id = payment_intent.latest_charge
    
    # 2. Get the charge and its balance transaction
    charge = stripe_client.v1.charges.retrieve(charge_id)
    balance_tx = stripe_client.v1.balance_transactions.retrieve(charge.balance_transaction)
    
    # 3. Calculate the correct transfer amount
    total_amount = balance_tx.amount
    stripe_fee = balance_tx.fee
    platform_fee = payment_intent.application_fee_amount
    
    # Calculate final transfer amount
    transfer_amount = total_amount - stripe_fee - platform_fee
    
    # 4. Create the transfer to the connected account
    connected_account_id = payment_intent.on_behalf_of or payment_intent.transfer_data.destination
    
    transfer = stripe_client.v1.transfers.create(
        amount=transfer_amount,
        currency=payment_intent.currency,
        destination=connected_account_id,
        source_transaction=charge_id,  # Links the transfer to the original charge
        description=f"Transfer for Payment Intent {payment_intent_id}"
    )
    
    return transfer