Payment flow
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
Day 0: IMMEDIATELY cancel the PaymentIntent
- Releases the auth hold (no money held on user’s card)
- Saved payment method remains on the customer
Day 1–45: Task lifecycle plays out (completion, confirmation, mediation)
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:
- Displaying the amount to the user
- Triggering currency conversion
- Saving the payment method with proper SCA authentication
You never capture it. You cancel it immediately after the PaymentSheet succeeds.
| Mechanism | What it does | Expiry |
|---|---|---|
capture_method: "manual" | Places an auth hold on the card | 7–31 days |
setup_future_usage: "off_session" | Saves the payment method to the customer | No expiry (until card itself expires) |
- These are independent side effects of the same PaymentIntent.
- The
setup_future_usageflag 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.
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:
- Create a SetupIntent
- Use the Stripe SDK to collect payment details
- 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_methodfield 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.
- 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}
)
- 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,
...
),
);
- 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
- 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,
},
}
)
- ✅ 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
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_actionon the off-session charge and potentially bring the user back to re-authenticate.
- The main driver is Strong Customer Authentication (SCA) regulation in the EU/UK/EEA. Outside those regions, re-auth is rare.
| Situation | Why |
|---|---|
| Issuer-requested challenge | The cardholder’s bank decides the transaction is risky and demands 3DS |
| This is the most common cause and is unpredictable. | |
| Amount significantly higher than original | If the off-session charge amount differs substantially from what was |
| originally authenticated, some issuers will soft-decline | |
| Different currency than originally authed | Charging in a different currency than the one used during the |
| initial SCA authentication | |
| Regulatory threshold changes | EU regulators periodically adjust SCA exemption thresholds |
| Card network mandate changes | Visa/Mastercard update their rules on which transactions require 3DS |
| Situation | Why |
|---|---|
| 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 settings | User tightens security preferences with their bank |
| Stripe Radar flags the charge | If Stripe’s fraud system elevates the risk score |
- 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.
- ~5-10% of off-session charges may get soft-declined with
- 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
- The risk is low because:
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.
The PaymentSheet handles Apple Pay / Google Pay as payment method options automatically. When the user selects Apple Pay or Google Pay:
- Stripe creates a tokenized card (a
pm_card_...payment method) behind the scenes setup_future_usage: "off_session"saves that tokenized card to the customer — just like a manually entered card- You cancel the auth hold, keep the saved payment method, and charge later
- Backend — no changes needed.
- The
payment_method_types: ["card"]already covers Apple Pay and Google Pay since they produce card tokens.
- Apple Pay and Google Pay tokens saved via
setup_future_usagebehave slightly differently than raw cards:
| Aspect | Regular card | Apple Pay / Google Pay |
|---|---|---|
| Saved for off-session | ✅ | ✅ |
| Off-session charging | ✅ | ✅ (uses the underlying card network token) |
| SCA re-auth risk | Low | Even 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
applePayandgooglePayparameters to the PaymentSheet config on the Flutter side.
- For Express accounts with platform-handled fees the only option for fees-collector is
fees_collector: "application"
- 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
- 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
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
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