---
id: docs.risk
title: "Risk rules and reviews"
canonical: https://docs.secureprocessing.app/risk
description: "What we check before a payment reaches the bank, what we do not check, and how to change it."
last_verified: 2026-11-06
---

# Risk rules and reviews

Every card payment passes through your rules before we call the bank. A rule either refuses the payment or lets it through and asks you to look at it afterwards. This page is honest about what is checked and, just as importantly, what is not.

## What is checked

When somebody presses the pay button, and before anything is sent to the bank, we build a small set of facts about the attempt and run your rules against them. The facts are:

| Attribute | Type | What it is |
|---|---|---|
| amount_minor | number | The amount, in minor units. 200000 is BZ$2,000.00. |
| currency | text | bzd or usd. |
| email | text | The address collected at checkout, lowercased. Absent if none was. |
| email_domain | text | Everything after the last @, lowercased. |
| ip_country | text | The two-letter country the payer's IP resolves to. Absent when it cannot be determined, and absent for a Tor exit node — neither is a country, and a rule must not fire because geolocation had a bad minute. |
| velocity_email_1h | number | Payment attempts from this email in the last hour. |
| velocity_email_24h | number | Payment attempts from this email in the last day. |
| velocity_ip_1h | number | Payment attempts from this IP in the last hour. |
| velocity_ip_24h | number | Payment attempts from this IP in the last day. |
| attempts_email_24h | number | Payments from this email in the last day whose latest attempt was refused — a declined card, or a block by a rule. Card testing looks exactly like this. |

The velocity counts are of attempts that were *evaluated* — every press of the pay button, whether or not a card was ever entered. That is the number that matters: card testing is a hundred registrations and two successes.

## What is not checked

**No card data.** The card number is typed on a page hosted by the bank, so there is no card number in this system to test a rule against — not the number, not the BIN, not the issuing country. If a rule about the issuing country would be useful to you, tell us; it is not something we can offer today and we would rather say so than publish an attribute that silently never matches.

**No device fingerprinting.** We do not load a script into your checkout, we do not build a device graph, and we do not track a payer between merchants. The IP address and the email address are what we have.

**No machine-learned score.** Every decision is a rule you can read, written by you or published by us on this page. A payment that was refused was refused by a named rule, and the record says which.

> **Warning:** **The saved-card fingerprint has a hard limit.** A `card_fingerprint` block-list entry is a hash of the masked card number and expiry the bank reports *after* a payment. It stops that saved card being charged again. It cannot stop the same physical card being typed into a checkout page afresh, because at that moment there is nothing to compare — the number goes straight to the bank. It is also blunt: a masked number is six digits, a mask and four digits, so two different cards sharing those and an expiry look identical to it.

## Our defaults

Four rules apply to every business on Secure Processing, in test and live alike. They are evaluated before yours, they are listed in your dashboard, and you cannot edit or switch them off — an entry on your *allow* list is what lets a payment past one.

| Rule | Action |
|---|---|
| Five payment attempts from one email address within an hour | Refuses the payment |
| Three declined attempts from one email address within a day | Opens a review |
| Ten payment attempts from one IP address within a day | Opens a review |
| A payment of 500,000 minor units or more | Opens a review |

The only one that refuses a payment is the first, and it catches a pattern that is never a customer. The other three ask a person to look at money that has already arrived.

Precisely, for the first: the email is the payment’s `receipt_email`; failing that, the checkout session’s `customer_email` on a checkout page, or the customer record’s email when you charge a saved card with `confirm: true`. It is never anything typed on the bank’s page, which we never see. An attempt is one assessed payment: a press of Pay by card on a checkout page, payment link or invoice, card entered or not, or a saved-card charge — and every assessment is recorded, refused ones included. A payment is allowed only while there are fewer than five recorded attempts from that email in the last hour, per business and per mode. So the sixth in an hour is refused, and so is every press after it: each refusal is itself an attempt, so pressing again keeps the count at five or more. Stop, and it clears an hour after the attempts that filled it. A payment with no email has no email count and never trips it.

> **Warning:** **This applies in test mode.** A test that reuses one `receipt_email` is refused on its sixth run within the hour, with `payment_blocked`, and on every run after it until you leave it alone for an hour — re-running to see whether it has cleared is what keeps it blocked. Use a fresh address per run, leave the email off, or add your test address to your *allow* list while using a test key — the lists are per mode, so that entry never reaches live.

> **Note:** **That last one is 500,000 minor units in the payment’s own currency** — BZ$5,000 for a payment priced in BZD, and US$5,000 for one priced in USD. Amounts are compared as integers and are not converted first, so a USD payment reaches it at a lower Belize-dollar figure than a BZD one. We would rather say that than round it off.

## Writing a rule

A rule is a name, an action and a predicate. The predicate is an AND of one to eight conditions. There is no `any` and no nesting: an OR is two rules, which is also two rules you can switch off independently and two reasons on a review that say which one fired.

```bash
curl https://api.secureprocessing.app/v1/risk_rules \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Review large payments from a new address",
    "action": "review",
    "predicate": { "all": [
      { "attr": "amount_minor", "op": "gte", "value": 200000 },
      { "attr": "email_domain", "op": "not_in", "value": ["seascape.bz", "example.com"] }
    ] }
  }'
```

The operators, and what each one takes:

| Operator | Works on | Value |
|---|---|---|
| eq, neq | numbers and text | A number or a string of the attribute's own type. |
| gt, gte, lt, lte | numbers only | A number. Comparing text this way would collate, not compare. |
| contains, ends_with | text only | A string. Case is ignored on both sides. |
| in, not_in | numbers and text | An array of one to fifty values of the attribute's type. |

> **Note:** **Amounts are integers in minor units, here as everywhere.** 200000 is BZ$2,000.00. A rule written with 2000 meaning two thousand dollars would fire on every twenty-dollar payment.

`action` is `block` or `review`. There is no `allow` action: an allow-rule with a predicate would need an ordering nobody can reason about, and letting a specific payer past is what the allow list is for.

> **Warning:** **A rule’s predicate and action cannot be changed.** Every payment we assess records the id of the rule that decided it, so a rewritten predicate would make every one of those records describe a test that no longer exists. You can change a rule’s name, priority, metadata and whether it is enabled. To change what it tests, create a new rule and disable the old one.

## Lists

A list entry is an exact value rather than a test, and it is checked before any rule runs. An `allow` hit ends the assessment there and the payment proceeds — it beats every rule, including ours. A `block` hit refuses the payment.

```bash
curl https://api.secureprocessing.app/v1/risk_lists \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"list": "block", "kind": "email_domain", "value": "mailinator.com", "note": "Three chargebacks in March"}'
```

The kinds are `email`, `email_domain`, `ip` and `card_fingerprint`. Values are stored lowercased and trimmed, and comparisons ignore case on both sides.

`DELETE /v1/risk_lists/{id}` really deletes, which is unusual in this API — everywhere else a delete is a disable. A list is a live control surface, and unblocking somebody has to actually unblock them. What the entry did is kept in the risk evaluations it decided, which cannot be edited.

## Reviews

A `review` outcome does not hold the payment. The customer pays, the money lands in your balance, and a review opens for you to look at afterwards. Holding it would mean a card failing at a counter in front of the person who entered it, for a rule whose job is to notice a pattern rather than to be certain.

A review is opened only when the payment actually succeeds — an abandoned checkout never produces one — and there is exactly one per payment however many of our settlement paths reach it first.

There are two ways to close one, and both are final:

|   | What happens |
|---|---|
| `POST /v1/reviews/{id}/approve` | You keep the money. Nothing about the payment changes; the review closes as approved. |
| `POST /v1/reviews/{id}/refund` | The payment is refunded in full through the ordinary refund path — same ledger entries, same fee proration — and the review closes as refunded. If the bank refuses the refund, the review stays open and the error says why. A 202 means the refund was sent and the bank has not confirmed it yet: the refund is pending, the review stays open, and calling again is safe — it never refunds twice, and closes the review once the refund has settled. |

A refund made this way carries `origin: "review"`, so you can tell it apart from one you decided on the payment’s own page.

## When a payment is blocked

Nothing is sent to the bank. The payment intent moves to `requires_payment_method` with a `last_payment_error`, and `payment_intent.payment_failed` is emitted — the same event your handler already has a branch for. The API answers 402:

```json
{
  "error": {
    "type": "card_error",
    "code": "payment_blocked",
    "decline_code": "blocked",
    "message": "This payment can't be completed. Please contact the business."
  }
}
```

It is `payment_blocked` rather than `card_declined` on purpose. No card was declined — none was even entered — and telling a payer their card failed sends them to try another one, which is precisely what somebody testing stolen cards is hoping to be told to do.

> **Warning:** The message never says which rule fired, and neither does the hosted checkout page. The merchant can see the rule in their dashboard; the person at the page would only be learning what to change. Please keep that property if you build your own checkout.

## On the payment object

Every payment intent carries `risk_outcome` and, if a review was opened, `review`. You can filter the list with `/v1/payment_intents?risk_outcome=review`.

> **Note:** `risk_outcome: null` means *not assessed*, not *allowed*. A payment taken before this existed, or one nobody has tried to pay yet — no press of Pay by card, and no charge of a saved card — has no outcome. Treat null as unknown.

## Events

| Event | When |
|---|---|
| review.opened | A payment succeeded and a rule had asked for a human to look at it. |
| review.closed | Approved or refunded. \`status\` on the object says which. |
| risk_rule.created | A rule was created. |
| risk_rule.updated | A rule was renamed, switched on or off, reprioritised or given new metadata. Never its test. |
| risk_list_item.created | Something was added to your allow or block list. |
| risk_list_item.removed | A list entry was deleted. |

A blocked payment does not emit an event of its own: it emits `payment_intent.payment_failed`, like every other payment that did not happen, and `last_payment_error.code` is what tells them apart.

## Who can change what

Writing a rule, editing a list and approving a flagged payment need the owner or an admin role. Refunding one needs the same role as any other refund, which support has. The separation is deliberate: someone who can close one review should not also be able to quietly loosen the rule that opened it.
