Integrating Stripe Radar for Payment Fraud Scoring

You reach for Radar when card-testing or stolen-card fraud starts showing up in your chargeback rate and hand-rolled velocity checks are not keeping up. Stripe Radar gives you a machine-learning risk score on every charge plus a rules engine you can extend, without standing up your own fraud model. This guide wires Radar into a checkout that already records risk decisions, as described in the parent guide Fraud Prevention & Dispute Management — Radar is the scoring engine that feeds the allow/block/review decision that page’s architecture depends on.

The decision context: you want to block obvious fraud, allow obvious good traffic frictionlessly, and send the ambiguous middle to a human review queue — all while keeping false positives low enough that you do not block paying customers.

Trade-offs

The fraud-scoring choice is Radar (fast, network-trained, Stripe-locked), a custom model (full control, months of work), or a third-party (cross-merchant network, sometimes a liability guarantee). The map contrasts them.

Fraud scoring options Stripe Radar is fast and network-trained but Stripe-locked, a custom model is full control at months of effort, and a third party offers a cross-merchant network and sometimes a guarantee. Stripe Radar hours to set up network-trained ML tunable rules already on Stripe Custom model months of work full control only your data unique patterns, huge volume Third-party cross-merchant net % of GMV liability guarantee? multi-processor
Radar is the fast default on Stripe; a third party earns its cut when you want a cross-merchant network or a chargeback guarantee.
Capability Stripe Radar Custom in-house model Third-party (Sift / Signifyd)
Setup time Hours (built into payment intents) Months (data pipeline + model) Weeks (integration + tuning)
Cost ~$0.05 per screened transaction (Radar for Fraud Teams) Eng salaries + infra % of GMV or per-decision fee, often higher
Network signal Strong — trained across all Stripe volume Only your own data Strong — cross-merchant network
Custom rules Yes, Radar rule language Full control Yes, vendor DSL
Chargeback guarantee No (you fight disputes) No Signifyd offers liability guarantee
Vendor lock-in High (tied to Stripe) None High
Best when Already on Stripe, want fast, tunable scoring Unique fraud patterns, huge volume Multi-processor, want guarantee

Run the cost math before you assume Radar for Fraud Teams is the obvious upgrade. At roughly $0.05 per screened transaction it is trivial on a $49 subscription checkout, but if you screen every authorization on a high-volume, low-ticket product — say $2 metered top-ups posted thousands of times an hour — the screening fee starts to rival the per-transaction margin. In that regime you either scope screening to first charges and card-on-file changes rather than every renewal, or you accept the base plan’s risk_level and build your own thresholds on top. The renewal case is worth calling out: a recurring subscription_id charging the same saved card that already cleared review three months running carries almost no incremental fraud risk, so paying to re-score it every cycle buys you little. Gate screening on signal that actually changed — a new payment method, a billing-country switch, a dormant account waking up — not on the calendar.

The network-signal column is the one engineers underrate. A first-in-house model sees only your own chargebacks, which for a young SaaS might be a few dozen labeled-fraud events a month — far too sparse to train anything that generalizes. Radar’s advantage is not a cleverer architecture; it is that the same card fingerprint, device, and IP that just tried three merchants in the last hour is visible to it and invisible to you. That is why a brand-new account attempting its first charge can still be scored highest on signals you have never observed. You give up that cross-merchant view the moment you leave Stripe, which is the real cost hidden in the lock-in row: the switching cost is not the API rewrite, it is re-accumulating months of labeled outcomes on a new engine before its scores are trustworthy.

When to escalate beyond Radar

Reach for a third party like Signifyd specifically when you want someone else to eat the chargeback. A liability guarantee turns fraud from a variable, spiky cost into a predictable percentage of GMV, which finance sometimes prefers even when the raw fraud rate is low, because it makes the P&L legible. The trade is that a guarantee provider optimizes for its own loss ratio, not your conversion, so it will decline marginal orders you would happily have taken. Custom models earn their months of work only past the point where your own fraud patterns are genuinely unlike the network’s — marketplaces with collusion between buyers and sellers, or products where the “fraud” is policy abuse (trial farming, referral gaming) that Radar was never trained to see.

Step-by-Step Implementation

The four steps read the risk score, add custom rules, route review-flagged charges to a queue, and capture decisions as labeled feedback. The rule-precedence diagram shows how block, review, and allow rules compose over the ML score.

Radar rule precedence Block rules win over review, review wins over allow, and allow overrides the ML score — feeding a final allow, block, or manual-review decision. ML score base Allow rules override Review rules beat allow Block rules win all Decision allow/block/review
Block beats review beats allow beats the ML score — precedence is why an over-broad allow rule can let a compromised account through.

1. Read the risk score off the charge

Radar scores every charge automatically. With Radar for Fraud Teams you get the numeric risk_score (0–99) and a risk_level; on the base plan you get the level only. Expand the charge outcome to read it.

import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

async function readRiskOutcome(paymentIntentId: string) {
  const pi = await stripe.paymentIntents.retrieve(paymentIntentId, {
    expand: ['latest_charge.outcome'],
  });
  const charge = pi.latest_charge as Stripe.Charge;
  const outcome = charge.outcome!;
  return {
    riskScore: outcome.risk_score ?? null,       // 0–99, Radar for Fraud Teams only
    riskLevel: outcome.risk_level,               // 'normal' | 'elevated' | 'highest'
    networkStatus: outcome.network_status,       // approved_by_network | declined_by_network
    sellerMessage: outcome.seller_message,
  };
}

2. Add custom Radar rules

Radar’s ML score handles the general case; custom rules encode your business-specific knowledge. Author these in the Dashboard or via the API. Block, review, and allow rules compose — block wins over review, review wins over allow.

# Block: card testing — many cards from one IP in a short window.
Block if :card_velocity_distinct_cards: > 3 and :ip_velocity: > 5

# Review: high-value first-time customer that is not authenticated.
Review if :amount_in_usd: > 500 and :is_3ds_authenticated: = false and :customer_age_days: < 1

# Allow: trusted returning customer overrides elevated ML score.
Allow if :customer: in @trusted_customers and :risk_level: != 'highest'

Maintain allow/block lists as Radar value lists so ops can update them without code deploys: @trusted_customers, @blocked_emails, @blocked_ip_ranges. Reference them in rules with the in @list operator.

Resist the urge to encode a threshold you cannot maintain. A rule like Block if :risk_score: > 75 looks precise but is brittle: Radar recalibrates its model periodically, and a score of 75 does not mean the same thing after a retrain as it did before. The risk_level buckets (normal, elevated, highest) are calibrated to hold their meaning across retrains, so prefer Review if :risk_level: = 'highest' for the general case and reserve raw-score comparisons for narrow, well-monitored rules you revisit. When you do use the numeric score, keep the customer identity handy — carry the customer_id and subscription_id into whatever store logs the decision, because “why was this charge blocked” is the first question every support escalation asks, and reconstructing it from the score alone is impossible after the fact.

Velocity variables deserve care because they are where card-testing rings actually get caught. The :card_velocity_distinct_cards: and :ip_velocity: counters look back over a rolling window, so a rule that fires on the fourth distinct card from one IP only catches the ring on its fourth attempt — the first three already cost you authorization fees and, if any succeeded, chargebacks. If card testing is your dominant threat, pair the Radar rule with a cheaper front-line control: a rate limit at the edge keyed on IP, or Turnstile on the payment form, so the attacker never reaches the authorization that increments the counter. Radar is the backstop, not the first wall.

3. Route review-flagged charges to a queue

When Radar flags a charge for review, the payment is authorized but funds should not be captured (or the product not fulfilled) until a human decides. Listen for review.opened and enqueue.

async function onReviewOpened(review: Stripe.Review) {
  await db.reviewQueue.insert({
    reviewId: review.id,
    chargeId: review.charge as string,
    reason: review.reason,                       // 'rule' | 'manual' | 'elevated_risk'
    openedAt: new Date(),
    status: 'pending',
  });
  // ⚠️ Do not fulfill while a review is open. Hold the order.
  await orders.hold(review.charge as string);
}

The order-hold step hides a subtlety worth spelling out. A review opens on an authorization that has not yet been captured, so you are holding funds the customer expects to have been charged. If your capture window is short — Stripe’s uncaptured authorizations expire after seven days — a review that sits in the queue past that window silently loses the auth, and now approving it does nothing because there is nothing left to capture. Store the charge_id and the authorization’s expiry alongside the queue row, and have the reconciliation job re-authorize or cancel rather than let a stale row imply money is still holdable. For subscription checkouts this interacts with your dunning logic: a first invoice held in review should not trip the invoice’s own retry schedule, or the customer gets a “payment failed” email for a charge that is merely awaiting a human.

Make the enqueue idempotent. Stripe can deliver review.opened more than once, and a naive insert produces duplicate queue rows that two reviewers then work in parallel — one approves while the other declines. Key the insert on review.id with an upsert, and treat the idempotency_key you already thread through order fulfillment as the join back to the order so a redelivered event resolves to the same held order rather than a second hold.

4. Capture decisions as labeled feedback

Every approve/decline a reviewer makes is training signal. Approving or declining a review in Stripe both resolves the charge and feeds Radar’s model. Record your own label too for analytics.

async function resolveReview(reviewId: string, decision: 'approve' | 'decline', actorId: string) {
  if (decision === 'approve') {
    await stripe.reviews.approve(reviewId);       // ✅ captures + labels legitimate
    await orders.fulfillByReview(reviewId);
  } else {
    // Declining refunds the charge and labels it fraudulent for the model.
    const review = await stripe.reviews.retrieve(reviewId);
    await stripe.refunds.create({ charge: review.charge as string, reason: 'fraudulent' }); // ✗ blocked
  }
  await db.reviewQueue.update(reviewId, { status: decision, resolvedBy: actorId, resolvedAt: new Date() });
}

Verification & Testing

The tests use Radar test cards to force each risk level deterministically, assert the review queue and order hold, verify approve/decline outcomes, and measure a new rule’s false-positive budget against history. The panel lists them.

Radar tests An elevated test card lands in the review queue and holds the order, approve fulfills and decline refunds as fraudulent, and the rule tester measures false positives on history. Elevated card 4000...4954 queue + hold Approve fulfills order labels good Decline refund fraudulent trains model Rule tester on history false-positive budget
Test a block rule against historical charges before enabling it — that hit count is your false-positive budget.

The point of the deterministic test cards is that they let you assert routing without a live model in the loop, which matters because the model’s real-world scores are non-deterministic and change under you. Treat the test cards as fixtures for your plumbing — does an elevated outcome reach the queue, does an approve capture, does a decline refund with the fraud reason — and treat the model’s accuracy as a separate, statistical question measured on production history, never asserted in a unit test.

Use Radar’s test mode and special test cards: 4000000000004954 triggers an elevated risk level and 4100000000000019 is always blocked as fraudulent, letting you assert your routing deterministically. Write a test that posts a charge with the elevated card and asserts a row lands in review_queue with status='pending' and the order is held. Assert that approving a review fulfills the order and declining it issues a refund with reason: 'fraudulent'. For custom rules, use the Dashboard rule tester against historical charges to measure how many legitimate past charges a new block rule would have caught — your false-positive budget. Reconcile weekly: every review_queue row should reach a terminal approve/decline, never stall in pending past your SLA.

Gotchas & Production Pitfalls

The pitfalls are plan limits (no numeric score on base), review latency, blunt block rules, allow-rules overriding fraud signals, and the refund reason that trains the model. The map groups them.

Radar pitfalls Numeric score needs Fraud Teams, held reviews add latency, block rules are blunt, allow rules override fraud signals, and the refund reason must be fraudulent to train the model. Plan limit no numeric score → Fraud Teams Review lag held = unfulfilled → tight SLA Blunt block kills conversion → test + prefer review Allow override compromised acct → scope narrow Refund reason plain refund → use fraudulent
Five pitfalls — the blunt block rule and the allow-rule override are the two that quietly cost the most revenue.
  • Numeric risk_score needs Radar for Fraud Teams. On the base plan risk_score is null and you only get risk_level. Do not build thresholds on a score you cannot read.
  • A held review is an unfulfilled paying customer. Reviews add latency to good orders. Set a tight ops SLA (e.g. resolve within 1 hour) and alert on queue age, or you convert fraud prevention into churn.
  • Block rules are blunt. An over-broad block rule silently kills conversion with no error the customer understands. Always test a rule’s historical hit count before enabling it, and prefer review over block for ambiguous rules.
  • Allow rules override fraud signals. An Allow rule beats the ML score, so a compromised trusted account sails through. Scope allow lists narrowly and exclude risk_level = highest.
  • Refund reason matters for the model. Declining a review with reason: 'fraudulent' trains Radar correctly; a plain refund does not. Use the fraud reason so your false negatives improve the model.

Measuring whether Radar is actually working

The trap after go-live is judging Radar by the fraud it blocked, which you can see, while ignoring the good customers it turned away, which you cannot. A block leaves no complaint in your inbox — the customer just leaves. Instrument both sides: log every block and review decision with the customer_id, the risk_level, and the order amount, then join blocks against the same customers’ later successful charges. A customer who was blocked on Monday and completed an identical purchase on Wednesday is a false positive that block rule cost you, and a rising count there is your signal to loosen. Track the review queue’s approve rate too — if reviewers approve 95% of what lands in the queue, the review rules are too broad and you are paying human time to rubber-stamp good orders; if they approve almost nothing, the rules may be duplicating what a block should have done outright.

Watch the chargeback rate as the outcome metric, but lag it correctly. Disputes arrive weeks after the charge, so a rule you shipped today shows up in the chargeback numbers a month or two out. Comparing this week’s chargebacks against this week’s rule changes will mislead you every time. Cohort the disputes by charge date, not dispute date, and only then read whether a rule bent the curve. Keep the whole decision log with monetary amounts in integer minor units so the analysis is exact — a blocked $500.00 order is 50000, and summing recovered-versus-lost revenue in cents avoids the rounding drift that creeps in when fraud analysts start dividing dollars in spreadsheets.

Frequently Asked Questions

Should rules be tuned before or after launch? After, on your own data. Fraud patterns are specific to the product, the price point, and the geography mix, and pre-launch rules are guesses that usually block legitimate customers.

What is an acceptable false-positive rate? Lower than most teams assume. Blocking one paying customer to prevent one fraudulent charge is a poor trade at subscription price points, because the blocked customer’s lifetime value exceeds the single fraudulent transaction.

Should scores be stored? Yes, alongside the outcome. The score’s value comes from correlating it with later disputes, and without stored scores you cannot tell whether a threshold change helped.

How do fraud rules interact with retries? A payment blocked by a rule should not enter the dunning ladder, since retrying will produce the same block. Treat a rule block as a distinct outcome requiring a different method or a manual review.