Keywords: transaction risk scoring, fraud triage, payment risk signals, velocity checks, geo mismatch, device fingerprint, account age, chargeback history, approve review block, payment fraud scoring, heuristic risk engine, 3-D Secure, AVS CVC, authorization risk
Give it a transaction (amount, velocity, geo mismatch, device, customer age, history) and it returns a risk score, an approve / review / block decision, and a plain explanation of the signals that drove it. It never declines a live payment in v0.
Python 3.11 or newer. No API key.
git clone https://github.com/pandeyvishwas51-oss/risk-triage.git
cd risk-triage
python3 -m venv .venv && source .venv/bin/activate
pip install -e .
risk-triage demoThat prints score, decision, reason codes, explanation, and recommended action for sample payments. Rule-based mode. Same result on the bundled review-band file:
risk-triage score examples/txn.json
# or: python examples/run.pyTo turn on the optional model loop for review cases, set one model name and the matching provider key:
| Provider | LLM_MODEL |
Key env |
|---|---|---|
| OpenAI | gpt-4o |
OPENAI_API_KEY |
| Anthropic | claude-opus-5 |
ANTHROPIC_API_KEY |
| Local Ollama | ollama/llama3.1 |
none |
# OpenAI
export LLM_MODEL=gpt-4o
export OPENAI_API_KEY=sk-your-key
# Anthropic
export LLM_MODEL=claude-opus-5
export ANTHROPIC_API_KEY=sk-ant-your-key
# Local Ollama (Ollama already running; no key)
export LLM_MODEL=ollama/llama3.1Then run risk-triage demo or risk-triage score examples/txn.json again. Review cases get a model-written explanation. Approve and block stay on the rules. If you set a key and omit LLM_MODEL, the default is gpt-4o-mini.
Also works with Google (gemini/gemini-2.0-flash + GEMINI_API_KEY), OpenRouter (openrouter/openai/gpt-4o + OPENROUTER_API_KEY), and local vLLM (hosted_vllm/<name>). LiteLLM reads the provider key from that provider's usual env var.
risk-triage is a small Python library and CLI for transaction risk scoring and fraud triage. Payments-risk desks look at the same few families of signals on every authorization: how large the amount is, how fast the card or account is moving, whether IP / billing / card country agree, whether the device is new or an emulator, how old the customer is, and whether that customer has chargebacks or declines on file.
This project extracts those signals, runs a weighted rule engine, and prints a 0-100 score plus reason codes. An optional model loop rewrites the explanation on borderline review cases. The engine itself needs no API key.
v0 is read-only and advisory. It scores and explains. It does not call a payment gateway and it does not block a live authorization.
| Piece | Role |
|---|---|
signals.py |
Extract and normalize amount, velocity, geo, device, account age, history, auth, and IP. Pure. |
scoring.py |
Weighted rules, approve / review / block thresholds, reason codes. No API key. |
RiskAgent |
Optional model loop via LiteLLM (LLM_MODEL) for review-band copy. Falls back to rules with no key. |
| Loaders | Generic JSON and a simulated generator. No live calls. |
| CLI | risk-triage score and risk-triage demo print score, decision, and reasons. |
How do I score the fraud risk of a payment transaction?
Load the payment as a Transaction (amount in minor units, velocity counts, countries, device flags, account age, history) and call score_transaction(...). The engine normalizes signals, applies fixed weights, clamps a 0-100 score, and returns approve below 30, review from 30 up to 70, and block at 70 or on a few stacked-risk overrides. RiskAgent().score(...) adds a factual explanation on review cases. Run risk-triage demo to see clearly-good, clearly-bad, and borderline examples.
Does this block a live payment or call a gateway?
No. v0 never declines an authorization, never captures funds, and never opens a socket to a processor. RiskScore.advisory is always True. Take the score and the reason codes into your own decisioning if you want enforcement.
Is this a guaranteed-fraud or compliance system? No. It is a heuristic triage helper. It does not replace scheme rules, PCI controls, AML screening, sanctions lists, or a trained fraud model. A low score is not proof of a genuine customer. A high score is not proof of fraud.
Gateway dashboards already show a risk badge. Hosted fraud products already sell device graphs and consortium scores. What is hard to unit-test is the boring middle: given these signals, what number do we put on the payment, which reason codes fired, and should a human look at it? This library is that middle, in the open.
| Gateway risk badge | Hosted fraud product | 3-D Secure only | This library | |
|---|---|---|---|---|
| Works offline | No | No | Auth only | Yes |
| Weighted signals you can unit-test | Hidden | Vendor | No | Yes (signals.py, scoring.py) |
| Approve / review / block | You configure | Vendor | Challenge or not | Yes |
| Explains reason codes | Partial | Vendor | ECI | Yes |
| Calls a live gateway in v0 | Yes | Yes | Yes | Never |
| Needs an API key to demo | Often | Yes | Yes | No |
Tradeoffs: this will not pull live authorizations, will not look up a BIN database, will not score a device graph, and will not learn from your chargebacks. Thresholds default to a mid-ticket two-decimal merchant. If you already like a vendor's real-time decisioning, keep it and use this as the checklist you can test.
Everything below is what the code does, not a wish list.
-
Input. A
Transactionis an amount in minor units (paise, cents), a currency, a timestamp, and optional signal fields: velocity (txns_1h,txns_24h,amount_24h,distinct_cards_24h), geo (billing_country,shipping_country,ip_country,card_country), device (device_id,device_age_days,device_trusted,emulator,rooted), customer age (account_age_days), history (prior_txns,prior_chargebacks,prior_declines,avg_ticket), and auth (three_ds,avs_result,cvc_result). Loaders accept a flat object or a lightly nested one (customer,geo,device,velocity,history,auth,ip). Missing optional fields mean unknown, not safe. -
Normalize signals.
extract_signalsalways returns the same named list. EachSignalhas a family, a 0-1 value, afiredflag, and a note. Amount is compared to a per-currency typical ticket (INR 2,000.00, USD 50.00, overridable). Geo pairs fire only when both sides are present and differ. New vs young vs established account are exclusive buckets. Emulator suppresses the trusted-device discount. 3-D Secure, AVS, and CVC strings are mapped onto pass / fail. -
Weight.
apply_ruleswalks fired signals ontoReasonCodevalues with fixed points. Examples: emulator +22, new account +18, IP vs billing +16, high amount +15, one chargeback +25, two or more chargebacks +35, 3-D Secure pass -10, established customer -12. The inspect-only aggregates (geo_mismatch,device_risk,account_age) are not double-counted. -
Decide. Score is the sum of points, clamped to 0-100. Default:
approvebelow 30,reviewfrom 30 to just under 70,blockat 70. Three stacked patterns forceblockeven if the raw sum is lower, and the reported score is raised to 70 so the number and the label agree: two or more prior chargebacks, eight or more authorizations in one hour, or emulator + new account + a geo mismatch together.ScoringConfigcan change the thresholds and turn the force-block flags off. -
Explain.
explainlists the risk-positive codes, the mitigators, and a recommended action. Every explanation states that the result is advisory and does not approve or block a live payment. -
Optional model.
RiskAgentalways runs the engine first. The model loop runs only when the decision isreviewand a provider key is set (orLLM_MODELis a local Ollama / vLLM string). The call goes through LiteLLM. Tools:get_transaction,get_signals,get_score,submit_review. Arefusalstop, a missing SDK, a missing key,--no-agent,RISK_TRIAGE_DISABLE_AGENT=1, or any exception falls back to the rule explanation. The model may suggest a lean (approve / review / block) in the copy. It does not change the engine decision and it does not talk to a gateway. -
Output. CLI prints score, decision, reasons, explanation, and recommended action.
advisoryis alwaysTrue.
transaction JSON -> extract_signals -> weighted rules
|
v
score 0-100 + reason codes
|
approve < 30 review block >= 70
|
review-only optional model copy
|
v
your integration decides (not v0)
- Advisory only. The library never calls a processor, never returns an ISO decline, and never captures or refunds.
- Heuristic, not a trained model. Weights are constants in
scoring.py. They are not fit on your chargeback file. - Not compliance. This is not PCI guidance, not AML, not sanctions screening, and not a scheme-mandate 3-D Secure implementation.
- Not guaranteed fraud detection. Attackers change. False positives and false negatives are expected.
- No BIN, IP, or device intelligence vendors. Countries and emulator flags are taken from the payload you pass in.
- Default typical tickets assume a two-decimal currency and a mid-size cart. Pass
ScoringConfig(typical_amount=...)for other books. - 3-D Secure / AVS / CVC values are copied from the request. This package does not verify them with an ACS or issuer.
Python 3.11 or newer.
git clone https://github.com/pandeyvishwas51-oss/risk-triage.git
cd risk-triage
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest -qRuntime dependency: litellm (imported only if the optional model loop runs). Optional extra: pydantic (pip install -e ".[pydantic]") if you want it in the same environment for your own wrappers. The scoring engine uses dataclasses and the standard library.
risk-triage demo
risk-triage score examples/txn.json
risk-triage --json --no-agent score examples/txn.jsonrisk-triage demo covers an established customer, a small repeat buyer, a geo mismatch on a new device, a ticket far above the customer's average, a young account with a large cart, a proxy with 3-D Secure, an emulator stacked with a new account and a geo mismatch, two prior chargebacks, extreme one-hour velocity, and a card-testing burst.
from risk_triage import Transaction, score_transaction
from datetime import datetime, timezone
txn = Transaction(
id="pay_keyboard",
amount=400_000,
currency="INR",
occurred_at=datetime(2026, 8, 21, 12, 0, tzinfo=timezone.utc),
billing_country="IN",
ip_country="US",
card_country="IN",
device_id="dev_new",
device_age_days=0,
account_age_days=90,
prior_txns=4,
avg_ticket=90_000,
three_ds="Y",
amount_24h=400_000,
)
result = score_transaction(txn)
print(result.score, result.decision.value)
for hit in result.reasons:
print(hit.code.value, hit.points, hit.detail)
print(result.explanation)
print(result.advisory) # Truefrom risk_triage import RiskAgent, load_transaction
txn = load_transaction("examples/txn.json")
result = RiskAgent(force_rules=True).score(txn)
print(result.decision.value, result.explanation)Set LLM_MODEL and the matching provider key if you want the model loop to rewrite the explanation on review cases. Use --no-agent on the CLI to force rules even when a key is present. Dummy test keys look like sk-test-dummy and are never live credentials.
- Map your authorization payload onto
Transaction. Keep gateway HTTP out of that mapping. - Call
score_transaction(orRiskAgent.score) at authorization time or in a batch. - If you enforce, do it in your own code: send the authorization, queue a review, or return a decline through the processor you already use.
- Do not treat the score as a guarantee. Issuers, schemes, and your own policy still decide.
- Log the reason codes next to the payment id so a reviewer can see why the number moved.
Python 3.11+. Tests run on 3.11, 3.12, and 3.13 in CI.
No. Signal extraction, scoring, loaders, and the simulated demo run without one. A key is only for the optional review-band tool-use loop. Local Ollama needs no key.
Whatever you put in LLM_MODEL. Default: gpt-4o-mini (risk_triage.DEFAULT_MODEL). LiteLLM routes the string to OpenAI, Anthropic, Google, OpenRouter, Ollama, vLLM, and other providers. A refusal stop is treated as a fallback to the rule explanation, not as a crash.
A single IP vs billing mismatch is +16 points. Approve is everything under 30. Pair it with a new device (+10) or a ticket far above average (+18) and it crosses into review. 3-D Secure pass (-10) and an established customer (-12) pull the other way.
repeat_chargeback is a force-block override. Two or more prior chargebacks set the decision to block and raise the reported score to 70. Turn that off with ScoringConfig(force_block_repeat_chargebacks=False) if your book treats repeats as review.
from risk_triage import ScoringConfig, score_transaction
cfg = ScoringConfig(approve_below=20, block_at=60, typical_amount=50_000)
result = score_transaction(txn, cfg)typical_amount is minor units. High amount is typical_amount * high_multiple (default 5).
Not through this package. There is no capture, refund, or decline client. If you add one, keep it outside signals.py and scoring.py so the engine stays testable without a key.
Yes. CI unsets provider keys and LLM_MODEL, and sets RISK_TRIAGE_DISABLE_AGENT=1. The LiteLLM layer is mocked at litellm.completion. Scoring and signal tests never import litellm.
pip install -e ".[dev]"
pytest -qMIT. Free for commercial and personal use.