Most dispute integrations are built on two assumptions. The first is that the evidence file is where the work is. The second is that won and lost are terminal states — the dispute is over, the row is closed, the ledger is settled.
Both are wrong, and they fail in opposite directions. The first makes teams spend effort at the wrong time. The second makes money move through an account that no longer has a place to put it.
Submission is one-shot, and that is the whole constraint
When a chargeback arrives, you usually have 7 to 21 days to respond, depending on the network. That window is not a drafting period with a save button. You can submit evidence for a dispute exactly once. Stripe forwards your response and every attached file to the issuing bank immediately, and network rules don’t allow submission after the deadline. There is no amending it, no “we found the delivery confirmation, can we add it.”
The API makes this easier to get wrong than the Dashboard does. Updating any field in the evidence hash submits the whole hash for review — and the submit parameter on a dispute update defaults to true. If you want to assemble evidence across several calls while you chase down a tracking number, you have to pass submit: false explicitly on every one of them. A background job that patches evidence.customer_email_address as a tidy-up step will, by default, fire your half-built case at the issuer.
So the engineering question isn’t “how do we write a good rebuttal.” It’s “what do we know at the moment the dispute opens, and did we collect it at checkout?” Evidence quality is mostly decided months earlier, by what your integration passes to Stripe on the payment. Shipping carrier and tracking number, service dates, the customer’s IP and email, the refund policy they actually saw — all of that is either in your payment metadata when the dispute lands, or it is a scramble through three systems against a clock.
Two practical limits worth designing to: the combined evidence file size caps at 4.5 MB, and Mastercard evidence is capped at 19 pages combined. Banks don’t follow links, so anything hosted externally is not evidence. One file per evidence type, so same-type documents have to be merged before upload.
“Closed” is not a terminal state
This is the half that breaks data models.
A cardholder can withdraw a dispute after the response deadline — every network allows it, including long after you have lost. These late withdrawals typically happen outside the networks’ dispute systems, so they aren’t governed by the usual rules or timelines. Stripe is explicit that it’s difficult to predict when one will show up in your account; the issuer might take weeks or months to process the adjustment. The practical consequence is that a dispute you marked lost in March can reinstate funds in July.
Worth knowing too: a withdrawal is not a win. If the customer tells you they’ve withdrawn and you skip the evidence submission, many issuers treat that silence as an acceptance of liability and you lose anyway. A withdrawn dispute also still counts against your dispute rate with the network. The only thing that converts a dispute into a win is evidence filed before the deadline.
And the status enum has grown. Alongside won, lost, needs_response, under_review, the warning_* inquiry states and prevented, the Dispute object’s status now includes appeal_needs_response and appeal_under_review — an appeal stage after a decision, with its own evidence.appeal hash, a reason_for_filing, and up to five supporting files. Treat the details here as provisional: the implementation page is access-gated, the change arrived on a preview API version, and I can’t verify from public documentation which disputes are eligible, what it costs, or whether it’s generally available on your account. If appeals matter to your volume, that’s a question to put to Stripe in writing rather than infer from a changelog. What is safe to say is the structural point: a path backwards out of a decided dispute now exists in the object model, and code written on the assumption that won/lost is the end of the line is code written against an older API.
There’s a specific trap in the event stream here. charge.dispute.closed fires when the status changes to lost, warning_closed, or won. If your handler treats that event as “archive this record, stop listening,” an appeal transition or a late reinstatement arrives at a row your application has already decided is history.
Status and money are separate streams
The other modelling error is assuming the dispute’s status tells you where the money is. It doesn’t, and the timing is counterintuitive.
Funds leave when the dispute is created, not when it’s lost. The network debits Stripe, Stripe debits your balance for the disputed amount plus the dispute fee, and those funds are held for the entire lifecycle. If you lose, nothing further moves — the debit you already took simply becomes permanent. If you win, the money comes back.
That’s why the Dispute object carries balance_transactions as a list of zero, one, or two entries: the withdrawal, and possibly the reinstatement. And it’s why charge.dispute.funds_withdrawn and charge.dispute.funds_reinstated exist as events separate from created and closed. Reconcile against those two, not against status transitions. A ledger driven by charge.dispute.closed will book a loss that was already booked and miss a reinstatement that arrives with no status change to accompany it.
One more asymmetry in the fees. Countering a dispute adds a dispute countered fee on top of the received fee. Stripe returns the countered fee if you win; it does not return the received fee (Mexico is the stated exception, and the countered fee doesn’t apply in Mexico or Japan). For Visa and Mastercard compliance disputes specifically, contesting costs an additional 500 USD or local equivalent, refunded on a win. So the decision to fight has a floor: below some disputed amount, the honest answer is to accept and move on, because winning costs more than the money you recover.
What to do with this
- Model dispute status and dispute money as two event streams that happen to share an ID, not as one lifecycle.
- Make the “closed” state in your schema reopenable, because the network’s version of closed already is.
- Collect evidence fields at payment time, not at dispute time.
- Pass
submit: falseon every evidence write that isn’t the final one.
The uncomfortable part is that none of this improves your win rate much. Dispute rates are measured on all disputes, won or lost — the industry treats dispute activity above 0.75% as excessive, and winning doesn’t pull you back under a monitoring threshold. Response machinery is damage control on money you’ve already lost the use of. It’s worth building well, and it’s worth being clear that it isn’t prevention.
Subodh Srivastava is the founder of SubcoDevs, a payments consultancy specialising in Stripe Connect marketplaces, subscription billing and migrations, dispute reduction and processor fee optimisation.
