Refunds get the attention. Disputes are worse, and the reason is structural rather than a matter of degree.
On a refund you at least have a parameter. reverse_transfer: true exists, you can pass it, and if you forget there is a boolean to point at afterwards. On a dispute there is no such parameter, because there is no such option.
What Stripe does when you lose
Stripe debits the platform for the disputed amount plus the dispute fee. That is it. That is the whole automatic behaviour.
The connected account that received the original transfer is not touched. Not reduced, not notified, not flagged. From that seller's perspective nothing happened at all — the money arrived weeks ago and is still theirs.
So the platform's position after losing a dispute on a destination charge is:
- disputed amount (debited from platform balance)
- dispute fee (debited from platform balance, typically ~15 USD)
+ nothing (the transfer is untouched)
The seller keeps the funds. You paid the customer back and paid a fee for the privilege.
Why this is not symmetrical with refunds
A refund is initiated by you, so Stripe can offer a parameter at the moment of initiation. A dispute is initiated by the cardholder's bank, days or weeks after the charge, through a process you are not in. There is no call of yours to attach a flag to.
The reversal, if you want one, has to be a separate action you take afterwards:
// on charge.dispute.closed with status 'lost'
await stripe.transfers.createReversal(transferId, {
amount: disputedAmount, // not necessarily the full transfer
description: `dispute ${disputeId}`,
});
Nothing calls that for you. If your webhook handler for charge.dispute.closed only writes a row and updates a status, the clawback never happens.
Three things that make this accumulate quietly
The timing hides it. A dispute closes 30 to 90 days after the charge. By then the transfer is in a different payout, a different month, and usually a different reconciliation report. The debit and the un-reversed transfer are never adjacent in any view you look at.
The connected balance may not cover it. Even when you do reverse, the seller may have already paid out. The reversal leaves the account negative, which surfaces later as a failed payout — a third event, in a fourth place, with no obvious link back to a dispute.
Winning still costs. The dispute fee is generally not returned on a win in most regions. A platform with a good win rate still bleeds fees, and those fees are charged to the platform rather than to the seller whose transaction caused them.
The reconciliation query
The question is the same shape as the refund one, and it is worth running over full history rather than a recent window, because the whole failure mode is that it is old by the time it matters:
SELECT d.id AS dispute_id,
d.charge_id,
d.amount AS debited,
d.fee AS dispute_fee,
COALESCE(r.amount, 0) AS reversed,
d.amount - COALESCE(r.amount, 0) AS unrecovered
FROM disputes d
LEFT JOIN transfer_reversals r ON r.charge_id = d.charge_id
WHERE d.status = 'lost'
AND d.amount > COALESCE(r.amount, 0)
ORDER BY unrecovered DESC;
The policy question underneath the technical one
Whether to claw back is genuinely a business decision, and reasonable platforms answer it differently. A marketplace with sellers who have no control over fraud may absorb disputes deliberately. A platform whose sellers do control fraud usually should not.
What is not a decision is doing it by accident. If you have never reversed a transfer on a lost dispute, you have chosen to absorb every one of them — you just did not know that was the choice you were making. Run the query and find out which platform you are.
Top comments (2)
The asymmetry you describe is the same one marketplaces hit on any split model, not just Connect: the clawback has to be your own action, weeks later, against a balance that may be empty. Two things that made it survivable for us — a rolling reserve on sub-merchants sized by their own dispute rate rather than a flat percentage, and treating the lost-dispute event as a job with retries, because the reversal often fails the first time on insufficient balance and a fire-and-forget webhook handler hides that. How do you handle the case where the seller has already paid out and gone quiet?
Thanks for adding this - rolling reserve sized by dispute rate (not flat) + lost-dispute as a retryable job is exactly the shape that survives.
On your question - seller paid out and gone quiet - we treat it as three states, not one, because the reversal has a different outcome in each:
Reversal succeeds but leaves negative available. This is the normal case you described — the job retries on insufficient_funds with backoff (not fire-and-forget webhook), transfer_reversals records amount vs attempts + next_retry_at, and the account stays negative until future volume covers it. We block new payouts while available < 0, surface it as balance_insufficient in the same view as the dispute (not a fourth place), and the reconciliation query you ran stays unrecovered > 0 until reversed catches up.
Negative persists past the seller's active window. Then the reserve matters: flat reserve hides the problem you flagged — good sellers subsidize bad ones and bad ones learn the reserve is not tied to them. Per-sub-merchant rolling reserve (reserve_bps = f(dispute_rate)) sized weekly means the seller who caused the fee already funded its own tail. On close-out we net reserve → reversal, remainder is platform_loss. That loss is explicit in disputes d LEFT JOIN transfer_reversals r as unrecovered, not hidden in payout failures.
Seller is actually gone (no future volume, no reserve left). At that point it's not a Stripe operation anymore — transfers.createReversal will fail permanently. We mark unrecovered as written_off vs in_collection and stop retrying. For us the decision is the one the article ends on: if you have never written off a transfer on a lost dispute, you have chosen to absorb them by accident. Making lost → reversal_attempt → (negative|reserve|write_off) a single job with retries + reserve sizing makes the absorption intentional and priced, rather than a quiet leak across 90-day-old payouts.
Curious how you size the reserve window — we use 90d rolling (match dispute close 30-90d) vs 30d?