Mastercard's First-Party Trust rule lets a merchant's own transaction history defeat a "No Cardholder Authorization" chargeback (reason code 4837). The idea: if the same cardholder bought from you before, and identity signals from those earlier purchases match the disputed one, the chargeback is rejected and liability shifts to the issuer.
The network makes the final decision. You still need your own model of it. With an internal eligibility checker you can:
- Predict which incoming 4837 disputes should bounce, and flag the ones that didn't.
- Find gaps in your data capture before a dispute arrives, not after.
- Answer evidence requests quickly if you use the on-dispute integration path.
This article covers how to design that checker: the data model, the matching algorithm, the edge cases that quietly break eligibility, and how to test it. For the program basics (regions, integration routes, and what issuers can do after a reject), see Mastercard First-Party Trust program requirements and data elements.
The rule, expressed as constraints
Strip away the business language and the rule comes down to a few constraints:
| Constraint | Value |
|---|---|
| Applicable dispute | Reason code 4837 only (37 on Debit Mastercard Single Message System) |
| Prior transactions required | At least 2 |
| Prior transaction age | 121 to 365 days before the disputed transaction |
| Prior transaction status | Not reported as fraud |
| Matching elements required | 3 total |
| Category requirement | At least one element from each of two core categories, plus a third from the core set or an additional list |
Public descriptions of the core categories group them roughly as device signals (device fingerprint, IP address) and contact/delivery signals (shipping address, email address). Additional elements include login ID, phone number, device location, device name, billing address, and a generic "additional factor."
Design decision #1: do not hardcode these lists. Put the categories, the window, and the minimum counts in config. Programs like this get revised, and your checker should change with a config update, not a deploy.
fpt_rules:
reason_codes: ['4837', '37']
window_days: { min: 121, max: 365 }
min_prior_txns: 2
min_total_elements: 3
core_categories:
device: [device_fingerprint, ip_address]
contact: [shipping_address, email]
additional: [login_id, phone, device_location, device_name, billing_address]
The data model
Your checker only works with data you kept. For every card-not-present transaction, persist an evidence record:
evidence_record
txn_id PK
card_fingerprint -- tokenized PAN reference, never raw PAN
txn_timestamp_utc
device_fingerprint
ip_address
shipping_address_normalized
email_normalized
login_id
phone_e164
billing_address_normalized
device_name
device_location
fraud_flag -- set if this txn was later reported/charged back as fraud
fraud_flag_source
Three practical points:
- Retention must exceed 365 days plus your dispute window. A dispute can arrive months after the disputed purchase, and the lookback runs from the disputed transaction date. If your evidence table is pruned at 12 months, your oldest eligible priors disappear exactly when you need them.
- Key on a card reference, not a customer ID. The rule is about the cardholder's card. Guest checkouts often have no customer ID at all.
- Store both raw and normalized values. Normalize for your own matching, but keep what you actually sent to the network so you can audit any differences.
The matching algorithm
from datetime import timedelta
def check_eligibility(disputed, history, rules):
if disputed.reason_code not in rules.reason_codes:
return Result(eligible=False, reason='reason_code_not_covered')
lo = disputed.txn_ts - timedelta(days=rules.window_max)
hi = disputed.txn_ts - timedelta(days=rules.window_min)
candidates = [
t for t in history
if t.card_ref == disputed.card_ref
and lo <= t.txn_ts <= hi
and not t.fraud_flag
]
if len(candidates) < rules.min_prior_txns:
return Result(eligible=False, reason='insufficient_priors',
detail=len(candidates))
# An element counts only if it matches across ALL chosen priors.
# Try every pair, since different pairs can match different elements.
best = None
for a, b in pairs(candidates):
matched = {
el for el in rules.all_elements
if present(disputed, el)
and eq(disputed, a, el) and eq(disputed, b, el)
}
if satisfies(matched, rules):
return Result(eligible=True, priors=(a.txn_id, b.txn_id),
elements=sorted(matched))
best = max(best, matched, key=score)
return Result(eligible=False, reason='insufficient_matches',
detail=best)
def satisfies(matched, rules):
has_device = matched & set(rules.core['device'])
has_contact = matched & set(rules.core['contact'])
return bool(has_device) and bool(has_contact) \
and len(matched) >= rules.min_total_elements
The reason and detail fields matter as much as the boolean. "Insufficient priors" points to a customer-history problem. "Insufficient matches" with no device element points to a data-capture problem. Those are different fixes.
The pairwise search grows quadratically, but candidate sets per card are small. If you have high-frequency subscribers, cap the candidates (for example, the most recent N in the window) and record that you did so.
Worked example
A dispute arrives on a transaction dated 2025-09-10. The window runs from 2024-09-10 to 2025-05-12.
| Txn | Date | Device FP | IP | Ship addr | Fraud flag | |
|---|---|---|---|---|---|---|
| D (disputed) | 2025-09-10 | fp_9a1 | 203.0.113.7 | a.lee@x.com | 12 Elm St | - |
| P1 | 2025-08-01 | fp_9a1 | 203.0.113.7 | a.lee@x.com | 12 Elm St | no |
| P2 | 2025-03-02 | fp_9a1 | 198.51.100.4 | a.lee@x.com | 12 Elm St | no |
| P3 | 2024-11-15 | fp_9a1 | 198.51.100.9 | a.lee@x.com | 12 Elm St | no |
| P4 | 2024-10-01 | fp_77c | 198.51.100.4 | a.lee@x.com | 12 Elm St | yes |
- P1 is excluded because it is too recent (40 days).
- P4 is excluded because of its fraud flag.
- The candidates are P2 and P3.
- Elements matching across D, P2, and P3: device fingerprint, email, and shipping address. IP fails because it changed every time.
- The device and contact categories are both covered, with 3 elements in total. Eligible.
Note that P1 is the best match in the table and it doesn't count. Teams that eyeball "recent purchase, same everything" get this wrong all the time.
Edge cases that silently break eligibility
Window boundaries and time zones
Is "121 days" counted in calendar days, by settlement date, or by authorization timestamp? Pick one, document it, and put it in config. Store timestamps in UTC. A purchase at 23:30 local time can land on a different UTC date and fall outside the window by one day. Treat transactions within ±1 day of either boundary as "uncertain" in your reporting.
IP address instability
Mobile carriers, carrier-grade NAT, and VPNs make IP the weakest element. Don't plan around it. If your device-category coverage depends on IP, invest in device fingerprinting instead.
Device fingerprint rotation
Browser updates, cleared storage, and privacy features can rotate fingerprints. Track your fingerprint vendor's stability. A sudden drop in device-element matches across all customers usually means the fingerprint changed, not the customers.
Normalization mismatches
12 Elm St. vs 12 Elm Street, Apt 0, and A.Lee+shop@x.com vs a.lee@x.com. Your normalizer decides what your checker sees as a match, but the network applies its own logic. Normalize conservatively (case, whitespace, standard address abbreviations). Don't strip email sub-addressing unless you have confirmed how the network treats it.
Late-arriving fraud flags
A prior transaction can be clean today and get disputed as fraud next month. Recompute eligibility when a dispute arrives, not when the purchase happens. Also, your fraud flag only reflects what you know. Issuer-side fraud reports you never saw could still disqualify a prior.
Card reissue
If the cardholder's card was reissued, a tokenized card reference may not link old and new transactions. Check whether your token provider keeps a stable reference across reissues.
Deletion requests
Privacy-driven deletion can remove the exact evidence records you depend on. Define how deletion interacts with dispute-evidence retention, with input from legal counsel. Don't leave it to whichever job runs first.
Pre-auth vs. on-dispute: engineering implications
There are two participation paths, and they need different systems:
| Concern | Pre-auth (data shared at checkout) | On-dispute (data requested later) |
|---|---|---|
| Where data lives | Sent to the network at transaction time | In your systems until requested |
| Latency budget | Inside the checkout path, so it must be fast | Asynchronous, but has a response deadline |
| Main failure mode | Missing fields at checkout are permanent | Retention gaps, slow lookups |
| Your checker's role | Monitoring coverage, predicting outcomes | Directly drives the API response |
With the pre-auth path, the most valuable metric is field completeness at checkout (for example, the percentage of CNP transactions with a non-null device fingerprint). With the on-dispute path, it is lookup reliability: the evidence service needs the same availability standards as payments, because a missed deadline means the dispute moves on without you.
Testing the checker
- Boundary fixtures: priors at day 120, 121, 365, and 366, in multiple time zones.
- Category fixtures: three matches all from one category should fail.
- Fraud flag fixtures: two clean priors plus one flagged prior, where only the flagged one matches.
- Property tests: adding a non-matching prior never changes eligible to ineligible. Setting the fraud flag on a used prior never makes the result more eligible.
- Shadow reconciliation: compare predicted outcomes against actual 4837 results each month. Every disagreement is either a checker bug or a rule interpretation you need to revisit.
Keep expectations honest
Your checker is a model of a rule you don't execute. It is useful for finding data gaps and spotting anomalies. It is not a guarantee. Confirm the current element definitions, window semantics, and regional availability with your acquirer and Mastercard's documentation. Keep every one of those in config, so the next rule revision is a one-line change.
Top comments (0)