The bug shows up after support clicks "submit evidence."
Your app calls Stripe. The request returns 200. The UI marks the dispute as handled. A week later, someone asks why the case is still sitting in review, or why support cannot tell which disputes actually had evidence attached.
Nothing failed at the HTTP layer. The bug is that your app treated "the POST returned" as the final dispute state.
The wrong state transition is too easy
The tempting handler is small:
export async function submitDisputeEvidence(disputeId: string, evidence: EvidenceInput) {
await stripe.disputes.update(disputeId, {
evidence: {
customer_email_address: evidence.customerEmail,
product_description: evidence.productDescription,
},
});
await disputeStore.update(disputeId, {
internalStatus: "handled",
evidenceSubmittedAt: new Date(),
});
}
That works only if your internal status means "we made an API call."
It does not work if support reads "handled" as "Stripe accepted evidence and there is nothing left to track."
Disputes are not checkout confirmations. They have a review lifecycle. Evidence can be submitted, but the dispute can still be needs_response, under_review, won, or lost depending on the provider state and timing.
Read before and after the write
For dispute tooling, the safer shape is:
export async function submitDisputeEvidence(disputeId: string, evidence: EvidenceInput) {
const before = await stripe.disputes.retrieve(disputeId);
if (before.status !== "needs_response") {
await disputeStore.reconcile(before.id, {
stripeStatus: before.status,
internalStatus: "not_actionable",
});
return;
}
const after = await stripe.disputes.update(disputeId, {
evidence: {
customer_email_address: evidence.customerEmail,
product_description: evidence.productDescription,
},
});
await disputeStore.reconcile(after.id, {
stripeStatus: after.status,
internalStatus: after.status === "needs_response" ? "evidence_submitted" : "reviewing",
evidenceSubmittedAt: new Date(),
});
}
The important part is the local vocabulary.
handled is too vague. Store the provider status separately from the support workflow state. evidence_submitted tells support what your app did. stripeStatus tells the rest of the system what Stripe currently says.
What to track locally
At minimum, persist:
- Stripe dispute ID
- provider status
- internal support status
- evidence submitted timestamp
- evidence submitter
- last reconciled timestamp
- next review/check deadline
That gives support a real queue. They can see which cases still need evidence, which ones have evidence submitted, and which ones need a later status check.
The test most examples skip
Most examples test only this:
await stripe.disputes.update(disputeId, { evidence });
The test that catches the bug is:
- retrieve the dispute
- confirm it is actionable
- submit evidence
- read the returned status
- update local support state without pretending the dispute is finished
The first GET protects you from stale support actions. The returned status protects you from local wishful thinking.
Receipts
Here's a real Stripe dispute evidence workflow run against the FetchSandbox sandbox, captured 2026-06-14:
GET /v1/disputes/DISP-1042-BRXK-7831 -> 200
id: DISP-1042-BRXK-7831
status: needs_response
POST /v1/disputes/DISP-1042-BRXK-7831 -> 200
id: DISP-1042-BRXK-7831
status: needs_response
Full timeline: fetchsandbox.com/runs/cfdb525560?flow=run_673dd1bc-80e8-47f2-8dbc-95e0534b0ec4
The dispute ID DISP-1042-BRXK-7831 is the record your support tooling should reconcile around. Notice the returned status is still needs_response in this run. That is exactly why "POST returned 200" should not become "case closed" in your app.
How to test this before real disputes
For Stripe disputes, the finish line is not "evidence POST returned 200." It is "support can still see the real dispute state after evidence is submitted."
We use FetchSandbox to run the dispute evidence workflow before wiring production support queues. The point is not to replace Stripe's real dispute process. The point is to keep your local state honest before money and support deadlines are involved.