Fraud Prevention & Dispute Management

Fraud and disputes are where checkout conversion collides with financial risk: every fraud control you add at authorization time trades conversion against loss, and every chargeback that lands weeks later forces an asynchronous, deadline-bound workflow onto a billing system that was optimized for the happy path. This subsystem sits inside Frontend Checkout UX & Dunning Recovery Flows because the same payment instrument that drives recovery also carries the fraud and dispute liability — the card you vault for off-session retries is the card a fraudster wants to test, and the charge you recover through dunning is the one a cardholder may later dispute.

The hard parts are not the obvious ones. Scoring a transaction is a solved problem you mostly buy. The difficulty is reconciling an external, slow-moving dispute state machine with your internal ledger, holding disputed funds in a reserve without double-counting them, gathering evidence before a hard issuer deadline, and deciding when a frictionless 3DS2 flow is worth the conversion hit because it shifts chargeback liability to the issuer. This page covers fraud signals and risk scoring, the chargeback lifecycle and evidence submission, and the liability mechanics of 3DS2/SCA. The three companion guides go deep on automating chargeback dispute evidence submission, integrating Stripe Radar for payment fraud scoring, and routing 3DS2/SCA challenge flows without killing conversion.

One number should anchor every decision on this page: the card networks put you into a monitoring program once your chargeback rate crosses a threshold — Visa’s Dispute Monitoring Program starts at 0.9% of transactions (and 100 disputes in a month), Mastercard’s Excessive Chargeback Merchant program at 1.5%. Cross it and you inherit remediation fees, mandatory workflow changes, and eventually the risk of losing your acquiring relationship entirely. That ratio is the reason a subscription business cannot treat fraud as a pure loss-minimization problem: a single month of aggressive card-testing that clears authorization can drag your dispute ratio over the line and cost you the merchant account, even if the fraudulent charges themselves were small. Everything here — inline scoring thresholds, the reserve ledger, the evidence deadline job — exists to keep that ratio low while protecting conversion on legitimate customers, and the two goals pull in opposite directions often enough that you will be tuning the balance for the life of the product.

It also matters that fraud on a recurring-billing product looks different from fraud on a one-shot storefront. The high-value event is rarely the first charge; it is the vaulted credential that will be re-billed off-session every month. That changes where you spend scrutiny: the initial signup charge is often small (a trial or a first month), so a naive amount-based rule under-weights exactly the transaction that opens a recurring liability. The mental model throughout is that you are not clearing a payment, you are onboarding a payment relationship, and the machinery has to reason about the whole subscription lifecycle, not the single charge_id in front of it.

Prerequisites

Fraud and dispute handling needs risk scoring on every charge, idempotent dispute webhooks, a dedicated reserve account, an audit log, 3DS2 liability data, and evidence storage. The stack lists them before the checklist.

Fraud/dispute prerequisites Risk scoring, idempotent dispute webhooks, a dispute-reserve ledger account, an audit log, 3DS2 liability data, and evidence storage underpin the subsystem. Fraud + dispute engine Risk scoring per charge Dispute WH idempotent Reserve acct no double-count 3DS2 data liability shift Evidence object store
Six foundations — the dedicated reserve account is what keeps disputed funds from ever counting as available revenue.

The dedicated dispute-reserve account is the prerequisite people are tempted to skip, and skipping it is what quietly corrupts revenue reporting. If a disputed charge stays in revenue_recognized while the chargeback is open, your recognized revenue is overstated by the disputed amount for the 30–120 days the dispute runs, and if the dispute is lost you then book the loss as a second, separate event — the money effectively leaves twice on the books even though only one debit is real. A separate reserve account makes the open exposure a first-class, queryable number: at any instant SUM(dispute_reserve) is the total value of charges whose outcome is still unknown, which finance needs for accruals and which you need to sanity-check the whole subsystem. Treat the reserve as non-optional infrastructure, not an accounting nicety.

The audit log requirement deserves the same weight. Every risk decision you make is a decision you may have to defend — to a manual reviewer overriding a block, to a customer complaining they were declined, and occasionally to the acquirer during a program review. Record the score, the level, the rule or model version that produced it, and the correlation_id that ties it back to the originating request, because “why did we block this customer_id” is a question you will be asked months later when the model has already been retrained and the live score is no longer reproducible. An append-only log is the only artifact that survives that retraining.

Architecture & Data Flow

The flow has two distinct phases on two different clocks. The authorization phase is synchronous and sub-second: a payment_intent is scored, optionally routed through a 3DS2 challenge, then allowed, blocked, or queued for manual review. The dispute phase is asynchronous and can span 30–120 days: the issuer raises a chargeback, your ledger moves the funds into a reserve, you assemble and submit evidence before the network deadline, and the dispute resolves to won or lost. The diagram below traces a disputed charge through its lifecycle states.

Chargeback dispute lifecycle A dispute moves from needs_response through under_review to a won or lost terminal state, with the ledger reserve held until close. needs_response reserve held under_review evidence sent won reserve released lost funds debited submit accept reject
The dispute reserve is held the moment a chargeback is created and is only resolved when the network reaches a terminal state.

Inputs are the risk score from your scoring engine, the 3DS2 liability-shift flag, and inbound dispute webhooks. Processing is the risk decision (allow/block/review) at authorization and the evidence assembly job at dispute time. Outputs are a ledger reserve entry, a network evidence submission, and a final reconciliation when the dispute closes.

The critical architectural property is that the two phases must never share a synchronous dependency. The authorization phase runs inside the customer’s checkout request and its latency is conversion; the dispute phase runs days later inside a background worker and its latency is irrelevant as long as it beats the network deadline. If you let the dispute path call back into the authorization path — say, to re-score a charge at dispute time using the live model — you couple a deadline-bound batch job to a latency-bound online service, and a slow scoring call now threatens both conversion and evidence timeliness. Keep the coupling to shared data (the persisted charge_risk_assessment row, the vaulted token metadata) and never shared code paths. The persisted risk row from the authorization phase is precisely what the dispute phase reads as evidence, which is why step 1 stores the processor’s verdict verbatim rather than a value you might recompute.

Where the dispute state actually lives

A subtle but important design choice is that your processor, not your database, is the source of truth for dispute status. Stripe, Adyen, and Braintree all model the dispute as an object they own and mutate as the network progresses; your webhook consumer is a replica that lags reality by the webhook delivery latency. This has two consequences. First, you should never let an internal action (like a support agent clicking “accept dispute”) mutate your local status directly — route it through the processor API and let the resulting webhook update your copy, so the two never diverge. Second, because webhooks can arrive out of order or be redelivered, your consumer must be reconciling, not event-sourced: on every charge.dispute.updated, re-read the full dispute object and converge your local row to it, rather than applying a diff that assumes you saw the previous event. A dispute that jumps straight from needs_response to won because you never saw the intermediate under_review event must still land in the correct terminal state.

Implementation Walkthrough

The four steps span the two clocks: persist the risk decision and route it synchronously at authorization, then open a dispute reserve and resolve it asynchronously when a chargeback arrives. The two-clock diagram shows why they need different infrastructure.

Fraud two-clock model The authorization clock is sub-second: score, route, allow or block. The dispute clock is 30 to 120 days: chargeback, reserve, evidence, resolve. Authorization clock sub-second, synchronous score → route → allow/block/review latency budget = conversion Dispute clock 30-120 days, asynchronous chargeback → reserve → evidence deadline-critical, durable queue
Two clocks, two infrastructures — the authorization path optimizes latency, the dispute path optimizes durability and deadlines.

1. Persist the risk decision on every charge

Capture the risk score, risk level, and 3DS2 outcome at the moment the charge succeeds. You need this later as evidence and for tuning your rules. Store the outcome verbatim from the processor rather than recomputing it.

CREATE TABLE charge_risk_assessment (
  charge_risk_id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  charge_id           TEXT        NOT NULL UNIQUE,
  customer_id         UUID        NOT NULL,
  risk_score          INTEGER     NOT NULL,            -- 0–99, processor-reported
  risk_level          TEXT        NOT NULL,            -- normal | elevated | highest
  three_ds_outcome    TEXT,                            -- authenticated | attempted | not_required
  liability_shifted   BOOLEAN     NOT NULL DEFAULT FALSE,
  amount_minor        BIGINT      NOT NULL,            -- cents, never float
  currency            TEXT        NOT NULL,
  decision            TEXT        NOT NULL,            -- allowed | blocked | review
  created_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_risk_assessment_customer ON charge_risk_assessment (customer_id);
CREATE INDEX idx_risk_assessment_review
  ON charge_risk_assessment (created_at) WHERE decision = 'review';

Two schema decisions are load-bearing. The UNIQUE constraint on charge_id makes the row itself idempotent — if the charge-succeeded webhook is redelivered you get a benign conflict rather than a duplicate assessment, which matters because you will key later evidence lookups on this row and a duplicate would make “the risk score for this charge” ambiguous. And amount_minor is a BIGINT of minor units, never a floating-point major-unit value, because you will aggregate these across a customer for velocity and lifetime-exposure checks and float rounding turns a clean SUM into a number that fails reconciliation by a cent. The three_ds_outcome column is stored as the processor’s own enum string rather than a boolean precisely so that the attempted-versus-authenticated distinction survives — collapsing it to liability_shifted alone loses the information you need when a scheme later disputes whether the shift applied.

2. Route the risk decision at authorization

Translate the processor risk level into an allow/block/review action. Anything routed to review must place a hold rather than fulfilling, so a fraudster never gets the product before a human looks. This connects directly to your scoring rules — see the Radar integration guide for custom rule design.

type RiskLevel = 'normal' | 'elevated' | 'highest';

function decideOnRisk(riskLevel: RiskLevel, liabilityShifted: boolean): 'allowed' | 'blocked' | 'review' {
  if (riskLevel === 'highest') return 'blocked';            // ✗ hard block
  if (riskLevel === 'elevated') {
    // Liability shift moves chargeback cost to the issuer, so we can accept more risk.
    return liabilityShifted ? 'allowed' : 'review';          // ⚠️ manual review path
  }
  return 'allowed';                                          // ✅ success path
}

The review branch is where most teams get the economics wrong. Manual review is not free — a reviewer costs real minutes and adds hours of latency before the customer is provisioned — so a rule that dumps 5% of traffic into review will either bankrupt the ops budget or, more likely, get rubber-stamped into meaninglessness within a week. Size the review queue to what a human can actually clear with attention: for most SaaS products that is a fraction of a percent of authorizations, reserved for the genuinely ambiguous elevated-without-liability-shift band. Everything cleanly normal should never see a human, and everything highest should hard-block without one, because a reviewer staring at an obviously fraudulent charge is wasted attention that a rule should have removed. When you do hold for review, the hold must be on fulfillment, not just on the money — a fraudster who receives API access or a provisioned tenant before the review completes has already extracted the value even if you later reverse the charge.

Note also that blocked at the highest level is a decision to decline, and declines have their own cost: false positives are legitimate customers you turned away, and unlike a chargeback they are invisible — you never see the revenue you didn’t earn. This asymmetry is why hard-blocking should be reserved for the top of the score distribution where precision is high. In the elevated band, the liability-shift signal is doing real work: an authenticated 3DS2 result means the issuer, not you, eats a fraud chargeback, so you can safely allow a transaction you would otherwise have sent to review, buying conversion at no additional chargeback risk. That is the single most valuable lever on this page — it converts a friction decision into a liability decision.

3. Open a dispute reserve when a chargeback arrives

When charge.dispute.created fires, post a balanced ledger entry that moves the disputed amount out of available revenue and into a reserve account. The entry must be idempotent on dispute_id so a redelivered webhook never double-reserves.

-- Runs inside the webhook transaction, keyed on dispute_id for idempotency.
INSERT INTO ledger_entry (ledger_entry_id, dispute_id, debit_account, credit_account, amount_minor, currency, reason, created_at)
VALUES
  (gen_random_uuid(), $1, 'revenue_recognized', 'dispute_reserve', $2, $3, 'chargeback_opened', now())
ON CONFLICT (dispute_id, reason) DO NOTHING;  -- ⚠️ redelivered webhook is a no-op

The composite conflict target (dispute_id, reason) rather than dispute_id alone is deliberate: a single dispute legitimately produces several ledger entries over its life — the reserve on open, the fee expense, the release or loss on close — and each carries a distinct reason. Keying idempotency on dispute_id alone would silently swallow the second and third legitimate entries. Keying on the pair makes each phase transition idempotent while still allowing the dispute to accrue its full set of entries. Remember also that the reserve amount is the disputed amount plus, in most schemes, a non-refundable dispute fee that you owe win or lose; whether you reserve the fee too is a policy choice, but you must record it somewhere on open so that a won outcome does not mislead finance into thinking the dispute was free.

4. Resolve the reserve on close

On charge.dispute.closed, read the terminal status. A won dispute releases the reserve back to revenue; a lost dispute debits the reserve permanently and records the dispute fee as an expense. The transition out of under_review is the only place funds leave the reserve.

async function onDisputeClosed(dispute: { id: string; status: 'won' | 'lost'; amountMinor: number; currency: string }) {
  if (dispute.status === 'won') {
    await ledger.post({ disputeId: dispute.id, debit: 'dispute_reserve', credit: 'revenue_recognized',
      amountMinor: dispute.amountMinor, currency: dispute.currency, reason: 'dispute_won' }); // ✅ funds restored
  } else {
    await ledger.post({ disputeId: dispute.id, debit: 'dispute_loss', credit: 'dispute_reserve',
      amountMinor: dispute.amountMinor, currency: dispute.currency, reason: 'dispute_lost' }); // ✗ loss realized
  }
}

Edge Cases & Failure Modes

The edge cases split into ledger double-counts (redelivered webhooks, refund-after-dispute), deadline/liability mistakes (missed evidence window, assumed 3DS shift), and abuse (card-testing bursts). The map groups them.

Fraud/dispute edge cases Ledger double-counts need idempotency and refund blocks, deadline/liability mistakes need scheduled submission and exact 3DS outcomes, and card-testing needs velocity rules. Double-count redelivered webhook refund after dispute → idem key + refund block Deadline / liability missed evidence window assumed 3DS shift → submit at 80% + exact outcome Abuse card-testing burst partial-use dispute → velocity + usage evidence
Three categories — the refund-after-dispute double-credit and the assumed-3DS-shift are the two most expensive mistakes.
Scenario Failure if unhandled Mitigation
Dispute webhook redelivered Reserve posted twice, ledger imbalance Idempotent insert keyed on dispute_id + reason
Evidence deadline missed Automatic loss, no recourse Schedule submission at 80% of the network window; alert at 90%
Customer refunded after dispute opened Double credit (refund + lost dispute) Block refunds while dispute.status != closed; accept the dispute instead
3DS2 liability shift assumed but attempted only You eat a chargeback you thought the issuer owned Persist exact 3DS outcome; only treat authenticated as a full shift
Card-testing burst at checkout Hundreds of low-value auths, fees and reputational damage Velocity rule on card fingerprint + IP; block on N attempts/minute
Dispute on a partially used subscription Evidence must show actual usage, not just the charge Attach usage logs and login timestamps keyed to customer_id

The refund-after-dispute trap in detail

The refund-after-dispute double-credit is worth dwelling on because it is the failure mode that survives a code review and only shows up in the monthly reconciliation. The sequence is mundane: a customer emails support angry about a charge, the agent issues a goodwill refund, and — unknown to the agent — the customer has also already filed a chargeback with their bank for the same charge. Now two independent processes are each returning the money. When the dispute later resolves, you have refunded the amount once and lost (or paid out) it a second time; the cardholder is made whole twice at your expense. The defensive invariant is that a charge with an open dispute is frozen against manual refunds — the support tooling must read dispute.status before offering a refund button, and when a dispute exists the correct action is to accept the dispute (which returns the funds through the network) rather than issue a parallel refund. This is a data-model constraint, not a training problem: agents will always click the fastest path to calm an angry customer, so the freeze has to be enforced in the refund API itself, keyed on charge_id.

Performance & Scale

The two clocks have opposite performance needs: scoring is inline and latency-bound (never block checkout), dispute handling is low-volume but deadline-critical (durable queue), and velocity detection needs a fast counter store. The diagram shows the three.

Fraud scale profile Inline scoring stays within the latency budget, dispute handling is a durable queue job with retries, and velocity rules use Redis sorted sets off the Postgres hot path. Inline scoring <300ms added never block checkout Dispute queue durable + retries deadline-critical Velocity store Redis sorted sets off PG hot path
Three performance domains — inline scoring must never block checkout; velocity detection lives in Redis, not Postgres.

Scoring happens inline in the authorization path, so it must stay within the processor’s latency budget — treat anything over ~300ms added latency as a conversion risk and never block checkout on a non-critical enrichment call. Dispute handling is the opposite: it is low-volume but deadline-critical, so the evidence-assembly worker should be a durable queue job with retries, not a synchronous request. Index the manual review queue on a partial index (WHERE decision = 'review') so reviewers always read a small hot set. For the reserve, keep dispute ledger entries in the same partition as the originating charge to make reconciliation a single-shard query. Velocity rules (card fingerprint, IP, email) need a fast counter store — Redis sorted sets with sliding windows handle card-testing detection at thousands of auths per second without touching Postgres on the hot path.

The sliding-window implementation matters for both correctness and memory. A sorted set keyed on the fingerprint, with the auth timestamp as the score, lets you ZREMRANGEBYSCORE everything older than the window and ZCARD the remainder in two O(log n) operations — a fixed-count “N attempts per minute” rule with no bucket-boundary blind spot that a naive per-minute counter would have. Set a TTL on each key equal to the window so idle fingerprints evict themselves and the store’s memory tracks active traffic rather than lifetime cardinality. The one operational caveat is that this store is now on the authorization critical path, so it must fail open: if Redis is unreachable, the velocity check should log and allow rather than block, because a velocity store outage that blocks every checkout is a far worse incident than the card-testing burst it was meant to stop. The durable scoring and reserve logic downstream still runs; you have only degraded one enrichment signal.

On the dispute side, scale is measured in deadlines met, not throughput. Even a large SaaS business sees dispute volume in the low hundreds per day at most, so the worker never needs to be fast — it needs to never silently drop a job. Make the evidence-assembly job idempotent and retryable, and drive submission timing off the processor’s evidence_due_by rather than a fixed offset from dispute creation, because reason codes carry different windows and a hardcoded “submit after 14 days” will miss the short ones. A single missed deadline is an unrecoverable loss, so the queue’s dead-letter handling and alerting are the parts of this subsystem that most deserve paranoia; a job that fails twelve times and lands in a dead-letter queue with nobody watching is an automatic chargeback loss that looks, in the metrics, exactly like a dispute you chose not to fight.

Testing Strategy

The tests force each dispute terminal state via processor fixtures, prove reserve idempotency on replay, verify the deadline job timing with a mock clock, and assert the reserve-equals-open-disputes ledger invariant. The panel lists them.

Fraud/dispute tests Test fixtures drive won and lost, a replayed dispute yields one reserve entry, a mock clock verifies the 80 percent submission, and the reserve equals the sum of open disputes. Terminal states fixtures won / lost Replay dispute twice one reserve Mock clock 80% window submits Invariant reserve sum = open disputes
The reserve-equals-open-disputes invariant is the ledger tripwire that catches every double-count bug.

Use the processor’s test fixtures to force each terminal state deterministically: Stripe exposes test card numbers and a dispute.create test trigger so you can drive needs_response → under_review → won/lost without real money. Assert that a redelivered charge.dispute.created produces exactly one reserve entry (idempotency replay). Use a mock clock to verify the submission job fires at 80% of the evidence window. Forge a webhook with an invalid signature and assert it is rejected before any ledger write. Finally, run a reconciliation query in tests asserting SUM(dispute_reserve) equals the total of all open disputes — the ledger invariant that catches every double-count bug.

Testing the out-of-order and redelivered webhook

The idempotency replay test is necessary but not sufficient; the harder property is order-independence. Build a fixture that delivers the dispute webhooks in the wrong sequence — closed before the created your consumer expected, or an updated you never saw the predecessor of — and assert the local row still converges to the processor’s current state. This is the concrete test that forces your consumer to be reconciling rather than diff-applying: if it passes only when events arrive in order, it will corrupt state the first time the network redelivers, which in production is a matter of when, not if. Pair it with a signature-rejection test that forges a payload with a valid body but an invalid signature and asserts zero ledger writes occurred, because an attacker who can forge a charge.dispute.closed with status won could otherwise release your reserve on demand.

Also add an integration test that opens a dispute on a charge_id, then calls the refund API for that same charge and asserts the refund is rejected while the dispute is open. This is the one test that protects the most expensive edge case on the page, and it is easy to forget because the happy-path refund test passes fine — the freeze only exists in the narrow window where a dispute and a refund race, which no unit test of the refund handler in isolation will ever exercise. Extend it to assert that accepting the dispute does return the funds, so the test documents the correct alternative action.

Frequently Asked Questions

Is a chargeback the same as a dispute? Practically, yes, in the card-network sense: a cardholder disputes a charge with their issuing bank, which raises a chargeback against your acquirer. Your processor surfaces it as a dispute object with a status. A separate “inquiry” or “retrieval request” may precede a formal chargeback and does not always move funds — but most processors normalize these into the same dispute lifecycle.

When does 3DS2 actually shift chargeback liability to me versus the issuer? Only a fully authenticated 3DS2 result shifts fraud-related chargeback liability to the issuer. An attempted result (the issuer was offline or did not respond) typically gives you the same protection under scheme rules, but a frictionless flow with no authentication does not. Persist the exact outcome and never assume liability shifted just because you initiated 3DS2.

Should I fight every dispute? No. Fighting costs engineering and operational time and you pay a non-refundable dispute fee win or lose. Skip disputes you will almost certainly lose (clear friendly fraud with no usage evidence, or where you already issued a refund) and focus evidence effort where you have strong proof — authentication, delivery, or product usage tied to the cardholder.

How long do I have to submit evidence? The network deadline is typically 7–21 days depending on the card scheme and reason code, and your processor exposes the exact evidence_due_by timestamp on the dispute. Treat it as hard: schedule automated submission well before it, because a missed deadline is an automatic loss with no appeal.

What chargeback rate is dangerous, and how is it counted? The threshold that matters is the ratio of disputes to transactions over a monthly window, and the card networks run monitoring programs that trigger around 0.9% (Visa) to 1.5% (Mastercard), usually with an absolute floor of roughly 100 disputes in the month. The counting subtlety is that the numerator and denominator can come from different months depending on the scheme, so a slow month for sales after a fraud burst can spike the ratio even though the fraud is over. Instrument the ratio yourself against the same window your acquirer uses, alert well below the threshold, and treat any single-day spike in charge.dispute.created as an incident, because by the time the acquirer’s program notice arrives you are already weeks into the problem.

Should the fraud model score renewals, or only the first charge? Score both, but with different features. The first charge has almost no history, so it leans on device, IP, velocity, and 3DS2 signals. A renewal on a vaulted token has a rich history — months of successful payments, product usage tied to the customer_id, prior disputes — and a sudden anomaly there (a renewal from a new country, a re-vaulted card after a decline) is a stronger fraud signal than anything available at signup. Treating renewals as automatically safe because “they paid before” misses account-takeover and card-swap fraud, where the payment relationship was legitimate right up until it wasn’t.