Skip to main content

Stripe

Stripe dispute evidence needs state, not just a submitted POST

A Stripe dispute can be open, need a response, receive evidence, and still not be won. If your app marks the case handled after one POST, support will lose track of the real state.

FetchSandbox EngineeringFetchSandbox Engineering

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:

  1. retrieve the dispute
  2. confirm it is actionable
  3. submit evidence
  4. read the returned status
  5. 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.