Portreeve
how-to · 7 Sept 202613 min read

Writing Stripe Radar rules for a small SaaS

Stripe Radar rules syntax, evaluation order, and the traps that make a rule match nothing, with paste-ready block and review rules for a SaaS product.

By the end of this you will have a working set of Stripe Radar rules in your account: two block rules aimed at a card-testing ladder, three review rules that route odd payments to a queue instead of a decline, and a Request 3DS pair that does not break Apple Pay or your renewals. You will also know why the rule you wrote last Tuesday has matched zero payments since.

What you need before the first rule:

  • A Radar plan with custom rules. Radar now comes in four tiers, and Stripe moved Radar for Fraud Teams accounts onto Radar Plus on July 29, 2026, with the new pricing taking effect January 22, 2027. Writing and backtesting custom rules starts at Plus.
  • Owner, administrator, or developer access. Stripe restricts rule creation to those three roles.
  • Live payment history. The Test rule step backtests against your last six months of charges, and two weeks of data tells you almost nothing.
  • The Stripe CLI, for the verification step at the end.

Rules live at dashboard.stripe.com/radar/rules. A sandbox gets its own separate set at /test/radar/rules.

Rule syntax, and the order Radar runs your rules

A rule is {action} if {condition}, and a basic condition is [attribute] [operator] [value]:

Block if :amount_in_usd: > 1000.00

There are four transaction actions, and Radar evaluates them by type in a fixed priority: Request 3DS first, then Allow, then Block, then Review. Rules of the same action type are not ordered against each other. A Request 3DS match stops nothing, since allow, block, and review still run after it. A match on any of those three ends evaluation there.

Read that order again, because it is where most first-week rule sets break. An allow rule is evaluated before every block rule you own, including Stripe's built-in Block if :risk_level: = 'highest'. The worked example set in Stripe's reference opens with an allow rule for payments under 10 USD, and the docs spell out the consequence: those payments process "regardless of their risk level or issuing location". For a subscription product, that is every $1 card-testing charge, unblockable.

Stripe's guidance is blunt. Implement allow rules minimally, because they override the Stripe default rules along with any other custom rule matching the same criteria. If you write one, bound it the way the docs do:

Allow if :ip_address: = '203.0.113.10' and :risk_level: != 'highest'

Allow rules are also off until you ask for them. You have to contact Stripe to enable them on your account, which is a reasonable piece of friction.

Attributes, operators, and the trap that makes rules match nothing

Attributes are wrapped in colons: :email_domain:, :card_funding:, :is_off_session:. Your own payment metadata gets double colons, ::product_sku::, and metadata on the linked Customer uses ::customer:trusted::. Metadata is case-sensitive when compared as a string, so the key and the value both have to match what you sent.

The attribute type decides which operators are legal. Strings and countries take =, !=, IN, INCLUDES, and LIKE. Numerics add <, >, <=, >=. Booleans take no operator and no value at all, so Block if :is_disposable_email: is valid and :is_anonymous_ip: = 'true' is listed as an invalid condition.

Now the trap. Any comparison against a missing attribute returns false, and that includes != and anything wrapped in NOT. Stripe's own worked example is Review if :email_domain: != 'definitelysafe.com', which does not match a payment with no email, because Radar reads != as "the attribute has some value other than this."

So the rule you wrote to catch everyone outside your team quietly skips exactly the payments that carry no email. Say the missing case out loud:

Review if is_missing(:email_domain:) OR :email_domain: != 'yourcompany.com'

Booleans are the exception. They are false rather than missing when the attribute does not apply.

This is also why IP rules go dead on server-side integrations. Radar picks up :ip_address: and :ip_country: from the browser through Stripe.js, Elements, or Checkout, and the docs list "you've only recently started using Stripe.js" as a cause of a missing :ip_country:. Confirm PaymentIntents from your backend with no client-side Stripe.js in the flow, and every IP-keyed condition in your rule set is comparing against nothing.

One more piece of arithmetic. Velocity counters exclude the payment being processed. The first charge from an email in a given hour sees total_charges_per_email_hourly at 0 and the second sees 1, so a rule written > 1 fires on the third attempt, not the second.

Charges versus transactions, and the toggle you have to flip

Stripe's Radar rules 101 guide gives this as the card-testing rule to write:

Block if :total_charges_per_ip_address_hourly: > 1

For a subscription product that saves a card at trial signup with a SetupIntent and charges nothing for 14 days, that rule counts zero. The _charges_ family counts card charges. The _transactions_ family counts charges, other supported payment method attempts, and SetupIntents. An attacker walking a stolen BIN through your card-on-file endpoint produces a long ladder of SetupIntent confirmations and not one charge.

AttributeCountsCap
total_charges_per_ip_address_hourlycard charges onlynone
total_transactions_per_ip_address_hourlycharges, other payment method attempts, SetupIntentsnone
card_count_for_ip_address_transactions_hourlydistinct cards per IP25
email_count_for_ip_transactions_dailydistinct emails per IP25
blocked_transactions_per_payment_instrument_fingerprint_hourlyprior blocks on that card, ACH, or SEPA instrumentnone

Two consequences. Bounded attributes stop incrementing at their cap, so card_count_for_ip_address_transactions_hourly > 30 can never be true and the rule is dead on arrival. And the _per_payment_instrument_fingerprint_ counters span cards, ACH, and SEPA, which is the family to reach for if you take more than cards.

Then there is the toggle. Radar does not evaluate rules on SetupIntents until you turn on "Use Radar on payment methods saved for future use" in Radar settings, and even then SetupIntents support Request 3DS, allow, and block rules but not review. You are billed per SetupIntent confirmation attempt once it is on. Turn it on before you write anything below, or the counters keep ticking and nothing acts on them.

"Hourly" is generous too. Stripe computes hourly windows in five-minute buckets, so an hourly attribute covers up to 3,900 seconds, an hour and five minutes.

1. Block the card-testing ladder with two rules

Start any rule you are unsure about as a review rule. Stripe says the same in fewer words: if you are not sure how to apply a block rule, place the payments in review first.

The first block rule is the SetupIntent-aware version of the card-testing rule, loosened because a shared office IP will legitimately produce a handful of attempts:

Block if :total_transactions_per_ip_address_hourly: > 4
  and :card_count_for_ip_address_transactions_hourly: > 2

Three or more distinct cards and five or more attempts from one IP inside an hour is not a confused customer. The second rule needs no tuning, because the instrument has already been judged:

Block if :blocked_transactions_per_payment_instrument_fingerprint_hourly: > 1

Both are keyed on the IP or the card, and the IP half is the weaker one. At the peak of the 2022 wave, Stripe reported blocking more than 20 million card testing attempts per day, with attempts up more than 100x since 2019, and small accounts get hit by the same infrastructure. On Hacker News, mattferderer described what it looked like at a nonprofit: "We were attacked by someone using different IP addresses testing out credit cards. Stripe caught a lot of these but surprisingly it seemed several went through that we had to refund & pay the transaction fee for."

Different IP addresses. A per-IP counter is the first control an attacker with a proxy pool routes around.

2. Send the ambiguous shapes to review instead of a decline

Disposable email on its own is a weak signal and a fine review trigger:

Review if :is_disposable_email:

A card never seen on your account, presented from a known proxy or Tor exit node:

Review if :is_anonymous_ip: and is_missing(:seconds_since_card_first_seen:)

And many distinct emails behind one IP, which is the signature of a multi-account run and also the signature of a corporate NAT, which is precisely why it reviews rather than blocks:

Review if :email_count_for_ip_transactions_daily: > 4

Then read the metrics. Stripe's rule table says to convert a review rule into a block rule when refunds, disputes, and early fraud warnings run high among the matches, and to remove it when most matches come back approved.

Two rules people paste that you should not

Block if :card_country: != :ip_country: turns up in every forum thread about Radar, and the reference page uses that predicate as its example of comparing one attribute against another. As a block rule for software it is a false-positive machine. Your customers use VPNs, travel, and hold cards issued somewhere other than where they live. Stripe's own fix for a broad country block is to gate it on risk, which works here too:

Review if :card_country: != :ip_country: and :risk_level: = 'elevated'

Block if :card_funding: = 'prepaid' is the other one. Stripe's docs use it as the example of a rule that is too broad and floods your queue, and improve it by pairing it with :is_disposable_email:. For a SaaS, prepaid cards mostly identify privacy-conscious buyers and people without a bank-issued credit card.

3. Request 3DS without losing Apple Pay or your renewals

Requesting 3DS does not by itself stop a payment where 3DS is not supported. That is why Stripe documents 3DS as a pair: one rule to request it, one to block what came back without an authentication flow.

Request 3D Secure if :risk_level: != 'normal' and :amount_in_usd: > 25
Block if not :is_3d_secure: and :risk_level: != 'normal' and :amount_in_usd: > 25
  and not :is_off_session:
  and :digital_wallet: != 'apple_pay'
  and not (:digital_wallet: = 'android_pay' and :has_cryptogram:)

Every clause after the amount is an exemption you want. not :is_off_session: keeps your subscription renewals alive, since an unattended renewal cannot present a challenge screen. The two wallet clauses keep Apple Pay and cryptogram-bearing Google Pay from being blocked for skipping a 3DS flow they never needed.

Use :is_3d_secure: in block rules and :is_3d_secure_authenticated: in review rules. Stripe is explicit that the authenticated variant in a block rule excludes legitimate transactions with an SCA exemption or an ambiguous outcome like attempt_acknowledged. Those belong in a queue, not in a decline.

4. Move your own data into lists and metadata

Create a list in the Dashboard, let Stripe generate the alias, and reference it with @:

Block if :email: in @confirmed_abusers

Custom lists are typed: string, card fingerprint, card BIN, customer ID, email, IP address, country, and the ACH and SEPA Direct Debit fingerprints. Each holds up to 50,000 items. One list beats forty near-identical rules, which matters against the cap of 200 transaction rules per account.

The list you get for free: refunding a payment as fraudulent, or setting fraud_details.user_report to fraudulent through the API, adds that card fingerprint and any associated email address to your default block lists. If your support team refunds fraud with the plain refund reason, you are throwing that away.

Metadata closes the loop from your own database. Set trusted on the Customer object for accounts you have manually verified, then:

Allow if ::customer:trusted:: = 'true' and :risk_level: != 'highest'

The trailing risk clause is not optional. Allow rules run first, so a bare Allow if ::customer:trusted:: = 'true' turns one compromised account into a permanent bypass of your entire rule set.

5. Backtest the custom rule, then watch it fire

Click Test rule before Add rule. Stripe runs the predicate against your last six months of charges and buckets the matches into disputes and early fraud warnings, refunded payments, blocked and failed payments, and successes. A block rule worth keeping matches far more of the first two buckets than the last.

The backtest searches historical live mode payments even when you write the rule in a sandbox. A sandbox gives you an isolated rule set, not isolated backtest data.

To watch a rule fire, create it in the sandbox and send a payment with one of Stripe's Radar test cards: 4000000000009235 produces a risk level of elevated, 4000000000004954 produces highest, and 4100000000000019 is blocked regardless of your rules.

stripe payment_intents create \
  --amount=2500 \
  --currency=usd \
  --payment-method-types=card \
  --payment-method=pm_card_riskLevelElevated \
  --confirm

Open the resulting payment in the Dashboard. The Radar section names the rule that acted and shows the evaluated risk. If it names a rule you did not expect, that is your evaluation order at work.

Then watch the rule performance chart for a week. A review rule's match count and your queue count will not agree, and that is correct: only successful payments enter the review queue, so matches the issuer declined never show up there. The rule activity log keeps changes for 180 days, so write the reason a rule exists into the rule name.

What Radar rules cannot see

Radar rules run when a PaymentIntent, SetupIntent, or Charge exists. Not before. Every attribute is keyed on the IP, email, card, or Customer attached to that object, and none of them are keyed on the browser or the device.

So the free-tier signup with no payment method is invisible to every rule in this post. So is the fifth account created from one browser with five Gmail dot-trick addresses. So is the credential-stuffing run against your login endpoint. By the time a card appears, the accounts already exist, and the accounts are what you need to revoke.

This is the gap I built Portreeve for. One API call at signup, trial_start, trial_convert, checkout_attempt, or login returns allow, review, or block with reason codes; velocity counters are kept per device and per linked cluster rather than only per IP, and the identity graph links accounts across hashed email, device fingerprint, and card fingerprint, with IP as a soft signal that never links accounts on its own. It covers the moments before a card exists. The Radar rules above still belong on the charge itself, and what you do with each verdict is a separate decision from what Radar does with the payment.

If the accounts, trials, and logins in front of the payment are where your problem actually lives, Portreeve's free tier screens 1,000 events a month with no card: start here.

← Back to all posts