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.

Where risk rules run in a card paymentResponse
Your appFlintCard processorconfirm the payment intent, or pay the orderpre-authorization rules: allow, block, or require 3D Secureauthorization request, with 3D Secure when a rule requires itapproved or declined, plus the risk level from its fraud screeningpost-authorization rules: allow or reviewthe payment result, then review.opened if a review rule matched
  1. Your app sends confirm the payment intent, or pay the order to Flint
  2. Flint sends pre-authorization rules: allow, block, or require 3D Secure to Flint
  3. Flint sends authorization request, with 3D Secure when a rule requires it to Card processor
  4. Card processor returns approved or declined, plus the risk level from its fraud screening to Flint
  5. Flint sends post-authorization rules: allow or review to Flint
  6. 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:

ActionAttributes it can readWhat happens when it decides
allowAnyYour 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.
blockPre-authorization onlyThe attempt fails with payment_blocked before authorization. The buyer sees a generic decline and can try another payment method.
require_3dsPre-authorization onlyThe bank must authenticate the buyer with 3D Secure. Skipped for off-session payments.
reviewAnyThe 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:

  1. Every matching require_3ds rule adds a 3D Secure requirement. It never changes the allow, block, or review decision, and an allow does not remove it.
  2. Before authorization, a matching allow rule wins. The attempt is allowed, and post-authorization rules do not run.
  3. Otherwise a matching block rule blocks the attempt.
  4. Otherwise a matching review rule marks the attempt for review.
  5. After authorization, if nothing was allowed or blocked, a matching post-authorization allow rule clears any pending review. Otherwise a matching review rule, 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#

PaymentWhat applies
Card, Apple Pay, Google PayEvery action.
ACH debitallow 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.
AffirmNot checked.
Off-session payments: subscription payments and automatic invoice chargesEvery action except require_3ds, which is skipped because no buyer is present to authenticate.
Virtual terminalA 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 descriptionActionMatches
Block payments matching Flint default block listsblockThe 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 flowsreviewrisk_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.

  1. Read the attribute registry#

    The registry lists every attribute your environment can use, with its type, operators, and when it becomes available:

    cURL
    curl https://api.withflintpay.com/v1/risk-rules/attributes \
      -H "Authorization: Bearer YOUR_API_KEY"
    
    Response
    {
      "data": {
        "version": "1",
        "attributes": [
          {
            "name": "amount_money",
            "value_type": "money",
            "operators": ["eq", "neq", "gt", "gte", "lt", "lte"],
            "nullable": false,
            "missing_value_behavior": "not_applicable_except_is_missing",
            "available_from": "pre_authorization",
            "available": true
          },
          {
            "name": "risk_score",
            "value_type": "integer",
            "operators": ["eq", "neq", "gt", "gte", "lt", "lte", "in", "is_missing"],
            "nullable": true,
            "missing_value_behavior": "not_applicable_except_is_missing",
            "available_from": "post_authorization",
            "available": false,
            "unavailable_reason": "risk_scoring_not_enabled"
          }
        ]
      }
    }
    

    A rule can only use attributes with available: true. Using an unavailable one returns ATTRIBUTE_UNAVAILABLE. Attributes describes each one.

  2. Write the predicate#

    A predicate is a comparison, or a group of predicates:

    JSON
    {
      "all": [
        {"attribute": "amount_money", "operator": "gte", "amount_money": {"amount": 50000, "currency": "USD"}},
        {"attribute": "is_guest", "operator": "eq", "value": true},
        {"attribute": "payment_method_type", "operator": "eq", "value": "card"}
      ]
    }
    

    A comparison names an attribute, an operator, and the operand that operator takes, here amount_money or value. Groups combine predicates: all matches when every child matches, any when at least one does, and not inverts one child. Predicate grammar lists every operator and limit.

    Keep these rules in mind when writing comparisons:

    • A missing value never matches. When a payment has no value for an attribute, every comparison on it is skipped, including neq and a not around it. {"attribute": "card_country", "operator": "neq", "value": "US"} does not match an ACH debit, which has no card. To match missing values too, pair the comparison with is_missing, as in the example after this list.
    • Amounts compare in one currency. amount_money takes an integer in the currency's smallest unit and an uppercase currency code. A USD threshold never matches a CAD payment, in either direction. Write one comparison per currency you accept.
    • Literals are exact. Flint lowercases email and email_domain before comparing, so write those literals in lowercase. Countries and currencies are uppercase ISO codes, such as "US" and "USD". Booleans are JSON true and false, not strings.

    This predicate matches cards issued outside the US, and payments with no card country:

    JSON
    {
      "any": [
        {"attribute": "card_country", "operator": "neq", "value": "US"},
        {"attribute": "card_country", "operator": "is_missing"}
      ]
    }
    
  3. Validate it#

    POST /v1/risk-previews checks a predicate and action without saving anything:

    cURL
    curl -X POST https://api.withflintpay.com/v1/risk-previews \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -d '{
        "action": "review",
        "predicate": {
          "all": [
            {"attribute": "amount_money", "operator": "gte", "amount_money": {"amount": 50000, "currency": "USD"}},
            {"attribute": "is_guest", "operator": "eq", "value": true},
            {"attribute": "payment_method_type", "operator": "eq", "value": "card"}
          ]
        }
      }'
    
    Response
    {
      "data": {
        "analysis": {
          "stage": "pre_authorization",
          "attributes_used": ["amount_money", "is_guest", "payment_method_type"],
          "applicability": "all_payments"
        },
        "warnings": []
      }
    }
    

    stage is the check the rule runs in. applicability is on_session_only for require_3ds rules and all_payments for the rest. An invalid predicate returns a 400 whose param is the JSON path of the first problem, such as $.all[1].operator. Validation errors lists the codes.

    A preview does not check that the lists a predicate names exist. Creating the rule does. To check an edit to a saved rule, send its risk_rule_id along with the new action or predicate.

  4. Create the rule#

    Send the same action and predicate with a description of 1 to 512 characters. Rules are enabled unless you send "enabled": false.

    cURL
    curl -X POST https://api.withflintpay.com/v1/risk-rules \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Idempotency-Key: rule-large-guest-review-001" \
      -d '{
        "action": "review",
        "description": "Review guest card payments of $500 or more",
        "predicate": {
          "all": [
            {"attribute": "amount_money", "operator": "gte", "amount_money": {"amount": 50000, "currency": "USD"}},
            {"attribute": "is_guest", "operator": "eq", "value": true},
            {"attribute": "payment_method_type", "operator": "eq", "value": "card"}
          ]
        }
      }'
    
    Response
    {
      "data": {
        "risk_rule_id": "rule_1kmn0aExample",
        "action": "review",
        "predicate": {
          "all": [
            {"attribute": "amount_money", "operator": "gte", "amount_money": {"amount": 50000, "currency": "USD"}},
            {"attribute": "is_guest", "operator": "eq", "value": true},
            {"attribute": "payment_method_type", "operator": "eq", "value": "card"}
          ]
        },
        "attributes_used": ["amount_money", "is_guest", "payment_method_type"],
        "stage": "pre_authorization",
        "applicability": "all_payments",
        "origin": "merchant",
        "editable": true,
        "version": 1,
        "description": "Review guest card payments of $500 or more",
        "enabled": true,
        "created_by": "key_1kmn0aExample",
        "created_at": "2026-09-24T16:02:11Z",
        "updated_at": "2026-09-24T16:02:11Z",
        "archived_at": null
      }
    }
    

    The rule applies from the next payment attempt. An enabled rule that names a list fails with RISK_LIST_ALIAS_NOT_FOUND if the list does not exist, RISK_LIST_ARCHIVED if it was archived, or RISK_LIST_TYPE_MISMATCH if the list's item type does not fit the attribute.

  5. See it match#

    When the rule decides a payment, the payment intent shows it:

    JSON
    {
      "data": {
        "payment_intent_id": "pi_1kmn0aExample",
        "status": "succeeded",
        "risk": {
          "level": "normal",
          "outcome": "manual_review",
          "outcome_reason": "risk_rule",
          "matched_risk_rule_id": "rule_1kmn0aExample",
          "review_id": "rev_1kmn0aExample",
          "evaluated_at": "2026-09-24T16:10:42Z"
        }
      }
    }
    

    outcome is manual_review because a review opened, and your webhook endpoint receives review.opened for rev_1kmn0aExample. Test your rules shows how to trigger a rule in a sandbox without affecting other test payments.

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.

OperatorOperandMatches when
eq, neqvalueThe attribute equals, or does not equal, one literal.
gt, gte, lt, ltevalueThe attribute is greater or less than the operand. Only amount_money and risk_score support ordering.
invalues, 1 to 100 literalsThe attribute equals any of them.
in_listlist_aliasThe attribute's value is on the list.
is_missingNoneThe 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.

AttributeTypeValue
amount_moneymoneyThe payment amount and currency.
currencycurrencyThe payment currency, such as USD.
card_brandenumamex, discover, diners, jcb, mastercard, unionpay, visa, or unknown.
card_fundingenumcredit, debit, prepaid, or unknown.
card_countrycountryThe country that issued the card.
card_binstringThe card's first 6 to 8 digits.
card_fingerprintstringAn identifier that stays the same for one card number across payments and customers.
emailemailThe receipt email, or the billing email when there is no receipt email.
email_domainstringThe part of email after the @.
ip_addressip_addressThe buyer's IP address.
ip_countrycountryListed in the registry but not available yet.
customer_idstringThe Flint customer the payment belongs to.
is_guestbooleantrue when no customer is attached.
payment_method_typeenumcard for cards, Apple Pay, and Google Pay.
digital_walletenumapple_pay or google_pay. Missing for a card entered by hand.
is_saved_payment_methodbooleantrue when the payment uses a saved card.
is_off_sessionbooleantrue for payments made without the buyer present.
payment_flowenumWhere the payment came from: checkout, payment_link, invoice, subscription_initial, subscription_renewal, virtual_terminal, or api.
risk_levelenumPost-authorization. The card processor's rating: normal, elevated, highest, or not_assessed.
risk_scoreintegerPost-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
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
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"]}'
Response
{
  "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:

JSON
{"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_typeAccepted valuesMatches
card_fingerprintFingerprints, compared exactly.card_fingerprint
card_bin6 to 8 digits. A BIN matches only a card with exactly that BIN, not a longer one that starts with it.card_bin
emailA bare address, without a display name. Lowercased.email
email_domainA domain such as example.com. Lowercased, with internationalized domains converted to ASCII.email_domain
ip_addressAn IPv4 or IPv6 address.ip_address
countryA two-letter ISO country code, in either case.card_country
customer_idA cus_ ID.customer_id
stringAny text. Lowercased.Any other attribute that supports in_list, such as card_brand or currency
case_sensitive_stringAny 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 name can be edited, with PATCH /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 with GET /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 returns LIST_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
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 in GET /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 in GET /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
curl "https://api.withflintpay.com/v1/reviews?status=open" \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
{
  "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
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
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#

Review status lifecycleFinal
  • 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 review
Final values do not change again
  • open
    Waiting for a decision. Approve or decline it.
  • resolving
    A decline is canceling or refunding the payment. pending_action is decline. Wait for review.closed.
  • closedFinal
    Resolved. closed_reason says how, and closed_by says who.

closed_reason records how a review closed. A review also closes without your action when something else settles the payment:

ReasonWhat happened
approvedYou approved it.
declinedYou declined it, and Flint canceled the uncaptured payment.
refunded_as_fraudYou declined it, and Flint refunded the captured payment. refund_id and refunded_amount_money describe that refund.
refundedThe payment was refunded in full some other way, or you declined a payment that was already fully refunded.
payment_canceledThe payment was canceled.
disputedThe buyer's bank opened a dispute on the payment.
expiredNobody 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
curl "https://api.withflintpay.com/v1/fraud-warnings?actionable=true" \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
{
  "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
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.

FieldMeaning
levelThe card processor's rating: normal, elevated, highest, or not_assessed. A payment blocked before authorization is not_assessed.
scoreA numeric score, present only where risk scoring is available. Omitted otherwise, which is not the same as 0.
outcomeauthorized, manual_review, blocked, issuer_declined, or invalid.
outcome_reasonWhy, 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_idThe rule that decided the result.
review_idThe review opened for this payment.
statementA sentence describing the result, for your staff.
evaluated_atWhen 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#

  1. What happens: A block rule, or the card processor's screening, stops a payment
  2. Flint sends: payment_intent.payment_failed
    last_payment_error.code is payment_blocked.
  3. What happens: A review rule matches a payment the bank approved
  4. Flint sends: review.opened
    The review, with the payment summary and risk level.
  5. What happens: You approve or decline it, or the payment is canceled, refunded in full, disputed, or expires
  6. Flint sends: review.closed
    Fires once per review. closed_reason says how it closed.
  7. What happens: The card issuer reports fraud on a payment
  8. Flint sends: fraud_warning.created
    The warning, with actionable and the payment summary.
  9. What happens: The payment is refunded in full or disputed
  10. Flint sends: fraud_warning.updated
    Fires when actionable or dispute_id changes.

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
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
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 numberResult
4000 0000 0000 9235Elevated risk. The default review rule opens a review, except on invoice and subscription renewal payments.
4100 0000 0000 0019Highest risk, blocked by the card processor's screening. The attempt fails with payment_blocked.
4000 0000 0000 5423The 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.

  • GET/v1/risk-rules/attributesRequired API key scopes: risk.controls.write, risk.read or risk.reviews.writeReference for GET /v1/risk-rules/attributes

    Lists the attributes rules can use.

  • POST/v1/risk-previewsRequired API key scope: risk.controls.writeReference for POST /v1/risk-previews

    Validates a rule without saving it. It changes nothing but still needs risk.controls.write.

  • POST/v1/risk-rulesRequired API key scope: risk.controls.writeReference for POST /v1/risk-rules

    Creates a rule. Updating and deleting rules, and every list change, need the same scope.

  • POST/v1/risk-lists/{risk_list_id}/itemsRequired API key scope: risk.controls.writeReference for POST /v1/risk-lists/{risk_list_id}/items

    Adds values to a list.

  • GET/v1/reviewsRequired API key scopes: risk.controls.write, risk.read or risk.reviews.writeReference for GET /v1/reviews

    Lists reviews. Reading one review needs the same scope.

  • POST/v1/reviews/{review_id}/approveRequired API key scope: risk.reviews.writeReference for POST /v1/reviews/{review_id}/approve

    Approves a review. Declining needs the same scope.

  • GET/v1/fraud-warningsRequired API key scopes: risk.controls.write, risk.read or risk.reviews.writeReference for GET /v1/fraud-warnings

    Lists fraud warnings.

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#

  • HTTP 402
    A block rule or the card processor's screening stopped a payment intent confirmation. Ask the buyer for a different payment method, and do not mention fraud. Order payments report the block on the failed attempt with 200 instead.
  • HTTP 409
    Capture was requested while the payment's review is not approved. Approve the review, then capture again.
  • HTTP 400
    A review or require_3ds rule matched an ACH debit. Limit the rule to cards with payment_method_type.
  • HTTP 400
    A require_3ds rule matched a virtual terminal payment. Exclude virtual_terminal from the rule.
  • HTTP 503
    Flint could not finish the risk check. Retry the same request with the same Idempotency-Key after a short delay, so a payment the bank already approved is completed rather than charged twice.
  • HTTP 409
    A capture, cancellation, expiry, or review decision is already running on the payment. Read the payment and review again, then retry if the action still applies.
  • HTTP 409
    The review was already closed by the other action or by a payment event. closed_reason in the error details says which.
  • HTTP 409
    Another decision on the review is still finishing. Wait for review.closed.
  • HTTP 409
    Only enabled can change on Flint's default rules, and they cannot be deleted.
  • The environment already has 100 enabled rules (409), or the predicate has more than 100 nodes or 10 levels (400).
  • HTTP 409
    The rule's version no longer matches expected_version, or the rule or list is archived. Read it again before retrying.
  • HTTP 409
    Another list in the environment, possibly an archived one, has this alias. Choose a new alias.
  • HTTP 409
    Enabled rules still reference the list. The error details list their risk_rule_ids.
  • HTTP 409
    Flint's default lists cannot be archived.

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:

CodeCause
UNKNOWN_ATTRIBUTEThe attribute is not in the registry.
ATTRIBUTE_UNAVAILABLEThe attribute exists but is not available in this environment.
INVALID_OPERATORThe operator is not supported for the attribute.
INVALID_OPERANDThe value has the wrong type, is not one of the attribute's enum values, or values is empty or longer than 100.
INVALID_MONEYamount_money needs an integer amount and an uppercase currency, and nothing else.
INVALID_LIST_ALIASlist_alias is not a valid alias.
UNKNOWN_PREDICATE_FIELDA node has a field it does not accept.
INVALID_PREDICATE_NODE, INVALID_PREDICATE_GROUPA node is not a valid comparison or group, or a group has no children or more than 50.
INVALID_RULE_ACTIONaction is not allow, block, review, or require_3ds.
RULE_ACTION_UNAVAILABLE_AT_STAGEA block or require_3ds rule reads risk_level or risk_score.
INVALID_DESCRIPTIONdescription is empty or longer than 512 characters.
RISK_LIST_ALIAS_NOT_FOUND, RISK_LIST_ARCHIVED, RISK_LIST_TYPE_MISMATCHAn 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_TYPEThe new list's alias or item type is invalid.
INVALID_RISK_LIST_ITEM, INVALID_LIST_ITEM_COUNTA 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.succeeded or order.paid ships before anyone reviews it. Use manual capture, or wait for review.closed before shipping.
  • Treating approval as capture. Approving a manual-capture review only allows the capture. Call capture yourself before the authorization expires.
  • Writing neq and expecting it to match missing values. A payment without the attribute never matches. Add is_missing.
  • Adding an allow rule and expecting it to skip 3D Secure. require_3ds rules still apply.
  • Leaving a review rule open to ACH debits. A match fails the bank payment. Limit it with payment_method_type eq card.
  • Writing emails or domains with capitals in a predicate. They never match. Use lowercase, or use a list, which normalizes both sides.

Next steps#

Was this helpful?