← System design

Financial & Trading

Design Real-time Fraud Detection

Every card payment pauses for a moment while someone decides whether to trust it. That someone is a risk engine with tens of milliseconds and a few hundred signals to choose between allowing, challenging or declining. Get it wrong one way and fraudsters drain stolen cards; get it wrong the other way and honest customers are turned away at the till.
Core: streaming features + rules and model + delayed labels24K decisions a second at peakp99 under 80 msExplainable declines

01

What the risk engine must do

02

What the risk engine must do

Picture a payments company that processes card payments for a few hundred thousand online merchants. Before each payment goes to the card network, the payments service calls our risk engine and waits for an answer. The same engine also scores logins, payout changes and new card enrolments, but card payments set the hardest budget, so design for them first.

Functional
  • Return a decision for every payment: allow, challenge or decline. Challenge means step-up authentication such as 3-D Secure or a one-time code.
  • Combine a learned model with explicit rules that analysts can write, test and switch on without a deploy.
  • Let each merchant tune its risk appetite: a gift card seller and a grocery chain should not share a threshold.
  • Attach the main reasons to every decision, so reviewers, merchants and regulators can see why a payment was stopped.
  • Take in outcomes as they arrive (chargebacks, fraud reports from cardholders, analyst verdicts) and use them to retrain.
  • Replay historical traffic against a new model or rule before it goes live.
Non-Functional
  • Decision latency of p99 under 80 ms inside our network, p50 around 25 ms.
  • Available 99.99%. If the engine cannot answer, payments still need a decision, so degradation must be designed.
  • Behaviour from the last few seconds counts: a burst of attempts on one card must be visible to the very next score.
  • Keep the false decline rate low. A blocked good customer often never comes back, and merchants notice.
  • Every decision is reproducible later: same inputs, same model version, same answer.
Questions to ask up front

Who carries the loss on a fraudulent payment: us, the merchant or the card issuer? That decides how aggressive the thresholds should be. Is step-up authentication available for every payment, or only some regions? And what is the latency budget the payments service gives us, including network time? The whole design is shaped by that one number.

02

Sizing the engine

03

Sizing the engine

Assume 520 million payments a day, a peak of roughly four times the average during holiday evenings, and about 350 model features per decision. Fraud is rare: say 0.15% of payments are later confirmed fraudulent.

QuantityWorkingResult
Average scoring rate520M / 86,400 sabout 6,000 / s
Peak scoring rate4x averageabout 24,000 / s
Feature reads at peak24K x 6 entity lookups (card, account, device, IP, merchant, email)about 144,000 key reads / s
Event updates into the streampayments plus logins and auth results, about 3x paymentsabout 70,000 events / s at peak
Online feature store900M entities x about 1.5 KB of rolling counters and profilesabout 1.4 TB, held in memory or on fast SSD
Logged feature vectors520M x 350 floats, compressed to about 600 Babout 310 GB a day
Fraud cases per day520M x 0.15%about 780,000
Challenged paymentstarget 1.5% of trafficabout 7.8M a day
Manual review queue0.02% sent to analystsabout 100,000 a day, so reviewers need tooling, not just a list
Label arrivalchargebacks filed by cardholdershalf within 2 weeks, the tail out to 120 days
The awkward numbers

Two numbers drive the design. First, 144,000 feature reads a second inside a 80 ms budget, which forces a purpose-built online store and parallel fetches. Second, the labels arrive weeks late. A model trained on last week's data has not yet heard about most of last week's fraud.

Imbalance is the default

With 0.15% positives, a model that always says "allow" is 99.85% accurate and useless. Judge models by fraud caught at a fixed false decline rate, or by money saved per thousand decisions, never by accuracy.

03

API design

04

API design

POST/v1/assessmentsScore one event, synchronously
// X-Deadline-Ms: 70, the caller tells us how long it will wait { "event_id": "pay_2Nf81kQ", "type": "card_payment", "amount_minor": 489900, "currency": "INR", "merchant_id": "m_70412", "card_fingerprint": "cf_9d1e", "account_id": "acc_55q", "device_id": "dv_7b0a", "ip": "203.0.113.77", "shipping_country": "IN" } // 200 { "decision": "challenge", "score": 0.62, "reasons": ["new_device_for_card", "amount_6x_card_median"], "model": "cnp-gbdt-2026-09-28", "ruleset": 412, "degraded": false }
POST/v1/outcomesReport what really happened
{ "event_id": "pay_2Nf81kQ", "outcome": "chargeback_fraud", // or cardholder_confirmed, analyst_legit, challenge_passed "reported_at": "2026-10-19T08:30:00Z", "source": "network_dispute_feed" }
POST/v1/rulesCreate a rule, shadow mode by default
{ "name": "card testing on low-value merchants", "when": "card.attempts_10m >= 8 and card.distinct_merchants_10m >= 5 and amount_minor < 10000", "then": "decline", "mode": "shadow" // logs would-be hits for review before enforcing }
GET/v1/assessments/{event_id}Full record for reviewers and audits
// returns the logged feature vector, per-feature contributions, rules that fired, // model and ruleset versions, and any outcomes attached since
The deadline header

Letting the caller pass its remaining budget means the engine can drop optional work instead of timing out. With 70 ms left it runs everything. With 15 ms left after a slow network hop, it skips the graph lookup and scores with defaults, and says so in the degraded flag.

04

Architecture overview

05

Architecture overview

Decision path, streaming features and the learning loopDiagram
flowchart LR
	P["Payments service"] --> SC["Scoring orchestrator"]
	SC --> RE["Rule engine"]
	SC --> FS[("Online feature store")]
	SC --> GR[("Entity graph lookup")]
	SC --> MS["Model server"]
	SC -. "decision and feature vector" .-> K[["Kafka: assessments"]]
	EV[["Kafka: payment and login events"]] --> FL["Stream processor"]
	FL --> FS
	K --> LAKE[("Feature log in lake")]
	OUT["Outcome feeds"] --> LAKE
	LAKE --> TR["Training and backtests"]
	TR --> MS
	LAKE --> GB["Graph builder"]
	GB --> GR

The engine has a fast loop and a slow loop. The fast loop answers one payment in tens of milliseconds: the orchestrator fans out reads to the feature store and the entity graph, runs pre-model rules, asks the model server for a score, runs post-model policy, and replies. The slow loop learns: every decision is logged together with the exact features used, outcomes are joined on later as they trickle in, and a training job produces the next model, which is backtested and then rolled out in shadow before it is trusted.

A third, middle-speed loop keeps features fresh. Every payment, login and authorisation result is published to Kafka, and a stream processor such as Flink updates rolling counters per card, account, device, IP and merchant within a second or two. The orchestrator also applies the current payment to those counters in memory before scoring, so the burst of attempts happening right now is never invisible.

Scoring orchestrator

Stateless and deadline-aware. It issues all reads in parallel, enforces a timeout on each, fills missing features with defaults and records which ones were missing.

Online feature store

A low-latency KV store such as Aerospike, Redis or a tuned Cassandra cluster, keyed by entity. Each key holds windowed counts (attempts in 1 minute, 10 minutes, 1 hour, 1 day) and slowly changing profile fields.

Rule engine

Evaluates analyst rules compiled into an in-memory expression tree. Pre-model rules handle allowlists and hard blocks; post-model rules turn the score into a decision per merchant.

Model server

Gradient boosted trees served in-process or over a local socket. A few hundred trees over 350 features score in one or two milliseconds on a CPU.

05

Deep dive: fresh features, honest training data, sensible thresholds

06

Deep dive: fresh features, honest training data, sensible thresholds

Spending the 80 ms

Write the budget down before anything else. Every stage gets an allowance, and the orchestrator cuts a stage that overruns rather than letting the whole decision miss.

StageTypicalHard cap
Parse, authenticate caller, load merchant policy from memory1 ms3 ms
Pre-model rules (allowlists, sanctions, known stolen cards)1 ms3 ms
Parallel reads: 6 entity keys plus graph cluster6 to 12 ms25 ms, then defaults
Feature assembly (ratios, deltas, encodings)2 ms5 ms
Model inference2 ms8 ms, then rules-only fallback
Post-model policy and reason codes1 ms3 ms
Network both ways and queueing8 mswhatever is left

Velocity features that are never a step behind

The strongest fraud signals are counts over short windows: how many cards this device has used in ten minutes, how many distinct merchants a card has tried in an hour, how much this account has spent today against its 90-day median. Card testing, where a fraudster checks hundreds of stolen numbers with tiny payments, shows up almost entirely in these counts.

The stream processor keeps each window as a ring of small time buckets, for example sixty one-minute buckets for the hour window, so sliding the window is a cheap shift instead of a recount. But a stream has lag, typically a second or two, and fraud bursts are faster than that. So the orchestrator does one more thing: after reading the stored counters, it adds the current event to them in memory before computing features. The tenth attempt in a burst therefore sees nine prior attempts from the store (or most of them) plus itself, never zero.

Graph signals for rings

One stolen card is a transaction problem. Two hundred stolen cards cycled through twelve devices and three shipping addresses is a ring, and no single payment looks unusual. A batch job builds a graph where cards, accounts, devices, IPs, emails and addresses are nodes and every co-occurrence is an edge, then computes connected clusters and their history: size, age, fraud rate of members. The online path only does a single lookup: which cluster is this device in, and how bad is that cluster. Recomputing clusters every few hours is enough for most rings, and the streaming counters cover the fast attacks in between.

Train on what you served

The classic quiet bug in fraud systems is training-serving skew. Data scientists rebuild features from the warehouse for training, but the warehouse computes "attempts in the last 10 minutes" slightly differently from the stream, or it accidentally includes events that happened after the payment. The model learns from data it will never see in production and performs worse live than it did in the notebook.

The cleanest fix is to log the exact feature vector at scoring time, alongside the decision and versions, and train on those logs. Point-in-time correctness comes free, because the vector was frozen at the moment of the decision. New features need a backfill, which the offline pipeline computes with careful as-of joins, and they enter the log as soon as they ship.

Living with late labels

Chargebacks are the most trustworthy label and the slowest. If you train only on payments old enough to be fully labelled, the model is always three months behind the fraudsters. Mix in faster signals with care: a cardholder reporting fraud to the issuer within days, an analyst verdict, a failed challenge, an account closed for abuse. Weight these by how often they turn out right. Also remember that payments you declined never get a label at all, because they never happened. Hold back a small random slice of risky traffic, for example 0.5% of would-be declines that are sent to challenge instead, so the model keeps learning what it would have missed.

From a decision to the next modelDiagram
flowchart TD
	D["Decision with logged feature vector"] --> J["Join outcomes as they arrive"]
	J --> M{"Old enough or labelled early?"}
	M -->|"yes"| T["Training set"]
	M -->|"no"| WAIT["Wait for maturity"]
	WAIT --> J
	T --> TRN["Train candidate model"]
	TRN --> BT["Backtest on recent weeks"]
	BT --> SH["Shadow scoring on live traffic"]
	SH --> RO["Gradual rollout by merchant segment"]

Turning a score into a decision

The model outputs a probability; the business needs an action. Set thresholds by cost, not by gut feel. For each payment, the expected loss of allowing it is roughly the fraud probability times the amount plus fees. The cost of declining a good payment is the lost margin and a chance of losing the customer. A challenge costs a little friction and some abandonment. The policy picks the cheapest action per band, and merchants with thin margins or high-value goods get their own cut-offs. Reason codes come from per-feature contributions computed from the trees, translated into plain phrases a reviewer can act on.

06

Decisions and their costs

07

Decisions and their costs

Chosen
Gradient boosted trees on the hot path

Strong on tabular data, cheap to serve, tolerant of missing values, and easy to explain feature by feature. Sequence models over a customer's history can be added as an input feature computed asynchronously.

Industry default for a reason
Alternative
Deep models scoring every payment

Better at raw sequences and text such as merchant descriptors, but heavier to serve within a millisecond budget, harder to explain to regulators, and slower to retrain. Useful as enrichment, risky as the sole decider.

As a feature, not the judge
Chosen
Rules and model together

Rules react to a new attack within minutes and encode hard facts such as sanctions. The model generalises to patterns nobody wrote down. The price is governance: rules pile up, so every rule needs an owner, a shadow period and an expiry review.

Fast reaction plus coverage
Rejected
Model only

Clean and elegant, but a new attack keeps working until enough labels arrive to retrain, which can be weeks. Analysts also lose the ability to act on what they see today.

Too slow to react
Chosen
Logged feature vectors as training data

Removes most skew by construction and makes every decision reproducible. Costs about 300 GB a day of storage and means a new feature cannot be trained on until it has been logged or carefully backfilled.

Trust over convenience
Alternative
Recompute features from the warehouse

Lets data scientists experiment freely with any feature over any history. It needs strict point-in-time joins and shared feature definitions, or the offline numbers quietly disagree with the online ones.

For exploration, with care
Chosen
Fail to rules plus a conservative default

If the model or feature store is down, the rules still run, small payments from known customers pass, and large or unfamiliar ones are challenged. Payments keep flowing with slightly more friction.

Degrade, never stall
Rejected
Fail closed or fail fully open

Declining everything during an outage destroys a day of merchant revenue. Allowing everything advertises a free window to every fraud ring that is watching for one.

Both are expensive

07

Failure modes

08

Failure modes

💥
Model server stops answering

A new model version loads with a memory leak and pods restart in a loop. Scoring calls start timing out and the payments service sees its own latency climb.

Inference has an 8 ms cap. On timeout the orchestrator uses a rules-only policy tuned in advance, marks decisions as degraded, and the deploy system rolls back to the last good model automatically when the degraded rate passes a threshold.
🧪
Card testing wave

A botnet runs 40,000 tiny payments an hour across small merchants, each from a new IP. Individually each payment looks harmless.

Velocity counters on card BIN ranges, merchants and device fingerprints catch the pattern; a ready-made rule template lets analysts tighten limits in minutes. Decline with a generic reason so the attacker cannot tell which check fired.
📉
Quiet drift

A new payment method launches, the population shifts, and the model's score distribution moves. Nothing errors, but fraud losses rise for six weeks before chargebacks reveal it.

Monitor score and feature distributions daily against the training window, and watch early proxies such as challenge failure rates. Alert on movement, and keep a retraining pipeline that can ship a fresh model within a day.
🚫
A bad rule declines a whole country

An analyst types > instead of < and pushes a rule live. Within a minute, 12% of all payments in one market are declined.

New rules start in shadow and show their would-be hit rate before enforcement. A rule whose live hit rate exceeds a sanity limit is disabled automatically and its owner paged.
🐢
Stream processor falls behind

A large Flink job restarts and spends ten minutes replaying from Kafka. Velocity counters freeze at their old values, which is exactly what a fast attack wants.

Alert on consumer lag, add a feature that records counter staleness so the model and rules can see it, and lean on the in-request increment and stricter rules while lag is high.
🎭
Fraudsters learn the threshold

A ring probes with varied amounts and finds that payments under a certain value from aged accounts always pass. Losses cluster just below the line.

Never expose scores or specific reasons to the payer, add some randomness near thresholds, and track loss density around each cut-off. Rings that adapt also leave graph traces, which the cluster features catch.

08

How to run the interview

09

How to run the interview

1

Start from the latency budget. Write the milliseconds per stage on the board. It shows you know the engine sits inside someone else's request.

2

Propose rules and a model, and say why. Rules react today, the model generalises. Answering "just use ML" is a red flag for most interviewers.

3

Explain how features stay fresh. Windowed counters from a stream, plus the current event added in memory. This is where card testing is caught.

4

Raise training-serving skew yourself. Logging the served vector is a crisp, senior answer.

5

Talk about labels honestly. They are late, biased by your own declines, and noisy. Mention a holdout slice and early proxy labels.

6

Have an outage answer. State exactly what the payments service gets when you are down, and why that is better than both failing open and failing closed.

7

Pick a metric that respects imbalance. Fraud caught at a fixed false decline rate, or money saved, never accuracy.

09

How the design grows

11

How the design grows

Stage 1: early volume
Rules and a review queue

A handful of rules in application code, a list of suspicious payments for analysts, and blocklists for cards and emails. Cheap, transparent and quickly outgrown.

Stage 2: enough chargebacks to learn from
A first model on daily features

Logistic regression or boosted trees trained on historical outcomes, with features recomputed nightly. It catches slow patterns and misses fast bursts entirely.

Stage 3: attacks move faster than a day
Streaming counters and an online store

A stream processor maintains windowed velocity features within seconds. Rules move into a dedicated engine with shadow mode.

Stage 4: organised rings appear
Entity graph and specialised models

Cluster features from the linkage graph, separate models for card payments, logins and payouts, and logged feature vectors as the training source.

Stage 5: mature programme
Cost-based policy and continuous learning

Thresholds set per merchant from expected loss, automated drift alerts, frequent retrains with backtests, and sequence or text models feeding the trees as extra features.

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.