Most teams pick dispute tooling based on how loud the problem feels. Finance sees lost revenue, the acquirer sends a warning about your chargeback ratio, and someone gets told to "buy chargeback software."
That label covers three different jobs:
- Prevention stops bad transactions before they happen, for example fraud screening and authentication.
- Deflection resolves a cardholder inquiry before it becomes a chargeback, for example issuer alerts and data-sharing tools.
- Recovery fights filed chargebacks through representment.
Each job fixes a different failure. The fastest way to find out which one you need is a diagnostic built on your own dispute data. This article shows how to build one: normalize dispute records, map network reason codes to categories, correct for mislabeled fraud, and calculate the numbers that should drive the decision. For a vendor-level comparison of how tools split across these jobs, see this comparison of chargeback management software.
What each job actually moves
Before writing any code, be clear about what you are measuring. The three jobs affect two separate outcomes:
| Job | Protects revenue? | Protects chargeback ratio? | Works on third-party fraud? | Works on first-party misuse? |
|---|---|---|---|---|
| Prevention (screening, auth) | Yes | Yes | Yes | Weakly |
| Deflection (alerts, inquiry data) | Partially (often refunds) | Mostly | Yes | Yes |
| Recovery (representment) | Yes, when won | Generally no | Sometimes | Yes |
The column that surprises people is the ratio column. Under most network calculations, a chargeback you win still counts toward your ratio. If you are near a monitoring-program threshold, recovery alone will not bring you back under it.
Your diagnostic therefore has to answer two questions:
- Which dispute types dominate?
- Which outcome is the business problem: revenue, ratio, or both?
Step 1: Normalize the dispute records
Dispute data usually arrives from several places: acquirer portals, processor APIs, webhook payloads, and monthly CSV exports. Each source uses its own field names. Start by mapping everything into one schema.
from dataclasses import dataclass
from datetime import date
from typing import Optional
@dataclass
class Dispute:
dispute_id: str
network: str # "visa" | "mastercard" | "amex" | "discover"
reason_code: str # raw network code, e.g. "10.4", "4853"
amount_cents: int
txn_id: str
customer_id: Optional[str]
device_id: Optional[str]
filed_on: date
stage: str # "inquiry" | "chargeback" | "pre_arb" | "arb"
represented: bool
won: Optional[bool] # None if still open
Two fields people tend to skip:
-
stage. Without it, you cannot tell deflected inquiries from real chargebacks, and you will double-count. -
represented. Without it, you cannot calculate coverage rate. As shown later, coverage rate matters more than win rate.
Keep customer_id and device_id too. You will need them to join disputes back to order history in Step 3.
Step 2: Map reason codes to categories
Visa groups its dispute reasons into four families: fraud (10.x), authorization (11.x), processing errors (12.x), and consumer disputes (13.x). Mastercard uses four-digit codes that map into similar groups. Build one lookup table and keep it in version control, because networks revise their codes.
CATEGORY_MAP = {
"visa": {
"10": "fraud",
"11": "authorization",
"12": "processing_error",
"13": "consumer_dispute",
},
"mastercard": {
"4837": "fraud", # No cardholder authorization
"4863": "fraud", # Cardholder does not recognize
"4808": "authorization",
"4834": "processing_error", # Point-of-interaction error
"4853": "consumer_dispute", # Cardholder dispute
"4855": "consumer_dispute", # Goods/services not provided
},
}
def categorize(d: Dispute) -> str:
table = CATEGORY_MAP.get(d.network, {})
if d.network == "visa":
return table.get(d.reason_code.split(".")[0], "unmapped")
return table.get(d.reason_code, "unmapped")
Treat unmapped as a real bucket and alert on it. If it grows past a few percent, either your mapping is stale or a new data source is sending codes in an unexpected format, such as "10.4 " with a trailing space or "104" without the dot.
Amex and Discover use their own code sets. Map them explicitly rather than guessing. Results on those networks also behave differently: each acts as both issuer and network, so representment outcomes usually need separate expectations.
Step 3: Correct the fraud bucket
This is where most diagnostics go wrong. A fraud reason code tells you what the cardholder claimed. It does not tell you what actually happened.
A customer who recognizes a purchase but wants their money back can still file under "no authorization." That transaction passed every fraud check because the card, device, and person were all legitimate. If you count it as third-party fraud, you will overinvest in screening, which cannot stop it.
To correct for this, join fraud-coded disputes back to order history and look for signs of an established relationship with the customer:
def likely_first_party(d: Dispute, history) -> bool:
"""history: lookup over prior settled, undisputed orders."""
if categorize(d) != "fraud":
return False
prior = history.undisputed_orders(
customer_id=d.customer_id,
device_id=d.device_id,
before=d.filed_on,
min_age_days=120,
)
signals = 0
if len(prior) >= 2:
signals += 1
if history.same_shipping_address(d.txn_id, prior):
signals += 1
if history.post_purchase_login(d.customer_id, d.txn_id):
signals += 1
if history.delivery_confirmed(d.txn_id):
signals += 1
return signals >= 2
The thresholds above are starting points, not rules. Tune them by hand-reviewing a sample. Pull about 50 disputes your classifier flags as first-party and 50 it leaves as true fraud, and have someone who knows your customers label them.
These same signals also matter in representment. Card networks have formalized evidence frameworks built around a history of prior undisputed transactions, so the join you build here can also feed your evidence pipeline later.
Step 4: Calculate the decision metrics
Once disputes are categorized, calculate these five numbers per month:
def diagnose(disputes, txn_count):
cbs = [d for d in disputes if d.stage == "chargeback"]
buckets = {}
for d in cbs:
key = "first_party" if likely_first_party(d, history) else categorize(d)
buckets[key] = buckets.get(key, 0) + 1
represented = [d for d in cbs if d.represented]
decided = [d for d in represented if d.won is not None]
won = [d for d in decided if d.won]
return {
"ratio": len(cbs) / txn_count,
"mix": {k: v / len(cbs) for k, v in buckets.items()},
"coverage_rate": len(represented) / len(cbs),
"win_rate": len(won) / max(len(decided), 1),
"effective_recovery": len(won) / len(cbs),
}
effective_recovery is coverage rate × win rate. It answers the question that matters: what share of all your chargebacks came back as money?
Check how your acquirer calculates the ratio before relying on ratio. Networks differ in whether the denominator uses the current or previous month's transactions, and whether it counts transactions or amounts.
Worked example
Suppose a subscription merchant processes 200,000 transactions in a month and receives 400 chargebacks. That is a ratio of 0.20%.
After Steps 2 and 3, the breakdown looks like this:
| Bucket | Count | Share |
|---|---|---|
| Fraud-coded, corrected to true third-party | 130 | 32.5% |
| Fraud-coded, reclassified as first-party | 90 | 22.5% |
| Consumer disputes (not received, cancelled recurring, not as described) | 140 | 35% |
| Processing errors | 40 | 10% |
Without Step 3, this merchant would see 55% "fraud" and buy screening. With the correction, true third-party fraud is about a third of the volume. The other two-thirds come from first-party misuse, consumer disputes, and the merchant's own processing errors. Screening cannot stop any of those.
Those three buckets point toward other fixes:
- Deflection, such as alerts and inquiry data-sharing, for first-party misuse and consumer disputes.
- Root-cause work on cancellation flows and billing descriptors for consumer disputes.
- Internal fixes for the 40 processing errors. No vendor should be paid to clean those up.
Now the recovery side. Assume a $60 average ticket and two representment options:
- Option A represents 40% of chargebacks and wins 75%. Effective recovery is 30%, or 120 wins worth $7,200.
- Option B represents 85% and wins 45%. Effective recovery is about 38%, or 153 wins worth $9,180.
Option A has the better headline win rate, but Option B recovers more money. A diagnostic that tracks effective_recovery exposes this immediately. A dashboard that only shows win rate hides it.
Common mistakes and how to catch them
Counting inquiries as chargebacks. Deflected inquiries inflate your dispute count and make deflection look useless. Filter on stage and test that the counts match your acquirer's chargeback report.
Trusting the reason code as root cause. Fraud-coded disputes from customers with years of clean history are the clearest signal of mislabeling. Run Step 3 on every report, not once.
Measuring win rate on a filtered denominator. If represented is missing from your data, you cannot calculate coverage. Make it a required field in ingestion and fail the pipeline when it is null for closed cases.
Mixing pricing units. Percentage of recovery, per-alert fees, and percentage of order value cannot be compared directly. Feed your monthly bucket counts into a cost model for each pricing structure, and include any monthly minimum.
Ignoring network splits. Report the mix and recovery metrics by network. A blended win rate can hide weak Amex or Discover results behind strong Visa results.
What the output tells you
Once the diagnostic runs monthly, the purchase decision usually becomes obvious:
- True third-party fraud dominates: prevention is the job.
- First-party misuse and consumer disputes dominate: deflection plus root-cause fixes, backed by recovery.
- The ratio is under threshold but revenue loss is high: recovery with high coverage.
- The ratio is near threshold: deflection and prevention first, because recovery will not move the count.
Build the classifier first. The tooling decision comes from the numbers it produces.
Top comments (0)