DEV Community

Veristria
Veristria

Posted on Originally published at feeguard.dev

What a lost dispute actually costs a Connect platform

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)
Enter fullscreen mode Exit fullscreen mode

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}`,
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
payneteasy profile image
Payneteasy

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?

Collapse
 
veristria profile image
Veristria

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:

  1. 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.

  2. 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.

  3. 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?