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_scoreis null and you only getrisk_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
reviewoverblockfor ambiguous rules. - Allow rules override fraud signals. An
Allowrule beats the ML score, so a compromised trusted account sails through. Scope allow lists narrowly and excluderisk_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.