Stripe Connect in Flutter: Building Marketplace Payments the Right Way
A practical guide to multi-party payments with Stripe Connect and flutter_stripe — account onboarding, PaymentIntents with platform fees, and webhooks you can trust.

When I built Range Buddy, the app needed to take a payment from a customer, pass most of it to a shooting range, and keep a small platform fee. That’s a marketplace payment, and a regular Stripe integration can’t do it on its own. You need Stripe Connect.
I’ve since used the same pattern on other projects, including a .NET backend for Dent Shop Manager. This post walks through how the pieces fit together in a Flutter app, and the mistakes I’d help you avoid.
The golden rule: the app never touches money logic
Before any code: your Flutter app should never decide how much to charge. The app asks the backend “I want to book session X”. The backend looks up the price, calculates the fee, and creates the payment. The app only displays Stripe’s payment sheet and reports what happened.
If the amount comes from the client, anyone with a proxy tool can change it. Keep the secret key and the pricing on the server.
The flow looks like this:
- The app asks your backend to start a checkout for a specific item.
- The backend creates a PaymentIntent on Stripe, with the connected account as destination and your platform fee attached.
- The backend returns the PaymentIntent’s
client_secretto the app. - The app presents Stripe’s PaymentSheet using that secret.
- Stripe notifies your backend via a webhook when the payment succeeds.
- The backend marks the booking as paid. The app refreshes.
Step 1: Onboard sellers with Express accounts
Each seller (a range, a shop, a coach — whoever receives money) needs a connected account. For most marketplaces, Stripe’s Express accounts are the right choice: Stripe hosts the identity verification and payout setup, and you don’t have to build any KYC screens.
On the backend (C# with Stripe.net here, but any server SDK looks similar), create the account and an onboarding link:
var account = await new AccountService().CreateAsync(new AccountCreateOptions
{
Type = "express",
Email = seller.Email,
Capabilities = new AccountCapabilitiesOptions
{
CardPayments = new AccountCapabilitiesCardPaymentsOptions { Requested = true },
Transfers = new AccountCapabilitiesTransfersOptions { Requested = true },
},
});
seller.StripeAccountId = account.Id;
await db.SaveChangesAsync();
var link = await new AccountLinkService().CreateAsync(new AccountLinkCreateOptions
{
Account = account.Id,
RefreshUrl = "https://yourapp.com/stripe/refresh",
ReturnUrl = "https://yourapp.com/stripe/return",
Type = "account_onboarding",
});
return link.Url;
In Flutter, open that URL with url_launcher. When Stripe redirects to your ReturnUrl, deep-link back into the app and re-check the account status from your backend — returning from the flow doesn’t mean onboarding is complete. The account is ready to receive money only when charges_enabled is true.
Step 2: Create a PaymentIntent with a platform fee
With destination charges, the customer pays your platform, and Stripe automatically transfers the funds to the connected account, minus your fee:
var intent = await new PaymentIntentService().CreateAsync(new PaymentIntentCreateOptions
{
Amount = booking.PriceInCents, // looked up server-side, never from the client
Currency = "usd",
ApplicationFeeAmount = booking.PlatformFeeInCents,
TransferData = new PaymentIntentTransferDataOptions
{
Destination = range.StripeAccountId,
},
Metadata = new Dictionary<string, string> { ["bookingId"] = booking.Id.ToString() },
AutomaticPaymentMethods = new PaymentIntentAutomaticPaymentMethodsOptions { Enabled = true },
});
return new { clientSecret = intent.ClientSecret };
Two things worth noting:
- Amounts are in the smallest currency unit (cents for USD). Use integers end to end. Floating-point money is a bug waiting to happen.
- Put your own ID in
Metadata. When the webhook arrives, that’s how you find the booking it belongs to.
Step 3: Show the PaymentSheet in Flutter
The flutter_stripe package gives you Stripe’s prebuilt, PCI-compliant PaymentSheet. Set the publishable key once at startup:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
Stripe.publishableKey = const String.fromEnvironment('STRIPE_PUBLISHABLE_KEY');
await Stripe.instance.applySettings();
runApp(const App());
}
Then, at checkout:
Future<PaymentOutcome> payForBooking(String bookingId) async {
final clientSecret = await api.createPaymentIntent(bookingId);
await Stripe.instance.initPaymentSheet(
paymentSheetParameters: SetupPaymentSheetParameters(
paymentIntentClientSecret: clientSecret,
merchantDisplayName: 'Range Buddy',
),
);
try {
await Stripe.instance.presentPaymentSheet();
return PaymentOutcome.submitted;
} on StripeException catch (e) {
if (e.error.code == FailureCode.Canceled) return PaymentOutcome.cancelled;
return PaymentOutcome.failed(e.error.localizedMessage);
}
}
Notice the return value is submitted, not paid. That’s deliberate — see the next step.
Step 4: Trust the webhook, not the app
When presentPaymentSheet() completes, the payment has been submitted. It usually succeeds, but some payment methods confirm asynchronously, and the app can be killed or lose its connection at exactly the wrong moment.
The only reliable signal is Stripe’s webhook to your server:
[HttpPost("stripe/webhook")]
public async Task<IActionResult> Webhook()
{
var json = await new StreamReader(Request.Body).ReadToEndAsync();
var stripeEvent = EventUtility.ConstructEvent(
json, Request.Headers["Stripe-Signature"], _webhookSecret);
if (stripeEvent.Type == "payment_intent.succeeded")
{
var intent = (PaymentIntent)stripeEvent.Data.Object;
await _bookings.MarkPaidAsync(intent.Metadata["bookingId"], intent.Id);
}
return Ok();
}
- Always verify the signature with
ConstructEvent. Without it, anyone can POST fake “payment succeeded” events to your endpoint. - Make the handler idempotent. Stripe retries webhooks, so the same event can arrive more than once.
MarkPaidAsyncshould do nothing if the booking is already paid. - After the PaymentSheet closes, the app shows a “Confirming payment…” state and polls (or listens) for the booking status the webhook sets.
Mistakes I’ve seen (and made)
- Testing only with the happy-path card. Stripe provides test cards for declines, insufficient funds and 3D Secure authentication. Run through all of them. 3DS in particular behaves differently on a real device.
- Forgetting the connected account might not be ready. Check
charges_enabledbefore showing a “Book” button for a seller. - Hard-coding keys in the app. The publishable key is fine to ship, but pass it via
--dart-defineso test and live builds can’t get mixed up. The secret key never goes near the app. - Ignoring refunds and disputes. With destination charges, a refund can reverse the transfer to the seller (
ReverseTransfer = true) and refund your application fee (RefundApplicationFee = true). Decide your policy before launch, not during your first dispute. - Skipping Apple Pay and Google Pay. Both are supported by the PaymentSheet with a few extra parameters, and they noticeably reduce checkout friction on mobile.
Takeaways
- Use Stripe Connect with Express accounts when money flows to third parties.
- Prices, fees and PaymentIntents are created on the server — never in the app.
- The Flutter side is small: fetch a client secret, init and present the PaymentSheet.
- The webhook is the source of truth for “paid”. Verify it, and make it idempotent.
Payments are one of those areas where a working demo and a production-ready system are far apart. If you’re building a marketplace app and want help getting the payment flow right, get in touch.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).