← Blog

E-commerce

Stripe dispute payload: three fields decide fight or refund

Every Stripe dispute payload has twenty fields. Three of them decide whether you fight or refund. The rest is context you read after the decision is made.

Jacob Molkenboer· Founder · A Brand New Company· 3 Jul 2024· 6 min
Craft-paper parcel with linen twine, three brass shipping tags with green ribbon and red wax seal on ivory table.

A Stripe dispute webhook lands at 02:14. The founder opens the dashboard, sees "Fraudulent," and forwards it to support with one word: "Fight." Two weeks later the chargeback is final, the funds are gone, and the evidence form was never submitted because nobody read the deadline field. This is the most common way small e-commerce teams lose disputes they could have won.

The Stripe dispute object has twenty-something fields. Three of them decide whether you fight, refund, or do nothing. The other twenty are context. Reading the payload like a payments engineer means going to those three first, in order, before you open any email thread.

The payload, stripped

Strip out the metadata and a real dispute looks like this:

{
  "id": "dp_1Oz...",
  "object": "dispute",
  "amount": 14900,
  "currency": "eur",
  "reason": "product_not_received",
  "status": "needs_response",
  "is_charge_refundable": true,
  "evidence_details": {
    "due_by": 1717977599,
    "has_evidence": false,
    "past_due": false,
    "submission_count": 0
  },
  "charge": "ch_3Oz..."
}

Read in order: reason, is_charge_refundable, evidence_details.due_by. Everything else is downstream of these three.

Field one: reason

The reason field is Stripe's translation of the card network's reason code. Visa, Mastercard, and Amex each have their own dispute taxonomy. Stripe collapses them into a smaller set: fraudulent, product_not_received, product_unacceptable, subscription_canceled, duplicate, credit_not_processed, general, plus a few more.

The translation is mostly faithful, but the network's actual code lives in network_reason_code and sometimes tells a different story. A Visa code 13.1 (merchandise/services not received) and a code 10.4 (card-absent fraud) both look bad in the dashboard, but the evidence you submit is completely different.

The reason determines what evidence Stripe will accept:

  • fraudulent: AVS match, CVC match, IP address, device fingerprint, prior successful purchases from the same customer.
  • product_not_received: shipping proof, tracking number with delivery confirmation, communication records.
  • subscription_canceled: cancellation policy, proof the customer used the service after the cancellation date, your terms of service.

If you submit shipping evidence for a fraudulent dispute, you lose. If you submit AVS data for product_not_received, you lose. Match the evidence to the reason, not to your hunch about what happened.

Field two: is_charge_refundable

is_charge_refundable is a boolean that quietly decides your options.

When true, you can still refund the original charge. The dispute reverses, you eat the chargeback fee, but you avoid the dispute counting against your win rate. This is usually the right call for small disputes where the customer is clearly going to win anyway.

When false, the funds are already held by the network. You cannot refund. You can only submit evidence and wait for the network to decide. The decision to fight is no longer a decision, it is forced.

The flip from true to false happens fast, sometimes the moment the dispute is created, sometimes a few hours later. If you see is_charge_refundable: true and the dispute reason is fraudulent, refund immediately. Fighting friendly fraud rarely pays back the support hours, and Stripe's own guidance on measuring dispute outcomes is candid about how few of these wins land in the merchant's favour.

Warning

If is_charge_refundable is false, do not submit a partial refund hoping it resolves the dispute. Partial refunds on disputed charges create reconciliation problems and do not withdraw the dispute.

Field three: evidence_details.due_by

evidence_details.due_by is a Unix timestamp. It is the only deadline that matters. Not the dashboard email, not the Slack reminder you set, not your support tool's SLA.

The deadline is set by the card network, not by Stripe. Visa gives you up to 30 days, Mastercard typically 45, but Stripe shortens these by about a week to give themselves time to review your submission. So a Mastercard dispute the network treats as due on day 45 shows due_by around day 38 in the payload.

Subtract today from due_by. If you have less than 72 hours, drop everything. If you have more than two weeks, schedule the evidence work but do not delay submission past the one-week-out mark. Stripe occasionally returns evidence as incomplete and you need re-submission time.

const dueBy = new Date(dispute.evidence_details.due_by * 1000);
const hoursLeft = (dueBy.getTime() - Date.now()) / 36e5;

let priority;
if (hoursLeft < 72) priority = 'urgent';
else if (hoursLeft < 168) priority = 'soon';
else priority = 'scheduled';

The past_due: true flag is terminal. If you see it, the window closed and the network ruled in the customer's favour. There is no appeal path inside Stripe's API.

The decision tree, in plain code

Once you read the three fields, the decision compresses to twenty lines:

function decide(dispute) {
  const { reason, is_charge_refundable, amount, evidence_details } = dispute;

  if (evidence_details.past_due) return 'already_lost';

  if (reason === 'fraudulent' && is_charge_refundable) {
    return amount < 10000 ? 'refund' : 'fight';
  }

  if (reason === 'duplicate') {
    return 'fight'; // Stripe usually has the receipts
  }

  if (reason === 'product_not_received') {
    return hasShippingProof(dispute.charge) ? 'fight' : 'refund';
  }

  if (reason === 'subscription_canceled') {
    return hasUsageAfterCancellation(dispute.charge) ? 'fight' : 'refund';
  }

  return 'review_manually';
}

This is not the final word. Every dispute has context. But it removes the cognitive load of pattern-matching from scratch each time, and most disputes route to a default action in under a second.

The smallest useful dispute handler

If you are running an e-commerce site on Stripe and you do not yet have an automated dispute response, the smallest useful thing you can build today is a webhook that does three things when it fires on charge.dispute.created:

  1. Posts to Slack with the three fields decoded into plain English.
  2. Calculates hoursLeft from due_by and colour-codes it.
  3. Looks up the original order and attaches shipping or usage data.

That alone closes the gap between "dispute happened" and "human acts." Most small teams lose disputes not because they are hard to fight, but because the payload sits in an email nobody opens for four days.

When we built the dispute handler for a Dutch e-commerce client, the thing we kept hitting was that network_reason_code and reason disagreed often enough that a default template based on Stripe's reason kept routing to the wrong evidence bundle. We ended up reading the network code first and using Stripe's reason as a fallback, the kind of pattern that lives inside our process automation work.

Take one real dispute payload from your Stripe dashboard, paste it into a JSON viewer, and read those three fields in order. If your team's current workflow does not start there, you have a five-minute fix to make before Monday.

Key takeaway

Read reason, is_charge_refundable, and evidence_details.due_by first. The other twenty fields are context for a decision those three already made.

FAQ

What does is_charge_refundable being false actually mean?

The funds are held by the card network and you cannot refund the original charge. Your only path is to submit evidence through Stripe's evidence flow and wait for the network's decision.

Is network_reason_code more reliable than Stripe's reason field?

Often yes. The network code is what the card network actually sent. Stripe's reason is a translation. When they disagree, evidence chosen against the network code tends to land better.

How fast does is_charge_refundable flip from true to false?

Usually within hours of dispute creation, sometimes immediately. Treat any true value as a closing window, not a stable state, and refund the same hour if that is the chosen action.

Can I appeal a dispute after past_due is true?

Not inside Stripe's API. Once past_due is true, the window closed and the network has already ruled. The funds are gone and the chargeback counts against your win rate.

e-commerceintegrationsoperationsworkflowautomation

Building something?

Start a project