Risk controls
Flint checks every card and ACH debit payment against your risk rules. A rule looks at facts about the payment, such as the amount, the buyer's email, or the card's country, and takes one of four actions: let the payment through, block it, require 3D Secure, or open a review for someone to approve or decline.
Every account starts with two rules already on: one blocks cards, emails, and IP addresses on Flint's default block lists, and one opens a review for payments the card processor rates as high risk. You add your own rules and lists on top of them through the API or the dashboard.
How Flint decides#
Flint checks rules twice for each payment attempt: once before the card is sent for authorization, and once after the result comes back.
- Your app sends confirm the payment intent, or pay the order to Flint
- Flint sends pre-authorization rules: allow, block, or require 3D Secure to Flint
- Flint sends authorization request, with 3D Secure when a rule requires it to Card processor
- Card processor returns approved or declined, plus the risk level from its fraud screening to Flint
- Flint sends post-authorization rules: allow or review to Flint
- Flint returns the payment result, then review.opened if a review rule matched to Your app
A blocked payment stops at the first check and never reaches the bank. Everything else is authorized as usual, and a review opens only on a payment the bank approved.
Which check a rule belongs to depends on the attributes it reads. Most attributes are known before authorization. risk_level and risk_score come from the card processor's fraud screening, so a rule that reads either one runs after authorization. That limits which actions it can take:
| Action | Attributes it can read | What happens when it decides |
|---|---|---|
allow | Any | Your block and review rules, and Flint's, no longer apply to this attempt. The bank can still decline it, and require_3ds rules still apply. |
block | Pre-authorization only | The attempt fails with payment_blocked before authorization. The buyer sees a generic decline and can try another payment method. |
require_3ds | Pre-authorization only | The bank must authenticate the buyer with 3D Secure. Skipped for off-session payments. |
review | Any | The payment proceeds, and Flint opens a review after the bank approves it. |
How rules combine#
Rules have no priority, and the order you create them in does not change the outcome. Flint resolves matches by action:
- Every matching
require_3dsrule adds a 3D Secure requirement. It never changes the allow, block, or review decision, and anallowdoes not remove it. - Before authorization, a matching
allowrule wins. The attempt is allowed, and post-authorization rules do not run. - Otherwise a matching
blockrule blocks the attempt. - Otherwise a matching
reviewrule marks the attempt for review. - After authorization, if nothing was allowed or blocked, a matching post-authorization
allowrule clears any pending review. Otherwise a matchingreviewrule, from either check, opens a review.
For example, an allow rule for customers on your trusted_customers list lets those customers pay even when their email is on default_block_emails, and skips the default review for high-risk payments. It would not skip a require_3ds rule that also matched.
When a rule matches, the payment intent's risk.matched_risk_rule_id names the rule that decided the result.
Each attempt keeps its own snapshot#
When an attempt starts, Flint records the rules in force, the payment details they read, and whether each value was on each list. The post-authorization check uses that same record. Editing a rule or a list afterward does not change an attempt already in progress. A retry is a new attempt and uses the rules and lists as they are then.
Which payments are checked#
| Payment | What applies |
|---|---|
| Card, Apple Pay, Google Pay | Every action. |
| ACH debit | allow and block. A review or require_3ds rule that matches an ACH debit fails the payment with RISK_ACTION_NOT_SUPPORTED_FOR_PAYMENT_OPTION. Add payment_method_type eq card to those rules. |
| Affirm | Not checked. |
| Off-session payments: subscription payments and automatic invoice charges | Every action except require_3ds, which is skipped because no buyer is present to authenticate. |
| Virtual terminal | A matching require_3ds rule fails the payment with RISK_ACTION_NOT_SUPPORTED_FOR_PAYMENT_FLOW, because the cardholder is not there to verify. Exclude virtual_terminal with payment_flow neq. |
Rules, lists, and reviews belong to one environment. Live and each sandbox have their own set.
Flint's default controls#
Every environment starts with these two rules:
Rule description | Action | Matches |
|---|---|---|
| Block payments matching Flint default block lists | block | The card fingerprint is on default_block_card_fingerprints, the email is on default_block_emails, or the IP address is on default_block_ip_addresses. |
| Review highest-risk payments and elevated-risk payments outside trusted recurring flows | review | risk_level is highest, or risk_level is elevated and payment_flow is not invoice or subscription_renewal. |
Both show up in GET /v1/risk-rules with origin: "system_default" and editable: false. You can turn either one off with PATCH /v1/risk-rules/{risk_rule_id} and {"enabled": false}. Any other change, or a delete, returns RULE_RESERVED.
The three default lists start empty. Add and remove items on them like any other list. Declining a review with add_to_block_list adds that payment's card fingerprint, email, and IP address to them. They cannot be archived.
Write a rule#
This example holds large guest card payments for review: $500 or more, with no customer attached.
Predicate grammar#
Each operator takes one operand field. Comparisons on amount_money use an amount_money operand in place of value. The registry lists the operators each attribute supports.
| Operator | Operand | Matches when |
|---|---|---|
eq, neq | value | The attribute equals, or does not equal, one literal. |
gt, gte, lt, lte | value | The attribute is greater or less than the operand. Only amount_money and risk_score support ordering. |
in | values, 1 to 100 literals | The attribute equals any of them. |
in_list | list_alias | The attribute's value is on the list. |
is_missing | None | The payment has no value for the attribute. |
all and any take 1 to 50 children, and not takes one. A predicate can have up to 100 nodes and nest 10 levels deep. Each node accepts only its own fields, so a typo such as "valeu" is rejected instead of ignored.
Attributes#
The registry is the authority for what your environment supports. The card attributes, email, email_domain, ip_address, and customer_id can be missing on any payment; see the note on missing values in step 2.
| Attribute | Type | Value |
|---|---|---|
amount_money | money | The payment amount and currency. |
currency | currency | The payment currency, such as USD. |
card_brand | enum | amex, discover, diners, jcb, mastercard, unionpay, visa, or unknown. |
card_funding | enum | credit, debit, prepaid, or unknown. |
card_country | country | The country that issued the card. |
card_bin | string | The card's first 6 to 8 digits. |
card_fingerprint | string | An identifier that stays the same for one card number across payments and customers. |
email | The receipt email, or the billing email when there is no receipt email. | |
email_domain | string | The part of email after the @. |
ip_address | ip_address | The buyer's IP address. |
ip_country | country | Listed in the registry but not available yet. |
customer_id | string | The Flint customer the payment belongs to. |
is_guest | boolean | true when no customer is attached. |
payment_method_type | enum | card for cards, Apple Pay, and Google Pay. |
digital_wallet | enum | apple_pay or google_pay. Missing for a card entered by hand. |
is_saved_payment_method | boolean | true when the payment uses a saved card. |
is_off_session | boolean | true for payments made without the buyer present. |
payment_flow | enum | Where the payment came from: checkout, payment_link, invoice, subscription_initial, subscription_renewal, virtual_terminal, or api. |
risk_level | enum | Post-authorization. The card processor's rating: normal, elevated, highest, or not_assessed. |
risk_score | integer | Post-authorization. Available only where the registry reports available: true. |
A payment intent's metadata is available too, as payment_intent_metadata. followed by the key, for example payment_intent_metadata.fulfillment_region. These are strings that support eq, neq, in, and is_missing. They are not listed in the registry.
Lists#
A list holds values a rule matches against, so you can change who is blocked or trusted without editing rules. A rule refers to a list by its alias.
Create the list:
curl -X POST https://api.withflintpay.com/v1/risk-lists \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: list-flagged-emails-001" \
-d '{
"name": "Flagged emails",
"alias": "flagged_emails",
"item_type": "email"
}'
Add values, one with value or up to 500 at once with values:
curl -X POST https://api.withflintpay.com/v1/risk-lists/rl_1kmn0aExample/items \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: list-flagged-emails-batch-001" \
-d '{"values": ["ada@example.com", "Grace@Example.com"]}'
{
"data": {
"items": [
{
"status": "created",
"risk_list_item": {
"risk_list_item_id": "rli_1kmn0aExample",
"risk_list_id": "rl_1kmn0aExample",
"value": "ada@example.com",
"created_by": "key_1kmn0aExample",
"created_at": "2026-09-24T16:20:03Z"
}
},
{
"status": "existing",
"risk_list_item": {
"risk_list_item_id": "rli_2kmn0aExample",
"risk_list_id": "rl_1kmn0aExample",
"value": "grace@example.com",
"created_by": "key_1kmn0aExample",
"created_at": "2026-09-20T09:12:44Z"
}
}
]
}
}
Values already on the list come back as existing, so a batch is safe to resend. The response is 201 when at least one value was added and 200 when none were. A batch is all or nothing: one invalid value fails the request with INVALID_RISK_LIST_ITEM, and param names it, for example values[3].
Then reference the list from a rule:
{"attribute": "email", "operator": "in_list", "list_alias": "flagged_emails"}
Flint normalizes list values when you add them and payment values when it looks them up, so Grace@Example.com on a list matches a payment from grace@example.com. The item type sets the normalization and which attributes the list can match:
item_type | Accepted values | Matches |
|---|---|---|
card_fingerprint | Fingerprints, compared exactly. | card_fingerprint |
card_bin | 6 to 8 digits. A BIN matches only a card with exactly that BIN, not a longer one that starts with it. | card_bin |
email | A bare address, without a display name. Lowercased. | email |
email_domain | A domain such as example.com. Lowercased, with internationalized domains converted to ASCII. | email_domain |
ip_address | An IPv4 or IPv6 address. | ip_address |
country | A two-letter ISO country code, in either case. | card_country |
customer_id | A cus_ ID. | customer_id |
string | Any text. Lowercased. | Any other attribute that supports in_list, such as card_brand or currency |
case_sensitive_string | Any text, compared as written. | Same as string |
Flint does not return card fingerprints in payment responses. default_block_card_fingerprints and your own fingerprint lists get their values when you decline a review with add_to_block_list.
Some list behavior to plan for:
- Aliases are permanent. An alias starts with a lowercase letter, then uses lowercase letters, digits, and underscores, up to 128 characters. It cannot be changed, and it stays reserved after the list is archived, so no new list in the environment can take it. Only
namecan be edited, withPATCH /v1/risk-lists/{risk_list_id}. - Remove a value with
DELETE /v1/risk-lists/{risk_list_id}/items/{risk_list_item_id}. Find its ID withGET /v1/risk-lists/{risk_list_id}/items. - Archive a list with
DELETE /v1/risk-lists/{risk_list_id}. While an enabled rule still names the alias, this returnsLIST_IN_USE, and the error's details list those rules'risk_rule_ids. Change or disable them first.
Change or turn off a rule#
PATCH /v1/risk-rules/{risk_rule_id} takes any of action, predicate, description, and enabled. Flint validates the rule as a whole and increases its version. To avoid overwriting a teammate's edit, send the version you last read as expected_version. If the rule changed since, the request fails with RISK_CONTROL_CONFLICT.
curl -X PATCH https://api.withflintpay.com/v1/risk-rules/rule_1kmn0aExample \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: rule-large-guest-review-disable-001" \
-d '{"enabled": false, "expected_version": 1}'
If an edit moves the rule to the other check, for example by adding risk_level to its predicate, the response includes a RULE_EVALUATION_STAGE_CHANGED warning in meta.warnings with the old and new stage.
- Turn a rule off with
"enabled": false. It stays inGET /v1/risk-rules, and you can turn it back on later. - Delete a rule with
DELETE /v1/risk-rules/{risk_rule_id}. This archives it for good: it stops running, and it appears only inGET /v1/risk-rules?include_archived=true. - An environment can have 100 enabled rules, counting Flint's defaults. Creating or re-enabling one more returns
RULE_LIMIT_EXCEEDED. Disabled rules do not count.
GET /v1/risk-rules returns the newest rules first. That order has no effect on which rule decides a payment.
Resolve reviews#
A review opens when a review rule decides a payment and the bank approves it. What the review controls depends on whether the payment was already captured:
Manual capture
The review holds the capture
The payment is authorized and waiting for capture. Capture returns PAYMENT_REVIEW_OPEN until the review is approved. Approving does not capture: call capture afterward. Declining cancels the authorization. If nobody resolves the review, Flint cancels the authorization about 5 minutes before authorization_expires_at and closes the review as expired.
Automatic capture
The payment already succeeded
The buyer saw a successful payment, and the order is paid. Approving closes the review. Declining refunds whatever has not been refunded yet, with reason fraudulent. If nobody resolves the review within 14 days, it closes as expired and the payment stands.
To hold funds until someone decides, create the payments your review rules are likely to match with capture_method: "manual". Manual capture covers authorizing now and capturing later.
Find open reviews#
Handle review.opened, or list the queue:
curl "https://api.withflintpay.com/v1/reviews?status=open" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": [
{
"review_id": "rev_1kmn0aExample",
"status": "open",
"pending_action": null,
"opened_reason": "rule",
"matched_risk_rule_id": "rule_1kmn0aExample",
"payment_intent_id": "pi_1kmn0aExample",
"order_id": "ord_1kmn0aExample",
"customer_id": null,
"payment_flow": "checkout",
"risk": {"level": "normal", "score": null},
"ip_address": "203.0.113.24",
"payment_summary": {
"amount_money": {"amount": 64900, "currency": "USD"},
"capture_method": "manual",
"authorization_expires_at": "2026-10-01T16:10:41Z",
"payment_method_brand": "visa",
"last4": "4242",
"email": "buyer@example.com"
},
"refund_id": null,
"refunded_amount_money": null,
"opened_at": "2026-09-24T16:10:42Z",
"closed_at": null,
"closed_by": null,
"closed_reason": null
}
]
}
Filter with status, risk_level, payment_flow, payment_intent_id, order_id, customer_id, and created_after. Reviews list newest first. GET /v1/reviews/{review_id} can also expand payment_intent, order, and customer.
Approve#
curl -X POST https://api.withflintpay.com/v1/reviews/rev_1kmn0aExample/approve \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: review-approve-001"
The response is the review with status: "closed" and closed_reason: "approved". For a manual-capture payment, capture it next, before authorization_expires_at. Approving a review that is already approved returns it unchanged.
Decline#
curl -X POST https://api.withflintpay.com/v1/reviews/rev_1kmn0aExample/decline \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: review-decline-001" \
-d '{"add_to_block_list": true}'
Declining cancels an uncaptured payment or refunds a captured one. When that finishes within the request, the response is 200 with the review closed. When it takes longer, usually because a refund is still processing, the response is 202 Accepted with status: "resolving", pending_action: "decline", and a Retry-After header. Read the review again after that many seconds, or wait for review.closed. If the refund fails, the review goes back to open so you can decline again or handle the refund yourself.
add_to_block_list defaults to false. When true, Flint adds the payment's card fingerprint, email, and IP address to the default block lists, so the same card, email, or address is blocked from the next attempt on. Values the payment did not have are skipped.
Review statuses#
- open moves to closed on approve, or payment event
- open moves to resolving on decline
- resolving moves to closed on cancel or refund done
- resolving moves to open on refund failed
status on reviewclosed_reason records how a review closed. A review also closes without your action when something else settles the payment:
| Reason | What happened |
|---|---|
approved | You approved it. |
declined | You declined it, and Flint canceled the uncaptured payment. |
refunded_as_fraud | You declined it, and Flint refunded the captured payment. refund_id and refunded_amount_money describe that refund. |
refunded | The payment was refunded in full some other way, or you declined a payment that was already fully refunded. |
payment_canceled | The payment was canceled. |
disputed | The buyer's bank opened a dispute on the payment. |
expired | Nobody resolved it in time. |
After a review closes, the other action returns REVIEW_ALREADY_CLOSED, with the closed_reason in the error details.
Respond to fraud warnings#
A fraud warning is the card issuer reporting that a payment was fraudulent, typically because the cardholder said they did not make it. It is separate from Flint's own risk decision: it can arrive days later, on any card payment, including one you approved in review. A dispute often follows.
List the warnings you can still act on:
curl "https://api.withflintpay.com/v1/fraud-warnings?actionable=true" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": [
{
"fraud_warning_id": "fw_1kmn0aExample",
"payment_intent_id": "pi_1kmn0aExample",
"fraud_type": "unauthorized_use_of_card",
"actionable": true,
"dispute_id": null,
"payment_summary": {
"amount_money": {"amount": 12900, "currency": "USD"},
"payment_method_brand": "visa",
"last4": "4242",
"email": "buyer@example.com"
},
"reported_at": "2026-09-24T08:31:07Z",
"created_at": "2026-09-24T08:31:09Z"
}
]
}
A warning is actionable until the payment is refunded in full, a dispute opens on it, or the card network marks it resolved. After that it stays unactionable. A partial refund does not change it. When a dispute opens, dispute_id links it.
For an actionable warning, stop fulfillment if the order has not shipped, then decide whether to refund. To refund the full payment, send its payment_intent_id with reason fraudulent:
curl -X POST https://api.withflintpay.com/v1/refunds \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: refund-fw-1kmn0a" \
-d '{
"payment_intent_id": "pi_1kmn0aExample",
"reason": "fraudulent"
}'
A full refund makes the warning unactionable and sends fraud_warning.updated. Read the payment first so you do not refund money another process already refunded. Refunds covers the request, and Handle disputes covers what happens if the dispute arrives anyway.
Read the risk result on a payment#
Every checked card payment intent has a risk object once Flint finishes checking it. It is null while the payment waits for 3D Secure or for a bank payment to settle, and for payment methods Flint does not check.
| Field | Meaning |
|---|---|
level | The card processor's rating: normal, elevated, highest, or not_assessed. A payment blocked before authorization is not_assessed. |
score | A numeric score, present only where risk scoring is available. Omitted otherwise, which is not the same as 0. |
outcome | authorized, manual_review, blocked, issuer_declined, or invalid. |
outcome_reason | Why, when there is a reason: risk_rule for one of your rules or Flint's defaults, risk_policy for the card processor's screening, or highest_risk, elevated_risk, or low_authorization_probability. |
matched_risk_rule_id | The rule that decided the result. |
review_id | The review opened for this payment. |
statement | A sentence describing the result, for your staff. |
evaluated_at | When the check finished. |
A blocked payment returns to requires_payment_method with last_payment_error.code set to payment_blocked. The card processor's own fraud screening can block a payment too, and that looks the same to the buyer and to your code. Tell the buyer the payment did not go through and offer another payment method. Do not tell them a fraud rule matched.
Webhooks#
Payloads are snapshots from when the event fired. Read the review or warning again before acting on it, and deduplicate deliveries by webhook_event_id. A review moving to resolving sends no event. Partner apps need the risk.read scope to receive these events.
Test your rules#
In a sandbox, scope a test rule to a metadata key so it affects only the payments you tag:
curl -X POST https://api.withflintpay.com/v1/risk-rules \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_SANDBOX_KEY" \
-H "Idempotency-Key: rule-test-block-001" \
-d '{
"action": "block",
"description": "Test: block payments tagged risk_test=block",
"predicate": {"attribute": "payment_intent_metadata.risk_test", "operator": "eq", "value": "block"}
}'
Create a card payment intent with "metadata": {"risk_test": "block"}, then confirm it with the test token pm_card_visa:
curl -X POST https://api.withflintpay.com/v1/payment-intents/pi_1kmn0aExample/confirm \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_SANDBOX_KEY" \
-H "Idempotency-Key: confirm-risk-test-001" \
-d '{"payment_source_token": "pm_card_visa"}'
The confirmation fails with 402 and PAYMENT_BLOCKED, and the payment intent's risk.matched_risk_rule_id is your rule. Change the action to review to open a review instead: with "capture_method": "manual" on the payment intent, you can then check that capture returns PAYMENT_REVIEW_OPEN until you approve. Delete test rules when you finish so they don't pile up toward the 100-rule limit.
These Stripe test cards exercise Flint's default controls in checkout:
| Card number | Result |
|---|---|
4000 0000 0000 9235 | Elevated risk. The default review rule opens a review, except on invoice and subscription renewal payments. |
4100 0000 0000 0019 | Highest risk, blocked by the card processor's screening. The attempt fails with payment_blocked. |
4000 0000 0000 5423 | The payment succeeds, then a fraud warning arrives. |
Testing covers sandbox setup and the other test cards.
Access#
API keys need these scopes. risk.controls.write and risk.reviews.write each include risk.read.
Every POST, PATCH, and DELETE on rules, lists, and reviews accepts an Idempotency-Key. Send one so a retried request does not create a second rule or resolve a review twice. See Idempotency.
Errors#
Validation errors#
Rule, preview, and list requests return 400 with type: "validation_error" and a param that points at the problem, such as $.all[0].attribute for a predicate or values[2] for a list item:
| Code | Cause |
|---|---|
UNKNOWN_ATTRIBUTE | The attribute is not in the registry. |
ATTRIBUTE_UNAVAILABLE | The attribute exists but is not available in this environment. |
INVALID_OPERATOR | The operator is not supported for the attribute. |
INVALID_OPERAND | The value has the wrong type, is not one of the attribute's enum values, or values is empty or longer than 100. |
INVALID_MONEY | amount_money needs an integer amount and an uppercase currency, and nothing else. |
INVALID_LIST_ALIAS | list_alias is not a valid alias. |
UNKNOWN_PREDICATE_FIELD | A node has a field it does not accept. |
INVALID_PREDICATE_NODE, INVALID_PREDICATE_GROUP | A node is not a valid comparison or group, or a group has no children or more than 50. |
INVALID_RULE_ACTION | action is not allow, block, review, or require_3ds. |
RULE_ACTION_UNAVAILABLE_AT_STAGE | A block or require_3ds rule reads risk_level or risk_score. |
INVALID_DESCRIPTION | description is empty or longer than 512 characters. |
RISK_LIST_ALIAS_NOT_FOUND, RISK_LIST_ARCHIVED, RISK_LIST_TYPE_MISMATCH | An enabled rule names a list that does not exist, is archived, or holds the wrong item type. |
INVALID_RISK_LIST_ALIAS, INVALID_RISK_LIST_ITEM_TYPE | The new list's alias or item type is invalid. |
INVALID_RISK_LIST_ITEM, INVALID_LIST_ITEM_COUNT | A value does not fit the list's item type, or values is empty or has more than 500 entries. |
Error handling covers the error envelope.
Common mistakes#
- Expecting a review to hold an automatic-capture payment. The payment has already succeeded, so fulfillment that runs on
payment_intent.succeededororder.paidships before anyone reviews it. Use manual capture, or wait forreview.closedbefore shipping. - Treating approval as capture. Approving a manual-capture review only allows the capture. Call capture yourself before the authorization expires.
- Writing
neqand expecting it to match missing values. A payment without the attribute never matches. Addis_missing. - Adding an
allowrule and expecting it to skip 3D Secure.require_3dsrules still apply. - Leaving a review rule open to ACH debits. A match fails the bank payment. Limit it with
payment_method_typeeqcard. - Writing emails or domains with capitals in a predicate. They never match. Use lowercase, or use a list, which normalizes both sides.
Next steps#
- Manual capture: hold funds while a review is open.
- Handle disputes: what to do when a fraud warning becomes a dispute.
- Testing: sandbox setup and test cards.
- Webhooks: verify and process
review.*andfraud_warning.*events. - Risk Controls API reference: every field on every route.
