PayinferenceDocs
payinference.com

Policy Manager

Policies are how you tell the Inference Engine what a decision must respect. A policy is structured JSON: an ordered list of rules, each with a condition (when) and an action (then), plus optional cost controls. Policies are validated before publish, versioned, evaluated deterministically on every decision, and never involve an LLM at decision time.

What policies can define#

  • Routing preferences (prefer or avoid specific providers)
  • Risk thresholds (step up or block above a score or band)
  • Retry limits and no-retry lists
  • Step-up requirements
  • Hold rules (pause for review or approval)
  • Block rules
  • Provider fallback rules
  • Minimum provider health requirements
  • Spending limits by amount, useful as agent guardrails
  • Cost and margin controls for cost-aware routing (deterministic-v2)

Anatomy of a policy#

json
{
  "merchant_id": "m_123",
  "name": "EU card routing",
  "default_provider": "stripe",
  "publish": true,
  "rules": [
    {
      "rule_id": "prefer_adyen_eu",
      "name": "Prefer Adyen for EU cards",
      "priority": 10,
      "enabled": true,
      "when": { "region_in": ["EU"], "payment_method_in": ["card"] },
      "then": { "type": "prefer_provider", "provider": "adyen", "min_health_score": 70 }
    },
    {
      "rule_id": "step_up_high_value",
      "name": "Step up new customers above 500",
      "priority": 20,
      "enabled": true,
      "when": { "amount_gte": 50000, "customer_type_in": ["new", "guest"] },
      "then": { "type": "step_up" }
    },
    {
      "rule_id": "never_retry_hard_declines",
      "name": "Never retry hard declines",
      "priority": 30,
      "enabled": true,
      "when": {},
      "then": { "type": "no_retry", "provider_reason_codes": ["stolen_card", "do_not_honor"] }
    }
  ]
}

Rules are evaluated by ascending priority. An empty when matches every transaction. A policy holds up to 100 rules.

Conditions (when)#

Field Type Matches
amount_gte / amount_lte integer Amount thresholds in minor units
currency_in string[] ISO 4217 currencies
country_in string[] ISO 3166-1 alpha-2 countries
region_in enum[] EU, US, AFRICA, GLOBAL
payment_method_in enum[] card, wallet, bank_transfer, mobile_money
card_network_in enum[] visa, mastercard, amex, discover, verve, other
customer_type_in enum[] new, returning, guest
transaction_type_in enum[] authorization, sale, capture
risk_level_in enum[] low, medium, high
risk_score_gte / risk_score_lte integer Risk score thresholds, 0 to 100

risk_level_in and risk_score_gte / risk_score_lte evaluate the risk_level and risk_score you supply in the decision request's transaction, which is how your existing fraud tooling drives step_up and block instructions. A condition on a field you did not send does not match (an absent risk_score is treated as 0 for threshold checks).

Previous attempt#

These match the decision request's previous_attempt, so a rule can route around whatever just failed rather than only reacting to timeouts through the retry actions.

Field Type Matches
previous_outcome_in enum[] timeout, declined, error
previous_provider_in enum[] Provider the failed attempt ran on
previous_reason_code_in string[] Provider reason code, compared case-insensitively

A request with no previous_attempt never matches any of these, so a retry rule stays inert on a first attempt instead of firing on every transaction.

json
{
  "rule_id": "r_route_around_decline",
  "name": "avoid_the_provider_that_just_declined",
  "priority": 5,
  "when": { "previous_outcome_in": ["declined"], "previous_provider_in": ["stripe"] },
  "then": { "type": "avoid_provider", "provider": "stripe" }
}

Naming a provider you have since disabled produces a publish-time warning, not an error, because a prior attempt can legitimately have run on it. The far more common cause is a typo, which would make the rule silently unreachable.

Risk signals#

These match the decision request's risk_signals object, for intents the derived risk_score cannot express.

Field Type Matches
velocity_count_10m_gte integer Attempts in the last 10 minutes
velocity_count_1h_gte integer Attempts in the last hour
velocity_amount_1h_gte integer Amount in the last hour, minor units
previous_decline_count_gte integer Declines already seen for this order
device_risk_band_in enum[] low, medium, high
ip_country_match boolean Whether the IP country matched

Two things to know before using these. They read your own numbers, and the risk engine also folds the same signals into risk_score, so pairing a velocity rule with a risk-score rule counts the same evidence twice. And unlike risk_score_gte, an absent signal never matchesvelocity_count_10m_gte: 0 does not fire for a caller that omits velocity data, because treating a missing count as zero would silently match every request.

Actions (then)#

Type Effect Extra fields
prefer_provider Bias routing toward a provider provider, optional min_health_score
avoid_provider Exclude a provider provider
set_fallback Set the fallback provider on the route provider
avoid_health_states Exclude providers in the given health states states, for example ["degraded", "critical"]
require_min_health Exclude providers below a health score score
block Return the block instruction
step_up Return the step_up instruction
hold Return the hold instruction (pause for review)
retry_on_timeout Allow retries after timeouts max_retries (0 to 3), use_different_provider
no_retry Suppress retries for specific provider reason codes provider_reason_codes

A matched block rule is terminal: evaluation stops and the decision is block with reason code RISK_SCORE_ABOVE_BLOCK_THRESHOLD, whatever the rule's condition was. A matched hold rule sets the instruction to hold (reason code HOLD_REQUIRED_BY_POLICY) and outranks step_up; a matched step_up rule sets step_up (reason code STEP_UP_REQUIRED_BY_POLICY). In both cases routing is still computed, so the response carries the provider to use once the pause or control resolves.

Cost controls#

Declaring any of these top-level cost controls (or having resolved pricing cached) switches the merchant's decisions to cost-aware routing (deterministic-v2). Without them, decisions run deterministic-v1 unchanged.

Field Meaning
optimization_mode approval (ignore cost), cost (minimize cost behind an approval guardrail), balanced (rank by expected value, default), margin (maximize expected margin)
margin_bps Your gross margin in basis points, used by margin mode
retry_cost_minor What a failed attempt costs you, in minor units
max_cost_bps Hard ceiling on route cost
require_real_pricing_for_cost_optimization Forbid optimizing on estimated_fallback or unknown pricing

Lifecycle#

  1. Draft. POST /v1/policies with publish: false. Drafts can be saved with warnings.
  2. Validate. Static validation plus conflict detection runs on every create. Errors always reject; warnings reject only on publish.
  3. Simulate. POST /v1/policies/simulate runs a draft (or the published policy) against a sample transaction with the same engine and cached health the live path uses. Nothing is persisted.
  4. Publish. publish: true makes the policy live and archives the previously published one. One published policy per merchant. Publishing invalidates the policy cache and is audit-logged.

Every decision response reports which policy shaped it (policy.policy_id, policy.policy_version) and which rules matched (policy.matched_rules), so you can trace any decision back to the exact rule set.

Drafting from plain English#

POST /v1/policies/translate turns text like "Prefer Adyen for EU cards. Require 3DS above 500 EUR. Never retry hard declines." into a structured draft. The translator is rule-based, runs only at authoring time, and its output must pass the same strict validation as hand-written policies before it can be saved or published. It never runs during a decision.

It also understands previous-attempt and risk-signal phrasing:

Phrase Condition produced
"Avoid PayPal when Stripe declines" previous_outcome_in: ["declined"], previous_provider_in: ["stripe"]
"after a charge declined on Stripe" same, from the declined on <provider> form
"when the previous attempt timed out" previous_outcome_in: ["timeout"]
"more than 5 attempts in 10 minutes" velocity_count_10m_gte: 5
"over 20 charges in the last hour" velocity_count_1h_gte: 20
"spends over $2,500 in the last hour" velocity_amount_1h_gte: 250000
"after 3 previous declines" previous_decline_count_gte: 3
"for high-risk devices" device_risk_band_in: ["high"]
"when the IP country does not match" ip_country_match: false

The translator binds a provider from its position in the sentence, so "avoid PayPal when Stripe declines" avoids PayPal and conditions on Stripe rather than confusing the two. It stays conservative by design: a sentence it cannot parse becomes a warning rather than a guessed rule, and "for high-risk devices" sets the device band without also setting the transaction risk_level_in.

Practical guidance#

  • Start with a small policy: one health guard (avoid_health_states: ["critical", "unavailable"]), one step-up rule for your risk threshold, one no-retry list for your known-fatal decline codes.
  • Use simulate before every publish. It shows matched rules, excluded providers, and the resulting instruction for a sample transaction.
  • Keep rule names meaningful; they appear verbatim in policy.matched_rules on every decision and in the decision log.
  • Amount thresholds are minor units. amount_gte: 50000 is 500.00 in a two-decimal currency.