Free audit · Marketplace CTO
Marketplace refund leaks: when reverse_transfer is missing
Nothing errors. The refund returns 200, the webhook fires, your logs stay clean — and your platform balance is quietly short by the amount of the original transfer. This is the single most common way Connect platforms lose money, and it is invisible until month-end close.
What actually happens when the flag is omitted
On a destination charge, Stripe collects funds into your platform balance and immediately transfers the amount minus your application fee to the connected account. Your platform is the merchant of record, so your platform carries refund liability.
When you create a refund, Stripe debits your balance for the full refunded amount. The transfer already sent to the connected account is untouched unless you explicitly ask for it back.
The result on a $100 charge with a $10 platform fee: you refund the buyer $100, the seller keeps $90, and you are $90 out of pocket on a sale you netted $10 from.
- Charge amount
- $100.00
- Your application fee
- $10.00
- Transferred to seller
- $90.00
- Refunded to buyer (from your balance)
- −$100.00
- Reversed from seller
- $0.00
- Your net position
- −$90.00
// Leaks money — the connected account keeps the transfer
await stripe.refunds.create({ charge: 'ch_123' });
// Correct — pulls the proportional transfer back
await stripe.refunds.create({
charge: 'ch_123',
reverse_transfer: true,
refund_application_fee: true,
});The partial-refund trap
Most teams that know about reverse_transfer still set it only on full refunds. Partial refunds are where the leak survives a code review.
Stripe reverses the same proportion of the transfer as the proportion of the charge refunded. A 30% refund on a $100 charge with a $90 transfer should create a $27 reversal. Skip the flag on partials and you leak 30% of every partially refunded order forever.
Rounding compounds this. A charge refunded across several partial refunds accumulates independent roundings, so a check computed once over the total can differ from Stripe by a cent or two. Any audit needs a small tolerance or it reports noise indefinitely.
How to audit your last 90 days
For every refunded charge that has a transfer, compare what should have been reversed against what actually was. The gap is your recoverable balance.
const charges = await stripe.charges.list({
limit: 100,
created: { gte: Math.floor(Date.now() / 1000) - 90 * 86400 },
expand: ['data.transfer'],
});
let shortfall = 0;
for (const charge of charges.data) {
if (!charge.transfer || charge.amount_refunded === 0) continue;
const transfer = await stripe.transfers.retrieve(charge.transfer as string);
const expected = Math.round(
(charge.amount_refunded / charge.amount) * transfer.amount,
);
const actual = transfer.amount_reversed ?? 0;
if (expected > actual) shortfall += expected - actual;
}
console.log('recoverable:', shortfall / 100);Recovering what has already leaked
A refund issued without the flag can still be corrected. Create a standalone reversal for the outstanding amount — it is capped at the transfer balance not yet reversed.
Timing matters more than most teams expect. Once the connected account has paid out, a reversal leaves them with a negative balance that Stripe recovers from future volume. If they never transact again, you absorb it. Recovering within the payout window is the difference between a bookkeeping entry and a write-off.
await stripe.transfers.createReversal(
'tr_123',
{ amount: 2700, refund_application_fee: true },
{ idempotencyKey: 'recovery-ch_123' },
);What FeeGuard does about it
FeeGuard runs the comparison above on every charge.refunded event as it arrives, then cross-checks it against the application-fee position so a charge already made whole by a fee refund is never double-flagged.
Detection is delayed a few seconds and serialised per charge, because charge.refunded and transfer.reversed routinely arrive within a second of each other in either order. Without that, a reversal still in flight looks identical to one that never happened — and a monitoring tool that cries wolf gets switched off.
Run the check yourself
Seven steps, no signup. If the number at step 6 is greater than zero, you have a leak — and you did not need us to prove it.
- 1
Export all charge.refunded events for the last 90 days.
- 2
For each charge that has a transfer, retrieve the transfer and sum reversals.
- 3
Calculate expected reversal = (amount_refunded / amount) × transfer.amount. Flag if actual < expected.
- 4
For every application_fee on those charges, confirm amount_refunded is proportional.
- 5
Pull all charge.dispute.closed with status=lost; verify transfer reversal + fee refund occurred.
- 6
Sum missing amounts. That number is your recoverable baseline.
- 7
Connect FeeGuard if you would rather this ran continuously than once.