Financial & Trading
Design Real-time Fraud Detection
01
What the risk engine must do
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.
- 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.
- 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.
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
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.
| Quantity | Working | Result |
|---|---|---|
| Average scoring rate | 520M / 86,400 s | about 6,000 / s |
| Peak scoring rate | 4x average | about 24,000 / s |
| Feature reads at peak | 24K x 6 entity lookups (card, account, device, IP, merchant, email) | about 144,000 key reads / s |
| Event updates into the stream | payments plus logins and auth results, about 3x payments | about 70,000 events / s at peak |
| Online feature store | 900M entities x about 1.5 KB of rolling counters and profiles | about 1.4 TB, held in memory or on fast SSD |
| Logged feature vectors | 520M x 350 floats, compressed to about 600 B | about 310 GB a day |
| Fraud cases per day | 520M x 0.15% | about 780,000 |
| Challenged payments | target 1.5% of traffic | about 7.8M a day |
| Manual review queue | 0.02% sent to analysts | about 100,000 a day, so reviewers need tooling, not just a list |
| Label arrival | chargebacks filed by cardholders | half within 2 weeks, the tail out to 120 days |
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.
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
API design
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
Architecture overview
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 --> GRThe 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.
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.
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.
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.
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
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.
| Stage | Typical | Hard cap |
|---|---|---|
| Parse, authenticate caller, load merchant policy from memory | 1 ms | 3 ms |
| Pre-model rules (allowlists, sanctions, known stolen cards) | 1 ms | 3 ms |
| Parallel reads: 6 entity keys plus graph cluster | 6 to 12 ms | 25 ms, then defaults |
| Feature assembly (ratios, deltas, encodings) | 2 ms | 5 ms |
| Model inference | 2 ms | 8 ms, then rules-only fallback |
| Post-model policy and reason codes | 1 ms | 3 ms |
| Network both ways and queueing | 8 ms | whatever 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.
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
Decisions and their costs
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 reasonBetter 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 judgeRules 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 coverageClean 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 reactRemoves 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 convenienceLets 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 careIf 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 stallDeclining 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 expensive07
Failure modes
Failure modes
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.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.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.An analyst types > instead of < and pushes a rule live. Within a minute, 12% of all payments in one market are declined.
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.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
How to run the interview
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.
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.
Explain how features stay fresh. Windowed counters from a stream, plus the current event added in memory. This is where card testing is caught.
Raise training-serving skew yourself. Logging the served vector is a crisp, senior answer.
Talk about labels honestly. They are late, biased by your own declines, and noisy. Mention a holdout slice and early proxy labels.
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.
Pick a metric that respects imbalance. Fraud caught at a fixed false decline rate, or money saved, never accuracy.
09
How the design grows
How the design grows
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.
Logistic regression or boosted trees trained on historical outcomes, with features recomputed nightly. It catches slow patterns and misses fast bursts entirely.
A stream processor maintains windowed velocity features within seconds. Rules move into a dedicated engine with shadow mode.
Cluster features from the linkage graph, separate models for card payments, logins and payouts, and logged feature vectors as the training source.
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.