UPI Fraud Risk Scoring API

Build and deploy a real-time ML fraud detection API for UPI transactions.

Introduction

30 Second Summary

Every time you tap to pay at a chai stall or split a dinner bill on UPI, there's a system working behind the scenes to decide if that transaction looks legitimate or suspicious. In India alone, over Rs 11,333 crore was lost to UPI fraud in 2024-25.

In this project, you will build and deploy a real-time fraud risk scoring API that takes UPI transaction details and returns a risk score, a decision (ALLOW, FLAG, or BLOCK), and reason codes explaining why.

What You'll Build

A FastAPI REST service powered by an XGBoost machine learning model that scores UPI transactions for fraud risk in real-time.

By the end of this project, you'll have:

  • A trained XGBoost fraud detection model achieving 89%+ ROC-AUC score on synthetic financial data.
  • A live FastAPI scoring endpoint that returns risk decisions and reason codes in under 100ms.
  • A Dockerized service deployed on Railway with auto-generated interactive API documentation.
  • Secret Mission: Add a batch scoring endpoint that processes multiple transactions at once and returns aggregate risk analytics.

Are there any prerequisites?

You need basic Python knowledge (variables, functions, loops). No machine learning experience is required. This project walks you through every ML concept from scratch.

Before We Start

UPI fraud costs Indians over Rs 11,000 crore per year. To fight back, you need a system that can score every transaction for fraud risk in milliseconds.

Before building your fraud detection API, you need Python, a set of ML libraries, and accounts for deployment. This step gets your entire development environment ready so you can focus on building.

In this step, get ready to:
  • Install Python 3.11+ and set up a virtual environment.
  • Install all project dependencies (FastAPI, XGBoost, pandas, and more).
  • Set up Docker, GitHub, and Railway for deployment later.
Install Python and create your project

Python is the language you'll use to train your fraud model and build the API. You need version 3.11 or higher.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Check your Python version by running this command:
python3 --version

✔️ I see a version number (3.11 or higher)

You're all set. Move on to creating your project folder below.

ⓧ Command not found or version is below 3.11

  • Download Python 3.11+ from python.org/downloads.
  • Run the installer and check the box that says Add Python to PATH.
  • Close and reopen your terminal, then run python3 --version again to confirm.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir upi-fraud-api
cd upi-fraud-api

A virtual environment keeps your project's packages isolated from other Python projects on your machine.

  • Create a virtual environment by running this command:
python3 -m venv venv
  • Activate the virtual environment by running this command:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

Make sure you're inside the upi-fraud-api folder. Run pwd (macOS) or cd (Windows) to check your current directory.

help me activate my Python virtual environment

Install project dependencies

Your project needs several libraries. A requirements.txt file lists them all with exact versions so every install is reproducible.

  • Create a file called requirements.txt inside your upi-fraud-api folder and paste in the following content:
fastapi==0.136.1
uvicorn==0.34.0
xgboost==3.2.0
pandas==2.2.3
scikit-learn==1.6.1
joblib==1.4.2
numpy==1.26.4

What are these libraries?

  • fastapi is the web framework for building your API endpoints.
  • uvicorn is the server that runs your FastAPI app.
  • xgboost is the machine learning algorithm you'll train for fraud detection.
  • pandas and numpy handle data loading and numerical operations.
  • scikit-learn and joblib provide model evaluation tools and serialization (saving your trained model to a file).
  • Save requirements.txt.
  • Install all the dependencies by running this command:
pip install -r requirements.txt

This takes 1-2 minutes. You should see packages downloading and a final line that says Successfully installed followed by a list of package names.

Seeing installation errors?

Make sure your virtual environment is active (you should see (venv) in your prompt). If it's not, re-run the activation command from the previous substep.

If you see errors about compiler tools or C++ build tools, try upgrading pip first with pip install --upgrade pip and then try again.

help me fix pip install errors for my fraud detection project

✔️ Awesome, I've got everything!

Great. Double check you've saved your requirements.txt file.

ⓧ I'd like to double check the full code

Here's the complete requirements.txt file for comparison:

fastapi==0.136.1
uvicorn==0.34.0
xgboost==3.2.0
pandas==2.2.3
scikit-learn==1.6.1
joblib==1.4.2
numpy==1.26.4
Set up Docker, GitHub, and Railway

Later in this project, you'll containerize your API with Docker and deploy it to Railway via GitHub. Set up these accounts and tools now so deployment goes smoothly.

  • Download and install Docker Desktop from docker.com/products/docker-desktop.
  • Open Docker Desktop after installation and let it finish starting up.
  • Verify Docker is running by running this command:
docker --version

You should see a version number like Docker version 27.x.x in the output.

  • Create a free GitHub account at github.com if you don't already have one.
  • Create a free Railway account at railway.app. Sign up with your GitHub account for the easiest setup.

What about Railway costs?

Railway gives you a $5 free trial credit for 30 days with no credit card required. This is more than enough to deploy and test your fraud scoring API.

After the trial, the Hobby plan is $5/month. You can delete your deployment in the Before You Go section to avoid charges.

Your environment is locked and loaded. Next up, you'll download real financial transaction data and engineer the features your fraud model needs to learn from.

Prepare the Fraud Detection Dataset

Your development environment is set up and ready to go. Now you need something for your fraud detection model to learn from: data.

Machine learning models learn patterns from historical data. In this step, you will download a dataset of financial transactions, explore its structure, and identify the features that will help your model distinguish fraud from legitimate activity.

In this step, get ready to:
  • Download the PaySim synthetic fraud dataset from Kaggle.
  • Explore the dataset structure with pandas.
  • Identify UPI-relevant features to engineer for the model.
Download the PaySim dataset from Kaggle

Real financial transaction data is private and protected. Researchers created PaySim, a synthetic dataset that simulates mobile money transactions based on real-world patterns. It contains 6.3 million transactions with realistic fraud patterns baked in.

  • Open your browser and go to the PaySim dataset page on Kaggle:
  • https://www.kaggle.com/datasets/ealaxi/paysim1
  • If you don't have a Kaggle account, click Register and create a free account.
  • Click the Download button near the top of the dataset page.
  • Extract the downloaded zip file. You will find a CSV file inside.
  • Rename the CSV file to paysim_data.csv and move it into your upi-fraud-api project folder.

Why rename the file?

The original filename from Kaggle is long and hard to type. Your training script will reference paysim_data.csv directly, so renaming it now saves you from typos later.

  • Confirm the file is in the right place by running:
ls paysim_data.csv

You should see paysim_data.csv printed in the output. The file should be roughly 470MB.

File not found?

Make sure your terminal is inside the upi-fraud-api folder. Run pwd to check your current directory.

If the file is on your Desktop or Downloads folder, move it into upi-fraud-api before continuing.

help me find and move my paysim_data.csv file into my project folder

Explore the data with pandas

Before training a model, you need to understand what your data looks like. You will use pandas to load the dataset and inspect its structure interactively.

  • Start a Python interactive session by running:
python
  • Load the dataset and preview the first few rows by running:
import pandas as pd

# Load the PaySim dataset
df = pd.read_csv("paysim_data.csv")

# Preview the first 5 rows
df.head()

What should I see?

You should see a table with columns like step, type, amount, nameOrig, oldbalanceOrg, newbalanceOrig, nameDest, oldbalanceDest, newbalanceDest, isFraud, and isFlaggedFraud.

Each row represents one financial transaction. The isFraud column is the label your model will learn to predict.

  • Check the dataset size and column types by running:
# Check the shape and data types
print(f"Dataset shape: {df.shape}")
print(f"\nColumn types:")
print(df.dtypes)

You should see approximately 6.3 million rows and 11 columns.

  • Check how many fraudulent transactions exist by running:
# Check class distribution
fraud_count = df["isFraud"].sum()
total_count = len(df)
print(f"Fraudulent: {fraud_count} ({fraud_count/total_count*100:.2f}%)")
print(f"Legitimate: {total_count - fraud_count}")

What is class imbalance?

Only about 1.3% of transactions are fraudulent. This is called class imbalance. It means the model will see far more legitimate transactions than fraudulent ones during training.

If you train naively, the model could just predict "legitimate" every time and appear 98.7% accurate. You will handle this in the next step using a technique called scale_pos_weight that tells XGBoost to pay extra attention to rare fraud cases.

  • Check the transaction types available in the dataset by running:
# View transaction types and their counts
print(df["type"].value_counts())

You should see five types: PAYMENT, TRANSFER, CASH_OUT, DEBIT, and CASH_IN. These map directly to UPI transaction categories.

Getting a MemoryError?

The PaySim dataset is large (6.3 million rows). If your machine runs out of memory, close other applications and try again.

help me load the PaySim dataset when I'm getting a MemoryError

Identify features to engineer for fraud detection

Raw data columns rarely make good inputs for a machine learning model on their own. Feature engineering is the process of transforming raw data into signals that help the model spot patterns.

Let's explore which transformations will help your model catch fraud. Run each of these to see why they matter:

  • Extract the hour of transaction from the step column by running:
import numpy as np

# The 'step' column represents hours from the start of the simulation
# Convert to hour of day (0-23)
df["hour"] = df["step"] % 24

# Check fraud rate by hour
fraud_by_hour = df.groupby("hour")["isFraud"].mean()
print("Fraud rate by hour (top 5 riskiest):")
print(fraud_by_hour.sort_values(ascending=False).head())

Why is hour important?

Fraudsters tend to operate during odd hours (late night, early morning) when victims are less likely to notice. The hour feature is 2.5x more predictive than raw amount according to SHAP analysis of similar models.

  • Check how amount relates to fraud by running:
# Log-transform the amount (reduces skew from huge transactions)
df["amount_log"] = np.log1p(df["amount"])

# Compare average amounts
print(f"Avg amount (legit): {df[df['isFraud']==0]['amount'].mean():,.0f}")
print(f"Avg amount (fraud): {df[df['isFraud']==1]['amount'].mean():,.0f}")

Fraudulent transactions are significantly larger on average. The log transform helps the model handle the huge range of transaction amounts without being dominated by outliers.

  • Check balance differential patterns by running:
# Calculate balance changes
df["balance_diff_orig"] = df["newbalanceOrig"] - df["oldbalanceOrg"]
df["balance_diff_dest"] = df["newbalanceDest"] - df["oldbalanceDest"]

# Calculate amount-to-balance ratio
df["amount_to_balance_ratio"] = df["amount"] / (df["oldbalanceOrg"] + 1)

# Check ratio for fraud vs legitimate
print(f"Avg ratio (legit): {df[df['isFraud']==0]['amount_to_balance_ratio'].mean():.2f}")
print(f"Avg ratio (fraud): {df[df['isFraud']==1]['amount_to_balance_ratio'].mean():.2f}")

What do these features tell the model?

  • balance_diff_orig: How much the sender's balance changed. Fraud often drains accounts completely.
  • balance_diff_dest: How much the receiver's balance changed. Money appearing in a previously empty account is suspicious.
  • amount_to_balance_ratio: How large the transaction is relative to what the sender has. Fraudsters transfer everything at once.
  • Exit the Python session by running:
exit()

You have now identified six engineered features that your model will use in the next step:

  • hour (time of day from step % 24).
  • amount_log (log-transformed transaction amount).
  • balance_diff_orig (sender balance change).
  • balance_diff_dest (receiver balance change).
  • amount_to_balance_ratio (transaction size relative to sender balance).
  • type_encoded (numeric encoding of transaction type).

Your data is explored and your feature strategy is clear. Next up, you will write a training script that engineers these features and trains an XGBoost model to detect fraud.

Train the XGBoost Fraud Detection Model

You have your dataset loaded and your features identified. Now it's time to train a machine learning model that can actually detect fraud.

In this step, you'll build a complete training script that engineers features, splits the data, trains an XGBoost classifier, and saves the trained model to disk. The saved model file is what your API will load later to score transactions in real-time.

In this step, get ready to:
  • Split the PaySim data into train and test sets with stratified sampling.
  • Train an XGBoost classifier with class imbalance handling.
  • Evaluate model performance and save the trained model with joblib.
Split data into train/test sets

Fraud is extremely rare in real transaction data. Only about 1.3% of PaySim transactions are fraudulent. If you train a model on a random subset, it might never learn what fraud looks like.

Stratified splitting ensures both the training set and test set keep the same fraud-to-legitimate ratio as the original data.

  • Create a new file called train_model.py in your upi-fraud-api folder.
  • Add the imports, data loading, and feature engineering code by pasting the following:
import pandas as pd
import numpy as np
from xgboost import XGBClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score, classification_report
import joblib
import os

# Load dataset
print("Loading PaySim dataset...")
df = pd.read_csv("paysim_data.csv")

# Feature engineering
print("Engineering features...")
df["hour"] = df["step"] % 24
df["amount_log"] = np.log1p(df["amount"])
df["balance_diff_orig"] = df["newbalanceOrig"] - df["oldbalanceOrg"]
df["balance_diff_dest"] = df["newbalanceDest"] - df["oldbalanceDest"]
df["amount_to_balance_ratio"] = df["amount"] / (df["oldbalanceOrg"] + 1)

# Encode transaction type
type_mapping = {"CASH_OUT": 0, "PAYMENT": 1, "CASH_IN": 2, "TRANSFER": 3, "DEBIT": 4}
df["type_encoded"] = df["type"].map(type_mapping)

What does this code do?

This section loads the raw PaySim CSV and transforms it into features a machine learning model can understand.

  • hour extracts the time of day from the step column. Fraudulent transactions spike during unusual hours.
  • amount_log applies a log transform to compress extreme values so the model treats a 100 INR and 10 lakh INR transaction more fairly.
  • balance_diff_orig and balance_diff_dest capture how much balances actually changed. Mismatches between amount and balance change are a strong fraud signal.
  • type_mapping converts text labels like "CASH_OUT" into numbers since ML models only work with numeric inputs.
  • Now add the feature selection and train/test split below the code you just pasted:
# Select features
features = [
    "type_encoded",
    "amount_log",
    "hour",
    "balance_diff_orig",
    "balance_diff_dest",
    "amount_to_balance_ratio",
    "oldbalanceOrg",
    "newbalanceOrig",
]

X = df[features]
y = df["isFraud"]

# Split data
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

What does this code do?

  • features is the list of 8 engineered columns the model will learn from. Only the most predictive columns are included.
  • X holds the input features and y holds the fraud labels (0 = legitimate, 1 = fraud).
  • stratify=y ensures both train and test sets keep the same ~1.3% fraud ratio. Without this, the test set might accidentally contain zero fraud examples.
  • Save train_model.py.

Seeing a NameError or ImportError?

  • Make sure your virtual environment is activated. You should see (venv) in your terminal prompt.
  • Check that you installed all dependencies from requirements.txt in Step 1.

Help me debug my import errors in train_model.py

Train an XGBoost classifier with class imbalance handling

Fraud makes up only ~1.3% of the dataset. A naive model could just predict "not fraud" every time and be 98.7% accurate. That's useless for catching actual fraud.

The scale_pos_weight parameter tells XGBoost to penalize missed fraud cases more heavily. It forces the model to pay attention to the rare fraud examples.

  • Add the class imbalance calculation and model training code below your train/test split:
# Calculate class imbalance ratio
fraud_ratio = (y_train == 0).sum() / (y_train == 1).sum()
print(f"Class imbalance ratio: {fraud_ratio:.0f}:1")

# Train XGBoost model
print("Training XGBoost model...")
model = XGBClassifier(
    n_estimators=100,
    max_depth=6,
    learning_rate=0.1,
    scale_pos_weight=fraud_ratio,
    eval_metric="auc",
    random_state=42,
)

model.fit(X_train, y_train)

What does this code do?

  • fraud_ratio calculates how many legitimate transactions exist for every fraudulent one. This will be roughly 773:1.
  • n_estimators=100 means 100 decision trees working together. More trees = better accuracy, but slower training.
  • max_depth=6 limits how complex each tree can be. This prevents the model from memorizing the training data instead of learning patterns.
  • scale_pos_weight=fraud_ratio multiplies the penalty for missing a fraud case by ~773x. This is how the model learns to catch rare fraud despite the imbalance.

Why XGBoost instead of deep learning?

XGBoost provides feature importance scores that map directly to reason codes (you'll use these in your API later). It also runs inference in under 100ms, which is critical for real-time fraud scoring.

Deep learning models (LSTMs, transformers) are harder to explain to regulators and slower for single-transaction scoring. Production fraud systems at Indian fintechs use XGBoost for exactly these reasons.

  • Save train_model.py.

Training taking too long?

PaySim has 6.3 million rows. Training should take 1-3 minutes depending on your machine. If it seems stuck, let it run. You'll see output when it finishes.

Help me troubleshoot slow XGBoost training

Evaluate model performance and save the trained model

A model is only useful if it actually catches fraud without flagging every legitimate transaction. ROC-AUC measures how well the model separates fraud from legitimate transactions across all possible thresholds. A score of 0.89+ means the model is production-ready.

  • Add the evaluation and model saving code below model.fit(X_train, y_train):
# Evaluate
y_pred_proba = model.predict_proba(X_test)[:, 1]
roc_auc = roc_auc_score(y_test, y_pred_proba)
print(f"\nROC-AUC Score: {roc_auc:.4f}")

y_pred = (y_pred_proba > 0.5).astype(int)
print("\nClassification Report:")
print(classification_report(y_test, y_pred, target_names=["Legitimate", "Fraud"]))

# Save model and feature list
os.makedirs("model", exist_ok=True)
joblib.dump(model, "model/fraud_model.joblib")
joblib.dump(features, "model/feature_names.joblib")
print("\nModel saved to model/fraud_model.joblib")

What does this code do?

  • predict_proba(X_test)[:, 1] gets the fraud probability (0.0 to 1.0) for each test transaction.
  • roc_auc_score measures overall model quality. Higher is better, with 1.0 being perfect.
  • classification_report shows precision (how many flagged transactions are actually fraud) and recall (how many actual frauds were caught).
  • joblib.dump serializes the trained model and feature list to disk. Your API will load these files at startup to score new transactions.
  • Save train_model.py.
  • Run the training script by running this command:
python train_model.py
  • Wait 1-3 minutes for training to complete. You should see output showing the class imbalance ratio, the ROC-AUC score (0.89 or higher), and the classification report with precision and recall for both classes.
  • Confirm the model files were saved by running:
ls model/
  • You should see fraud_model.joblib and feature_names.joblib in the output.

ROC-AUC score below 0.89?

  • Double check that your features list matches exactly the 8 features shown in the code above.
  • Make sure paysim_data.csv is the full dataset (6.3 million rows). A truncated file will produce worse results.
  • Verify that scale_pos_weight=fraud_ratio is set. Without it, the model ignores the rare fraud class.

Help me debug my model's low ROC-AUC score

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

Here is the complete train_model.py file:

import pandas as pd
import numpy as np
from xgboost import XGBClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score, classification_report
import joblib
import os

# Load dataset
print("Loading PaySim dataset...")
df = pd.read_csv("paysim_data.csv")

# Feature engineering
print("Engineering features...")
df["hour"] = df["step"] % 24
df["amount_log"] = np.log1p(df["amount"])
df["balance_diff_orig"] = df["newbalanceOrig"] - df["oldbalanceOrg"]
df["balance_diff_dest"] = df["newbalanceDest"] - df["oldbalanceDest"]
df["amount_to_balance_ratio"] = df["amount"] / (df["oldbalanceOrg"] + 1)

# Encode transaction type
type_mapping = {"CASH_OUT": 0, "PAYMENT": 1, "CASH_IN": 2, "TRANSFER": 3, "DEBIT": 4}
df["type_encoded"] = df["type"].map(type_mapping)

# Select features
features = [
    "type_encoded",
    "amount_log",
    "hour",
    "balance_diff_orig",
    "balance_diff_dest",
    "amount_to_balance_ratio",
    "oldbalanceOrg",
    "newbalanceOrig",
]

X = df[features]
y = df["isFraud"]

# Split data
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

# Calculate class imbalance ratio
fraud_ratio = (y_train == 0).sum() / (y_train == 1).sum()
print(f"Class imbalance ratio: {fraud_ratio:.0f}:1")

# Train XGBoost model
print("Training XGBoost model...")
model = XGBClassifier(
    n_estimators=100,
    max_depth=6,
    learning_rate=0.1,
    scale_pos_weight=fraud_ratio,
    eval_metric="auc",
    random_state=42,
)

model.fit(X_train, y_train)

# Evaluate
y_pred_proba = model.predict_proba(X_test)[:, 1]
roc_auc = roc_auc_score(y_test, y_pred_proba)
print(f"\nROC-AUC Score: {roc_auc:.4f}")

y_pred = (y_pred_proba > 0.5).astype(int)
print("\nClassification Report:")
print(classification_report(y_test, y_pred, target_names=["Legitimate", "Fraud"]))

# Save model and feature list
os.makedirs("model", exist_ok=True)
joblib.dump(model, "model/fraud_model.joblib")
joblib.dump(features, "model/feature_names.joblib")
print("\nModel saved to model/fraud_model.joblib")

Your trained model is saved and ready to serve predictions. Next up, you'll build a FastAPI endpoint that loads this model and scores UPI transactions in real-time.

Build the FastAPI Scoring Endpoint

Your trained XGBoost model is sitting in the model/ folder, ready to make predictions. Now you need a way for applications to send transaction data and get a risk score back.

In this step, you will build a REST API using FastAPI that accepts UPI transaction details and returns a fraud risk assessment. The API will load your trained model, engineer the same features you used during training, and return a score from 0 to 100.

In this step, get ready to:
  • Create the FastAPI app with Pydantic request and response schemas.
  • Build the /v1/score POST endpoint with feature engineering and model prediction.
  • Add risk decision logic with ALLOW/FLAG/BLOCK thresholds and reason codes.
Create the FastAPI app with a Pydantic request/response schema

FastAPI uses Pydantic models to validate incoming requests and shape outgoing responses. You will define exactly what fields a transaction must include and what the risk response looks like.

  • Create a new file called main.py in your upi-fraud-api folder.
  • Add the imports, app initialization, and model loading by pasting this code:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from typing import Literal
import numpy as np
import joblib
import time

# Initialize the FastAPI application with metadata
app = FastAPI(
    title="UPI Fraud Risk Scoring API",
    description="Real-time fraud risk scoring for UPI transactions. Returns risk score, decision, and reason codes.",
    version="1.0.0",
)

# Load model at startup
model = joblib.load("model/fraud_model.joblib")
feature_names = joblib.load("model/feature_names.joblib")

What does this code do?

The FastAPI() call creates your web application. The title and description parameters automatically generate interactive API docs later.

The two joblib.load() calls load your trained XGBoost model and feature list into memory once when the server starts. This avoids reloading the model on every request, keeping response times under 100ms.

  • Below the model loading code, add the two Pydantic models that define your API contract:
class TransactionRequest(BaseModel):
    amount: float = Field(..., description="Transaction amount in INR", gt=0)
    transaction_type: Literal["CASH_OUT", "PAYMENT", "CASH_IN", "TRANSFER", "DEBIT"] = Field(
        ..., description="Type of UPI transaction"
    )
    hour: int = Field(..., description="Hour of transaction (0-23 IST)", ge=0, le=23)
    sender_balance: float = Field(..., description="Sender account balance before transaction", ge=0)
    receiver_balance: float = Field(..., description="Receiver account balance before transaction", ge=0)


class RiskResponse(BaseModel):
    risk_score: int = Field(..., description="Risk score from 0 (safe) to 100 (fraud)")
    decision: Literal["ALLOW", "FLAG", "BLOCK"] = Field(..., description="Recommended action")
    reasons: list[str] = Field(..., description="Reason codes explaining the risk assessment")
    processing_time_ms: float = Field(..., description="Time taken to process in milliseconds")

What are these Pydantic models doing?

TransactionRequest defines the five fields every API call must include. The Field(...) constraints like gt=0 and ge=0 automatically reject invalid data (negative amounts, impossible hours) before your scoring logic even runs.

RiskResponse defines exactly what your API returns. The Literal type on decision ensures only ALLOW, FLAG, or BLOCK can ever be returned.

  • Save main.py.

Seeing a red underline on Literal or list[str]?

Make sure you are using Python 3.11+ in your virtual environment. The list[str] syntax requires Python 3.9+ and Literal is imported from typing. Check your Python version matches by running python --version in your terminal.

Help me fix Pydantic model errors in my FastAPI project

Build the /v1/score POST endpoint

The endpoint receives a transaction, engineers the same features your model was trained on, and passes them through the XGBoost classifier to get a fraud probability.

  • Below your RiskResponse class in main.py, add the scoring endpoint with feature engineering:
@app.post("/v1/score", response_model=RiskResponse)
def score_transaction(txn: TransactionRequest):
    start_time = time.time()

    # Map transaction type to numeric encoding (same as training)
    type_mapping = {"CASH_OUT": 0, "PAYMENT": 1, "CASH_IN": 2, "TRANSFER": 3, "DEBIT": 4}

    # Engineer the same features the model was trained on
    amount_log = np.log1p(txn.amount)
    balance_diff_orig = -txn.amount
    balance_diff_dest = txn.amount
    amount_to_balance_ratio = txn.amount / (txn.sender_balance + 1)

    # Build feature array in the exact order the model expects
    features = np.array([[
        type_mapping[txn.transaction_type],
        amount_log,
        txn.hour,
        balance_diff_orig,
        balance_diff_dest,
        amount_to_balance_ratio,
        txn.sender_balance,
        txn.sender_balance - txn.amount,
    ]])

    # Get fraud probability from model and convert to 0-100 score
    fraud_probability = model.predict_proba(features)[0][1]
    risk_score = int(fraud_probability * 100)

Why do we re-engineer features here?

Your model was trained on engineered features like amount_log and balance_diff_orig. The API receives raw transaction data, so it must transform inputs the same way train_model.py did. If the feature order or transformation differs, the model produces garbage predictions.

The predict_proba(features)[0][1] call returns the probability of fraud (class 1). Multiplying by 100 converts it to a human-readable risk score where 0 means safe and 100 means almost certainly fraud.

Why FastAPI instead of Flask?

FastAPI automatically validates request data against your Pydantic models before your function runs. With Flask, you would write manual validation code for every field.

FastAPI also generates interactive API documentation at /docs with zero extra code. For ML APIs that need clear contracts and fast inference, FastAPI is the production standard at Indian fintechs.

Add risk decision logic and reason codes

A raw score alone is not actionable. Payment systems need a clear decision (allow, flag for review, or block) and human-readable reasons explaining why the transaction was flagged.

  • Directly below risk_score = int(fraud_probability * 100) in the same function, add the decision logic and reason code generation:
    # Apply decision thresholds
    if risk_score >= 80:
        decision = "BLOCK"
    elif risk_score >= 50:
        decision = "FLAG"
    else:
        decision = "ALLOW"

    # Generate reason codes based on transaction characteristics
    reasons = []
    if txn.hour >= 23 or txn.hour <= 4:
        reasons.append("unusual_hour")
    if txn.amount > 50000:
        reasons.append("high_amount")
    if amount_to_balance_ratio > 0.8:
        reasons.append("amount_exceeds_balance_threshold")
    if txn.transaction_type in ["CASH_OUT", "TRANSFER"]:
        reasons.append("high_risk_transaction_type")
    if not reasons:
        reasons.append("normal_pattern")

    # Calculate processing time for monitoring
    processing_time = (time.time() - start_time) * 1000

    return RiskResponse(
        risk_score=risk_score,
        decision=decision,
        reasons=reasons,
        processing_time_ms=round(processing_time, 2),
    )

How do the thresholds and reason codes work?

The thresholds split transactions into three buckets. Scores 80+ get blocked automatically. Scores 50-79 get flagged for human review. Scores below 50 are allowed through.

Reason codes explain why a transaction was flagged, which is critical for regulatory compliance. The RBI requires that flagged transactions have documented justification. If no risk signals are detected, the code returns normal_pattern as the default reason.

  • Save main.py.
  • Confirm the file exists by running:
ls main.py

You should see main.py listed in the output.

Indentation errors in main.py?

The decision logic and reason codes must be indented inside the score_transaction function (4 spaces). If you see an IndentationError later when running the server, check that all code after def score_transaction(txn): is consistently indented.

Help me fix indentation in my FastAPI endpoint function

✔️ Awesome, I've got everything!

Make sure you have saved main.py with all the code above.

ⓧ I'd like to double check the full code

Here is the complete main.py file:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from typing import Literal
import numpy as np
import joblib
import time

app = FastAPI(
    title="UPI Fraud Risk Scoring API",
    description="Real-time fraud risk scoring for UPI transactions. Returns risk score, decision, and reason codes.",
    version="1.0.0",
)

# Load model at startup
model = joblib.load("model/fraud_model.joblib")
feature_names = joblib.load("model/feature_names.joblib")


class TransactionRequest(BaseModel):
    amount: float = Field(..., description="Transaction amount in INR", gt=0)
    transaction_type: Literal["CASH_OUT", "PAYMENT", "CASH_IN", "TRANSFER", "DEBIT"] = Field(
        ..., description="Type of UPI transaction"
    )
    hour: int = Field(..., description="Hour of transaction (0-23 IST)", ge=0, le=23)
    sender_balance: float = Field(..., description="Sender account balance before transaction", ge=0)
    receiver_balance: float = Field(..., description="Receiver account balance before transaction", ge=0)


class RiskResponse(BaseModel):
    risk_score: int = Field(..., description="Risk score from 0 (safe) to 100 (fraud)")
    decision: Literal["ALLOW", "FLAG", "BLOCK"] = Field(..., description="Recommended action")
    reasons: list[str] = Field(..., description="Reason codes explaining the risk assessment")
    processing_time_ms: float = Field(..., description="Time taken to process in milliseconds")


@app.post("/v1/score", response_model=RiskResponse)
def score_transaction(txn: TransactionRequest):
    start_time = time.time()

    type_mapping = {"CASH_OUT": 0, "PAYMENT": 1, "CASH_IN": 2, "TRANSFER": 3, "DEBIT": 4}

    amount_log = np.log1p(txn.amount)
    balance_diff_orig = -txn.amount
    balance_diff_dest = txn.amount
    amount_to_balance_ratio = txn.amount / (txn.sender_balance + 1)

    features = np.array([[
        type_mapping[txn.transaction_type],
        amount_log,
        txn.hour,
        balance_diff_orig,
        balance_diff_dest,
        amount_to_balance_ratio,
        txn.sender_balance,
        txn.sender_balance - txn.amount,
    ]])

    fraud_probability = model.predict_proba(features)[0][1]
    risk_score = int(fraud_probability * 100)

    if risk_score >= 80:
        decision = "BLOCK"
    elif risk_score >= 50:
        decision = "FLAG"
    else:
        decision = "ALLOW"

    reasons = []
    if txn.hour >= 23 or txn.hour <= 4:
        reasons.append("unusual_hour")
    if txn.amount > 50000:
        reasons.append("high_amount")
    if amount_to_balance_ratio > 0.8:
        reasons.append("amount_exceeds_balance_threshold")
    if txn.transaction_type in ["CASH_OUT", "TRANSFER"]:
        reasons.append("high_risk_transaction_type")
    if not reasons:
        reasons.append("normal_pattern")

    processing_time = (time.time() - start_time) * 1000

    return RiskResponse(
        risk_score=risk_score,
        decision=decision,
        reasons=reasons,
        processing_time_ms=round(processing_time, 2),
    )

Your scoring endpoint is ready. In the next step, you will add a health check and test the API locally to see it return real risk scores.

Add Health Check and Test Locally

Your scoring endpoint is built, but before deploying to the cloud you need to verify everything works locally. Deployment platforms like Railway also need a way to confirm your API is alive and healthy after deployment.

In this step, you'll add a health check endpoint that deployment platforms can ping, then start your API server locally and test it with real transaction data.

In this step, get ready to:
  • Add a /health endpoint for deployment monitoring.
  • Start the API locally and explore the auto-generated docs.
  • Test the /v1/score endpoint end-to-end with sample UPI transactions.
Add a /health endpoint to main.py

A health check endpoint is a simple GET route that returns a status message confirming the API is running and the model is loaded. Deployment platforms ping this URL to know whether your service is healthy or needs restarting.

  • In main.py, find the line that says @app.post("/v1/score", response_model=RiskResponse).
  • Add the following code directly above that line:
@app.get("/health")
def health_check():
    return {"status": "healthy", "model_loaded": model is not None}

What does this code do?

This creates a GET endpoint at /health that returns a JSON object with two fields.

  • status confirms the server is responding.
  • model_loaded confirms the XGBoost model was loaded into memory successfully at startup.
  • Save main.py.

Why do deployment platforms need a health check?

When Railway deploys your container, it needs to confirm the app started correctly. It pings the health endpoint repeatedly. If it gets a successful response, traffic starts flowing to your service. If it doesn't, Railway restarts the container.

Start the API server locally

Uvicorn is a lightning-fast ASGI server that runs your FastAPI application. It watches for incoming HTTP requests and routes them to your endpoint functions.

  • Make sure your virtual environment is still active (you should see (venv) in your terminal prompt).
  • Start the API server by running this command:
uvicorn main:app --reload

What does this command do?

  • main:app tells uvicorn to look in main.py for the variable called app.
  • --reload makes the server restart automatically whenever you save changes to your code.

You should see output like Uvicorn running on http://127.0.0.1:8000 in your terminal. The server is now listening for requests.

  • Open your browser and navigate to http://127.0.0.1:8000/health.

You should see a JSON response: {"status":"healthy","model_loaded":true}.

  • Now navigate to http://127.0.0.1:8000/docs to see the auto-generated interactive API documentation.

You should see the Swagger UI page titled "UPI Fraud Risk Scoring API" with your /health and /v1/score endpoints listed.

Where do these docs come from?

FastAPI automatically generates interactive documentation from your Pydantic models and endpoint decorators. The field descriptions, types, and validation rules you defined in TransactionRequest and RiskResponse all appear here. No extra work required.

Server not starting?

  • If you see ModuleNotFoundError, your virtual environment might not be active. Run source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows) and try again.
  • If you see FileNotFoundError: model/fraud_model.joblib, make sure you ran train_model.py in the previous step and the model/ folder exists.
  • If port 8000 is already in use, stop the other process or run uvicorn main:app --reload --port 8001 instead.

Help me debug why my uvicorn server won't start.

Test the scoring endpoint with sample transactions

The interactive docs page lets you send real requests to your API directly from the browser. You'll test with two transactions: one that should be low risk and one that should trigger a high risk score.

  • On the /docs page, click on the POST /v1/score endpoint to expand it.
  • Click the Try it out button on the right.
  • Replace the request body with this low-risk transaction:
{
  "amount": 500,
  "transaction_type": "PAYMENT",
  "hour": 14,
  "sender_balance": 25000,
  "receiver_balance": 10000
}
  • Click Execute.

You should see a response with a low risk_score, a decision of ALLOW, and processing_time_ms under 100ms.

  • Now test a high-risk transaction. Replace the request body with:
{
  "amount": 200000,
  "transaction_type": "CASH_OUT",
  "hour": 2,
  "sender_balance": 210000,
  "receiver_balance": 0
}
  • Click Execute again.

This time you should see a higher risk_score with a decision of FLAG or BLOCK. The reasons array should include unusual_hour, high_amount, and high_risk_transaction_type.

Why these specific test values?

The first transaction is a small payment at a normal hour from an account with plenty of balance. This is textbook low-risk behaviour.

The second is a large cash-out at 2 AM, draining almost the entire account balance into a receiver with zero balance. These are classic fraud indicators that your model and reason codes are designed to catch.

Getting a 422 Validation Error?

  • Make sure your JSON uses the exact field names: amount, transaction_type, hour, sender_balance, receiver_balance.
  • Check that transaction_type is one of the allowed values: CASH_OUT, PAYMENT, CASH_IN, TRANSFER, or DEBIT (all uppercase).
  • Confirm that hour is between 0 and 23 and amount is greater than 0.

Help me fix this 422 validation error from my FastAPI endpoint.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py and your server is still running.

ⓧ I'd like to double check the full code

Here's the complete main.py file with the health check endpoint included:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from typing import Literal
import numpy as np
import joblib
import time

app = FastAPI(
    title="UPI Fraud Risk Scoring API",
    description="Real-time fraud risk scoring for UPI transactions. Returns risk score, decision, and reason codes.",
    version="1.0.0",
)

# Load model at startup
model = joblib.load("model/fraud_model.joblib")
feature_names = joblib.load("model/feature_names.joblib")


class TransactionRequest(BaseModel):
    amount: float = Field(..., description="Transaction amount in INR", gt=0)
    transaction_type: Literal["CASH_OUT", "PAYMENT", "CASH_IN", "TRANSFER", "DEBIT"] = Field(
        ..., description="Type of UPI transaction"
    )
    hour: int = Field(..., description="Hour of transaction (0-23 IST)", ge=0, le=23)
    sender_balance: float = Field(..., description="Sender account balance before transaction", ge=0)
    receiver_balance: float = Field(..., description="Receiver account balance before transaction", ge=0)


class RiskResponse(BaseModel):
    risk_score: int = Field(..., description="Risk score from 0 (safe) to 100 (fraud)")
    decision: Literal["ALLOW", "FLAG", "BLOCK"] = Field(..., description="Recommended action")
    reasons: list[str] = Field(..., description="Reason codes explaining the risk assessment")
    processing_time_ms: float = Field(..., description="Time taken to process in milliseconds")


@app.get("/health")
def health_check():
    return {"status": "healthy", "model_loaded": model is not None}


@app.post("/v1/score", response_model=RiskResponse)
def score_transaction(txn: TransactionRequest):
    start_time = time.time()

    type_mapping = {"CASH_OUT": 0, "PAYMENT": 1, "CASH_IN": 2, "TRANSFER": 3, "DEBIT": 4}

    amount_log = np.log1p(txn.amount)
    balance_diff_orig = -txn.amount
    balance_diff_dest = txn.amount
    amount_to_balance_ratio = txn.amount / (txn.sender_balance + 1)

    features = np.array([[
        type_mapping[txn.transaction_type],
        amount_log,
        txn.hour,
        balance_diff_orig,
        balance_diff_dest,
        amount_to_balance_ratio,
        txn.sender_balance,
        txn.sender_balance - txn.amount,
    ]])

    fraud_probability = model.predict_proba(features)[0][1]
    risk_score = int(fraud_probability * 100)

    if risk_score >= 80:
        decision = "BLOCK"
    elif risk_score >= 50:
        decision = "FLAG"
    else:
        decision = "ALLOW"

    reasons = []
    if txn.hour >= 23 or txn.hour <= 4:
        reasons.append("unusual_hour")
    if txn.amount > 50000:
        reasons.append("high_amount")
    if amount_to_balance_ratio > 0.8:
        reasons.append("amount_exceeds_balance_threshold")
    if txn.transaction_type in ["CASH_OUT", "TRANSFER"]:
        reasons.append("high_risk_transaction_type")
    if not reasons:
        reasons.append("normal_pattern")

    processing_time = (time.time() - start_time) * 1000

    return RiskResponse(
        risk_score=risk_score,
        decision=decision,
        reasons=reasons,
        processing_time_ms=round(processing_time, 2),
    )

Your fraud scoring API is working locally with sub-100ms response times. Next up, you'll package it into a Docker container and deploy it to Railway so anyone on the internet can call your API.

Dockerize and Deploy to Railway

Your fraud scoring API works locally. Now it needs to be accessible to the outside world so any fintech app can call it.

In this step, you will package your application into a Docker container, push it to GitHub, and deploy it to Railway to get a live public URL.

In this step, get ready to:
  • Write a Dockerfile to containerize the application.
  • Push your code to a GitHub repository.
  • Deploy to Railway and get a live public URL.
Write the Dockerfile

A Dockerfile is a recipe that tells Docker how to package your app into a portable container. Railway reads this file to build and run your service.

  • Create a new file called Dockerfile in your upi-fraud-api folder (the same folder where main.py lives).
  • Paste the following into Dockerfile:
# Use a lightweight Python base image
FROM python:3.11-slim

# Set the working directory inside the container
WORKDIR /app

# Copy requirements first for Docker layer caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy the rest of the application
COPY . .

# Expose the port FastAPI runs on
EXPOSE 8000

# Start the server
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

What does this Dockerfile do?

  • FROM python:3.11-slim starts from a minimal Python image (much smaller than the full image).
  • COPY requirements.txt is copied first so Docker can cache the dependency install layer. If your code changes but dependencies don't, rebuilds are faster.
  • COPY . . copies all your project files (including the model/ directory) into the container.
  • CMD tells Docker to start uvicorn on port 8000, listening on all network interfaces (0.0.0.0).
  • Save Dockerfile.
  • Confirm the file exists by running:
ls Dockerfile

You should see Dockerfile printed in the output.

Don't see the file?

Make sure you saved the file with the exact name Dockerfile (capital D, no file extension). It should be in the same folder as main.py.

help me find why my Dockerfile isn't showing up

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Push your code to GitHub

Railway deploys by pulling your code from a GitHub repository. You need to push your project files there first.

  • First, create a .gitignore file to exclude the large dataset from your repository by running:
echo "paysim_data.csv" > .gitignore

Why exclude the dataset?

The PaySim CSV is hundreds of megabytes. GitHub has a 100MB file size limit. Your deployed API only needs the trained model file (model/fraud_model.joblib), not the raw training data.

  • Open GitHub in your browser and create a new repository.
  • Click the + icon in the top right, then click New repository.
  • Enter upi-fraud-api as the repository name.
  • Leave it set to Public.
  • Do NOT check any of the initialization boxes (no README, no .gitignore, no license).
  • Click Create repository.

GitHub shows you a set of commands for pushing an existing repository. Use the commands below instead (they are identical but include context).

  • Initialize git and push your code by running these commands:
git init
git add .
git commit -m "Initial commit: fraud scoring API"
git branch -M main
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/upi-fraud-api.git
git push -u origin main

What do these commands do?

  • git init creates a new git repository in your project folder.
  • git add . stages all files (except those in .gitignore).
  • git commit saves a snapshot of your files.
  • git push uploads everything to your GitHub repository.

If prompted to authenticate, follow the on-screen instructions to log in with your GitHub credentials.

  • Refresh your GitHub repository page in the browser.

You should see your files listed: main.py, train_model.py, requirements.txt, Dockerfile, and the model/ directory.

Push failed or authentication error?

If you see a permission denied error, you may need to set up a Personal Access Token. Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic) and generate a token with repo scope. Use this token as your password when prompted.

help me push my code to GitHub

Deploy to Railway

Railway automatically detects your Dockerfile and builds the container for you. All you need to do is connect your GitHub repo.

  • Open railway.app in your browser and sign in to your account.
  • Click New Project on the dashboard.
  • Select Deploy from GitHub repo.

First time connecting GitHub?

If Railway hasn't been linked to your GitHub account yet, you will see a prompt to connect it. Click Configure GitHub App and grant Railway access to your repositories. You can choose to give access to all repos or just the upi-fraud-api repo.

  • Search for upi-fraud-api and click on it.
  • Click Deploy Now.

Railway starts building your Docker image. You can watch the build logs in real time on the Project Canvas.

How long does the build take?

The first build takes 2-5 minutes because Railway needs to download the Python base image and install all your dependencies (XGBoost, scikit-learn, etc.). Future deployments are faster thanks to Docker layer caching.

Once the build completes and the deployment status shows a green checkmark, you need to generate a public URL.

  • Click on the deployed service tile on your Project Canvas.
  • Open the Settings tab.
  • Scroll down to Networking > Public Networking.
  • Click Generate Domain.

Railway generates a public URL ending in .up.railway.app. Copy this URL.

  • Verify your deployment is live by opening this URL in your browser:
[[RAILWAY_URL="your-railway-url.up.railway.app"]]/health

You should see the JSON response:

{"status": "healthy", "model_loaded": true}
  • Now test the scoring endpoint by opening your API docs:
[[RAILWAY_URL="your-railway-url.up.railway.app"]]/docs
  • On the /docs page, expand the POST /v1/score endpoint.
  • Click Try it out.
  • Paste this sample transaction into the request body:
{
  "amount": 75000,
  "transaction_type": "CASH_OUT",
  "hour": 2,
  "sender_balance": 80000,
  "receiver_balance": 5000
}
  • Click Execute.

You should see a response with a high risk score, a BLOCK or FLAG decision, and reason codes like unusual_hour and high_amount. Your API is live.

Seeing a 502 error or application failed to start?

  • Check the deploy logs on Railway for error messages. Click on your service, then the Deployments tab, and click the latest deployment to see logs.
  • If you see ModuleNotFoundError, double check that requirements.txt includes all packages and was pushed to GitHub.
  • If you see FileNotFoundError: model/fraud_model.joblib, the model/ folder was not pushed. Run git add model/ and push again.
  • Railway free tier has peak-hour deployment restrictions (8AM-8PM local time). Try deploying outside those hours or upgrade to the Hobby plan.

help me fix my Railway deployment

Your fraud scoring API is now live and publicly accessible. Any application on the internet can call your /v1/score endpoint to get real-time risk decisions. Next up, you will extend the API with batch scoring in the Secret Mission.

Secret mission

Add Batch Scoring Endpoint

Real fraud detection systems don't score one transaction at a time. They process thousands in bulk. Can you extend your API with a batch endpoint that scores multiple transactions and returns aggregate risk analytics?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Your Railway deployment has ongoing costs after the free trial ends, so review your options below.

Cost warning

Railway's free trial gives you $5 of credit for 30 days. After the trial, your deployment will stop unless you upgrade to the Hobby plan at $5/month.

If you are not actively using the API, consider pausing or deleting the Railway service to avoid charges.

Resources you used:

  • Railway deployment (cloud, has ongoing cost after free trial).
  • GitHub repository (cloud, free to keep).
  • Python virtual environment and project folder (local).
  • Trained XGBoost model file (local).
  • PaySim dataset CSV file (local).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep your API live for testing and demos.

  • Your Railway deployment stays live at its public URL.
  • Your GitHub repo remains connected for future deploys.
  • Monitor your Railway usage dashboard to track remaining trial credit.

Pause - I'll come back to this later

Shut down the Railway deployment to stop accruing costs, but keep all your local files and GitHub repo intact.

  • Go to your Railway project dashboard and click on your service.
  • Click Settings, scroll down to Delete Service, and remove the service. Your GitHub repo and local files remain untouched.
  • When you are ready to resume, reconnect your GitHub repo to a new Railway service to redeploy in minutes.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible for cloud resources.

  • In Railway, open your project and click Settings. Scroll to Danger and click Delete Project.
  • Delete the GitHub repository from your GitHub account settings if you no longer need it.
  • Deactivate and remove your local virtual environment by running deactivate in your terminal, then delete the project folder from your Desktop.
  • Delete the paysim_data.csv file if it is stored separately from your project folder.

That's a Wrap!

That's a Wrap!

Nice work! You just built and deployed a production-grade AI fraud detection API that scores UPI transactions in real time.

You've learned how to:

  • Trained an XGBoost model on financial transaction data and achieved 89%+ ROC-AUC using feature engineering and class imbalance handling.
  • Built a FastAPI REST service with a /v1/score endpoint that returns risk scores, decisions, and reason codes under 100ms.
  • Containerized the application with Docker and deployed it to Railway as a live, publicly accessible API with auto-generated documentation.
  • Secret Mission: Added a /v1/batch endpoint that scores multiple transactions at once and returns aggregate risk analytics.

Ready to quiz yourself?