Design a payment fraud scoring service
Design the service that scores every card authorisation in 50 ms p99: a real-time feature store with velocity features, rules and a model working as an ensemble, shadow mode and holdouts for safe change, and the chargeback feedback loop that arrives months later.
01Understand the problem and size it
- Set a latency budget (50 ms p99) and spend it stage by stage
- Build velocity features (count and sum per card, device, merchant over sliding windows) with a real-time feature store
- Combine rules and ML so each does what it is good at
- Change models safely with shadow mode, champion/challenger and holdouts, and learn from chargebacks that arrive weeks later
What they probe: how you hit 50 ms with a feature store in the path, how velocity is computed without counting the current transaction twice or missing it, what happens if scoring times out, how training avoids leakage, why declined transactions have no labels. Where candidates slip: calling a batch warehouse in the hot path, no fallback, training on features computed after the fact, ignoring label delay.
A merchant or payment processor sends an authorisation request (card, amount, merchant, device, IP) and must answer approve, decline or challenge (for example 3-D Secure, the card scheme's step-up for online payments) within the card network's time limit. Fraud scoring sits inside that window. A chargeback is the cardholder disputing a transaction through their bank; fraud chargebacks are the main ground truth and can arrive up to around 120 days later under common scheme rules.
Functional requirements
- Score every authorisation and return approve, decline, review or step-up (3-D Secure) with reason codes.
- Support rules authored by fraud analysts without a deploy, and ML models trained by data scientists.
- Compute velocity features over windows from 1 minute to 30 days.
- Run new models and rules in shadow mode and learn from chargebacks and issuer fraud reports.
Non-functional requirements
- 10k transactions/s peak, 2k average.
- 50 ms p99 end to end inside our service.
- 99.99 % availability, with a defined fallback when scoring is slow or down.
- Training data is point-in-time correct (no feature from the future).
Back of the envelope
average 2,000 tps x 86,400 s = 172.8 M transactions / day
peak 10,000 tps
entities per txn card, account, device, IP, email, merchant = 6 keys
online feature reads one batched multi-get per txn -> 10,000 calls / s, 60,000 keys / s
online store size 200 M entities x 1 kB = 200 GB, x3 replicas = 600 GB in memory
decision log 5 kB per txn (features + scores + decision)
-> 172.8 M x 5 kB = 864 GB / day, about 315 TB / year before compression
chargeback rate ~0.1 % of transactions -> ~173k fraud labels / day at most02High-level design and the latency budget
- ACTOR
- EDGE
- SERVICE
- DATA
- Live flow
- Lookup / async
Spending 50 ms
| Stage | Budget (p99) | How |
|---|---|---|
| Ingress, auth, parse | 3 ms | Persistent connections from the gateway, protobuf |
| Online feature fetch | 10 ms | One batched multi-get to an in-memory store in the same zone |
| Request-time features | 3 ms | Amount vs card's average, BIN country vs IP country, local time |
| Rules | 2 ms | Compiled rule set in process |
| Model inference | 8 ms | Gradient-boosted trees in process (often about 1 ms); a neural model via a sidecar with a hard timeout |
| Decision policy, response | 2 ms | Score bands by merchant and amount |
| Headroom | 22 ms | GC pauses, retries inside the budget, tail latency of the store |
Per-stage p99s do not add exactly (tails rarely coincide), but budgeting as if they do gives a safe plan. Everything slow, such as writing the decision log, updating aggregates, opening cases, happens after the response, asynchronously.
Velocity features
Velocity is how much activity an entity has had recently: transactions per card in the last 10 minutes, total amount per card in 24 hours, distinct cards per device in 1 hour, distinct merchants per card in 24 hours. Fraud is bursty (a stolen card is drained fast), so these are among the strongest features.
- Streaming aggregates (Flink or similar) consume the transaction stream and maintain bucketed counters per entity per window, written to the online store. Lag is typically a second or two.
- The current transaction is not in the stream yet, and neither are ones from the last second. So at score time, read the stored buckets and add the current transaction in-request. For card testing (dozens of small authorisations a second), also keep a tiny synchronous counter per card with an atomic increment.
- Distinct counts (cards per device) use HyperLogLog sketches; exact sets are too big.
// Sliding-window velocity from per-minute buckets kept in the online store.
// buckets: map of epoch-minute -> { count, amountMinor } for one entity, pruned beyond 24 h.
interface Bucket { count: number; amountMinor: number }
const MAX_WINDOW_MIN = 24 * 60;
export function velocity(
buckets: ReadonlyMap<number, Bucket>,
nowMs: number,
windowMin: number,
current: { amountMinor: number },
): { count: number; amountMinor: number } {
if (!Number.isInteger(windowMin) || windowMin < 1 || windowMin > MAX_WINDOW_MIN) {
throw new RangeError("windowMin must be an integer in 1..1440");
}
if (!Number.isSafeInteger(current.amountMinor) || current.amountMinor < 0) {
throw new RangeError("amountMinor must be a non-negative integer");
}
const nowMin = Math.floor(nowMs / 60_000);
let count = 1; // include the transaction being scored
let amountMinor = current.amountMinor;
for (let m = nowMin - windowMin + 1; m <= nowMin; m++) { // at most 1,440 iterations
const b = buckets.get(m);
if (b) { count += b.count; amountMinor += b.amountMinor; }
}
return { count, amountMinor };
}03Deep dives: ensemble, safe change, feedback
Rules and ML as an ensemble
| Layer | Good at | Examples |
|---|---|---|
| Hard rules (pre-model) | Compliance, known-bad, instant reaction to a new attack | Sanctioned country, card on block list, more than 5 declines in 10 min on one card |
| Model | Subtle combinations of hundreds of features | Gradient-boosted trees scoring 0 to 1 |
| Policy (post-model) | Business trade-offs per merchant and amount | Score > 0.9 decline; 0.6 to 0.9 step-up with 3-D Secure; amount > $2,000 and score > 0.4 review |
| Override rules | Analyst response during an active attack | Temporarily decline gift card merchants from a new BIN range |
Analysts get a rule language with backtesting (run the rule over last month's labelled data before enabling) and every rule carries an owner and an expiry date. Rules that never expire are how a fraud system ends up declining good customers for an attack that ended two years ago.
Training without leakage
A model trained on features computed today for a transaction from last month sees information the scorer did not have, such as later transactions on the same card. That is label leakage, and it makes offline metrics look far better than production. The fix is to train on the decision log: the exact features served at decision time. For new features, the offline store must support point-in-time joins (feature value as of the transaction timestamp).
Shadow mode, champion/challenger, holdouts
- Shadow mode: the challenger model scores every live transaction alongside the champion; only the champion's score is used. Compare score distributions, decline rates and, as labels mature, catch rates. Shadow costs inference capacity, not risk.
- Champion/challenger: route a small hashed share of traffic (for example 5 % by card) to the challenger's decisions once shadow results look good.
- Holdout: you never learn whether a declined transaction was fraud. Approve a tiny random share of transactions the model would decline (under a strict amount cap) to get unbiased labels. Without it, each model is trained only on what the previous model let through.
Declined transactions get no chargeback, so they look like neither fraud nor good. Retraining on approved transactions only teaches the new model about the fraud the old model missed, and it slowly forgets the fraud the old model caught. A holdout, weighted by the inverse of its sampling rate, is the standard correction.
Chargeback feedback
- Issuer fraud reports (for example Visa TC40 data) arrive in days and flag transactions the cardholder reported, even without a dispute.
- Chargebacks arrive over weeks to months. Labels are only "mature" for transactions older than about 90 to 120 days, so evaluate models on matured windows and use early signals for monitoring.
- Friendly fraud (a real customer disputing a purchase they made) pollutes labels. Keep reason codes and treat them separately.
- Join labels back to the decision log by transaction ID and write them to the offline store for the next training run.
04Failure modes and trade-offs
Failure modes
| Failure | Behaviour | Why |
|---|---|---|
| Scoring exceeds 50 ms | Gateway uses fallback: rules-only result computed first, or approve under a low amount limit and decline above | Decided per merchant; never block all payments |
| Feature store partially unavailable | Score with missing features flagged; the model is trained with missingness as a feature | Missing data should not crash or silently zero |
| Stream lag (velocity stale) | Synchronous per-card counter still catches card testing; alert on lag > 30 s | Bursts are the attack |
| Bad model push | Decline rate monitor per merchant; auto-rollback if it moves > 30 % in 10 min | A spike in declines is an outage |
| Feature drift | Population stability index per feature daily | Upstream schema or behaviour changes break models quietly |
Trade-offs to state
| Choice | Gain | Cost |
|---|---|---|
| In-process GBT vs remote deep model | Predictable 1 to 2 ms | Less accuracy on sequence patterns; mitigate with precomputed embeddings |
| Fail open under a limit vs fail closed | Revenue and customer trust during outages | Fraud exposure capped by the limit |
| Streaming aggregates vs in-request computation | Scales to 30-day windows | A second or two of lag; needs the in-request top-up |
| Holdout approvals | Unbiased labels, honest metrics | Real fraud losses on the holdout, so cap amounts and share |
| Step-up (3-D Secure) vs decline | Saves good customers in the grey zone | Checkout friction and abandonment |
Fraud basis points (fraud losses per $10,000 of volume), false decline rate measured on the holdout, approval rate per merchant, and the share of step-ups that pass. A model with better AUC that raises false declines can lose more money than it saves.
05Final design, follow-ups and self-test
"I'd spend the 50 milliseconds deliberately: one batched read from an in-memory feature store, rules and a gradient-boosted model in process, and everything else async after the response. Velocity comes from streaming per-minute buckets, plus the current transaction added in-request, because the stream lags by a second and card testing doesn't wait. Rules handle compliance and live attacks, the model handles subtle patterns, and a policy table turns scores into approve, 3-D Secure or decline per merchant. New models go through shadow mode then a small challenger slice. I train on the decision log so features are point-in-time correct, and I keep a small capped holdout of would-be declines because otherwise I never learn what my declines cost."
Q1How would you detect card testing, many tiny authorisations on stolen cards?+
Per-merchant and per-device velocity of small amounts, a high share of declines with codes like invalid CVV, many distinct cards per device or IP. A synchronous counter catches it within seconds; respond by stepping up or declining low-value auths from that source.
Q2A merchant wants their own thresholds. How?+
Policy is data keyed by merchant and segment, versioned, with backtesting against that merchant's history before activation.
Q3Why not train on all transactions with the latest features from the warehouse?+
Leakage: later activity on the same card is visible, so offline metrics lie. Use the decision log or point-in-time joins.
Q4How do you scale to 100k tps?+
Partition the feature store by entity key, keep scoring stateless and horizontally scaled, and move rare heavy features to precomputed values. The budget stays the same.
Velocity features come from a streaming job that lags by about 2 seconds. A fraudster fires 30 authorisations on one card within 3 seconds. What design keeps the velocity feature accurate?