Building & Shipping

The Withdrawal I Didn't Authorize

Brett Ridenour Brett Ridenour · Published July 2026

I opened my banking app on Monday because a low-balance alert had pinged. There was a pending withdrawal from Freebo’s account I did not remember initiating. A few hundred dollars, labeled “Withdrawal to cover a negative balance”, initiated by Stripe.

That is a fun sentence to read at breakfast.

Freebo is my booking platform. It runs on Stripe Connect. My operators — tour operators, charter captains — get paid through their own connected Stripe accounts. My platform account earns a small application fee per booking. My platform bank account is not supposed to move much. It especially is not supposed to shrink because Stripe decided.

Nine messages into a debugging session with the Stripe CLI later, I understood exactly what happened. Nothing was wrong with my code. Nothing was wrong with Stripe. Every step in the chain was documented behavior. But the chain, end to end, was news to me. It is probably news to a lot of other Connect platforms too. So here it is.

The setting nobody reads

When you create a Stripe Connect Express or Custom account, one field in the controller config quietly decides where losses go.

The clause that decides who eats a bad refund

"losses": { "payments": "application" } means the platform is contractually the loss guarantor for that operator. If the operator’s account cannot cover a refund, chargeback, or dispute, Stripe pulls the shortfall from the platform’s balance — and if the platform’s balance is empty, from the platform’s linked bank account.

Only Standard connected accounts carry their own loss liability. Express and Custom do not. And the field is set at account creation. You cannot flip it later. Changing it means re-onboarding the operator on a fresh Standard account.

I read Stripe’s docs before shipping. I know I did. I did not understand what this line meant.

Direct charges vs destination charges

Stripe Connect gives you three charge patterns. They differ in tiny ways that matter enormously.

Feature PatternCharge lives onRefund pulls from
Direct Connected account Operator balance
Destination Platform account Platform balance
Separate charges and transfers Platform account Platform balance

Freebo runs direct charges. Every booking is a PaymentIntent created with the Stripe-Account header set to the operator’s account ID and an application_fee_amount set to my platform take. Money flows to the operator. Refunds pull from the operator.

Which is what you would want. Refunds are the operator’s cost. That is the design.

Except.

The three-setting fuse

The design assumes the operator has money in their Stripe balance when the refund happens. In practice, three settings — all defaults or near-defaults — arrange the exact opposite.

  1. Setting 1
    Daily payouts, 2-day delay
    The operator's Stripe balance sweeps to their bank every day. It hovers near zero.
  2. Setting 2
    Refund issued more than two days after the charge
    The customer cancelled. The captain refunded. The payout for that booking already left. The money is in the operator's bank, not their Stripe balance.
  3. Setting 3
    debit_negative_balances: true on both accounts
    The default for onboarded Express accounts. When either balance goes negative, Stripe pulls the shortfall from the linked bank.

Fuse lit.

The refund succeeds. Stripe always pays the customer. The operator’s Stripe balance goes negative. And because the operator is Express with losses.payments: application, Stripe reaches into the platform’s balance and reserves matching collateral instantly, before it even attempts recovery from the operator.

My platform balance is thin by design. Application fees only, swept nightly. A collateral reserve of a few hundred dollars empties it and pushes it negative. debit_negative_balances: true on the platform account then does the rest, and my bank account gets a withdrawal request I never made.

The default Stripe Connect setup, with default flags, on Express accounts, is a marketplace where the platform silently insures against every connected account’s inability to cover a refund.

— what I finally understood

Who actually pays

The mechanics of who eats the loss are less brutal than the withdrawal alert made it look. Stripe simultaneously debits the operator’s bank account for the shortfall. Once that settles, it releases the platform’s reserved collateral back, and the money returns to the platform’s bank over the next few payouts. Net-net the platform is a temporary cash-flow shock, not a real loss.

That is only true if the operator’s bank debit succeeds. If it bounces — insufficient funds, closed account, frozen — the reserve is consumed and the platform eats the entire refund for real. That is the scenario the guarantor clause exists for. That is what I signed up for the moment I chose Express onboarding.

The fix stack, ranked by leverage

The first fix is the one I wish I had shipped on day one.

The six-line guard I should have shipped on day one

Before calling stripe.refunds.create, call stripe.balance.retrieve for the connected account. If available < refund_amount, warn the operator in the UI: “Your Stripe balance cannot cover this refund. Stripe will debit your bank account within 2 days to make up the difference.” Every future surprise becomes a decision.

The rest of the stack:

  1. Pass refund_application_fee: true on platform-fault refunds. By default Stripe keeps the application fee even on a full refund, and so does the platform. Which means the operator eats the refund plus my take plus the Stripe processing fee. Refunding the app fee shrinks their loss to just the processing fee and makes platform-fault refunds properly cost the platform.
  2. delay_days: 7 for new operators. Payout cadence is per-account. A week of holding means most refunds land while funds are still in the operator’s Stripe balance — killing both the operator bank debit and the platform collateral grab.
  3. Minimum-balance hold on the platform account. Stripe supports a floor below which daily payouts do not sweep. Set it to at least the largest single-booking refund you would expect. Mine goes up.
  4. A balance watchdog cron. Pull the platform balance and each operator balance every morning. Alert on any negative available, any connect_reserved > 0, and any payout described “Withdrawal to cover a negative balance”. Would have caught this a full day before my bank did.
  5. Handle the payout webhooks you skipped. payout.failed, payout.canceled, and the transfer.* family are the ones that fire on this whole cascade. My webhook handler was covering charge.refunded and payout.paid — which is why my ledger noticed the refund and the outgoing payout but not the collateral grab or the bank debit. Nothing in my system could have told me any of this was happening.

What I actually did wrong

I did not read the settings. All of them.

I read the Connect quickstart. I read enough of the direct-charge docs to know refunds pulled from the operator’s balance, and I stopped there because that was the answer I wanted. The loss-liability clause is not hidden. It is on the fifth page. I skipped past it because the setup wizard did not stop and ask me if I understood what I was signing up for.

The takeaway is not really about Stripe. It is about defaults in any platform that sits between users and money. The default is almost never the safe option for you. The default is the option that gets the most platforms onboarded fastest, which is a different optimization target. Every field in that setup wizard exists because at some point somebody’s setup broke around it, and now it is a knob. If you skip the knob, you inherit whoever asked for the default.

The fix is not sophisticated. It is: read the setup wizard the way you would read a contract, because that is what it is.