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.
Prerequisites
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.
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.
Implementation Walkthrough
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';
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
}
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
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
| 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 |
Performance & Scale
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.
Testing Strategy
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.
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.