Skip to content
On this page

Developers › Payments

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.

Verified 6 November 2026Explain this to your team →

View as Markdown

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:

AttributeTypeWhat it is
amount_minornumberThe amount, in minor units. 200000 is BZ$2,000.00.
currencytextbzd or usd.
emailtextThe address collected at checkout, lowercased. Absent if none was.
email_domaintextEverything after the last @, lowercased.
ip_countrytextThe 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_1hnumberPayment attempts from this email in the last hour.
velocity_email_24hnumberPayment attempts from this email in the last day.
velocity_ip_1hnumberPayment attempts from this IP in the last hour.
velocity_ip_24hnumberPayment attempts from this IP in the last day.
attempts_email_24hnumberPayments 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.

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.

RuleAction
Five payment attempts from one email address within an hourRefuses the payment
Three declined attempts from one email address within a dayOpens a review
Ten payment attempts from one IP address within a dayOpens a review
A payment of 500,000 minor units or moreOpens 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.

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

OperatorWorks onValue
eq, neqnumbers and textA number or a string of the attribute's own type.
gt, gte, lt, ltenumbers onlyA number. Comparing text this way would collate, not compare.
contains, ends_withtext onlyA string. Case is ignored on both sides.
in, not_innumbers and textAn array of one to fifty values of the attribute's type.
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.

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}/approveYou keep the money. Nothing about the payment changes; the review closes as approved.
POST /v1/reviews/{id}/refundThe 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.

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.

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

EventWhen
review.openedA payment succeeded and a rule had asked for a human to look at it.
review.closedApproved or refunded. `status` on the object says which.
risk_rule.createdA rule was created.
risk_rule.updatedA rule was renamed, switched on or off, reprioritised or given new metadata. Never its test.
risk_list_item.createdSomething was added to your allow or block list.
risk_list_item.removedA 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.