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

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

Step-by-Step Implementation

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.

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);
}

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

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

  • 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.