Portreeve
explainer · 7 Sept 202612 min read

Real time fraud detection: what fits in 100 ms at checkout

Real time fraud detection is a latency budget plus a fail-open policy. What you can compute in under 100 ms, what belongs in a queue, and how to wire it.

Someone has told you to add real time fraud detection to your signup or your checkout. Every vendor page and every paper uses the phrase without telling you what to put in the request handler.

Real time is two numbers. A latency budget, and a policy for what happens when you blow it. Every other question follows from those two: which signals you can honestly compute inside the request, which ones belong in a queue, and whether the check is ever allowed to hold the response.

Most of what catches abuse at signup and trial is a counter you can read in microseconds, if you precomputed it. The expensive part of fraud detection is not the decision. It is the feature.

Real time is two numbers

Look at what is already on the request path before your check exists.

At checkout, the user has spent thirty seconds on the form. Then you call Stripe to confirm a PaymentIntent, and that call is a round trip to Stripe, which is itself a round trip to the issuer over the card networks. Whatever your fraud check costs, it lands next to a call that already costs real time and that nobody is arguing about.

At signup there is no processor call at all, so your check is the whole added cost. That is the harder budget, and the one people get wrong, because a signup POST that returns in 90 ms and a signup POST that returns in 900 ms both look fine in local dev.

For the order of magnitude: Google's Milliseconds Make Millions study, run with Deloitte across 37 brands, found that a 0.1-second improvement in mobile site speed raised retail conversion by 8.4%. That measured page load, not API handlers, so do not treat it as a law. A hundred milliseconds is a real cost you can absorb. Eight hundred is not.

So: 100 ms of p99 for the check, 400 ms hard deadline including retries and connection setup, fail open past the deadline. Everything below is what those numbers force you to do.

Fraud detection latency: what fits in 100 ms

Four things, and they are all memory access.

Hash lookups against a graph you built earlier. You do not traverse a graph at request time. When an account is convicted, that is when you walk the cluster and write the mark onto every node in it: the hashed email, the device fingerprint, the card fingerprint, the payer wallet. At request time you hash the identity keys off the incoming payload and do three or four key lookups, and a lookup costs the same whether the cluster has four members or four hundred.

This inversion is the whole trick. Write-time expansion, read-time lookup.

Sliding-window velocity counters. Redis documents this as the rate limiter pattern on the INCR page: increment a key per request, set EXPIRE only on the first increment, and the counter survives one window and then removes itself. The docs also flag the race everyone hits, where a client runs INCR and dies before EXPIRE and "the key will be leaked until we'll see the same IP address again." Their fix is a Lua script sent with EVAL:

local current
current = redis.call("incr",KEYS[1])
if current == 1 then
    redis.call("expire",KEYS[1],1)
end

Key that on the card fingerprint, the device, the cluster. Not on the IP alone: shared egress makes per-IP counts untrustworthy, which is why an IP counter can justify a review and never a block. I wrote about picking the key in velocity checks.

Local tables you refresh on a timer. Disposable email domains, datacenter and VPN ranges, known-bad ASNs. These are a few megabytes. Load them into a radix tree or a hash set at boot, refresh from a background job every few minutes, and answer from memory. The moment you turn an IP reputation check into a per-request HTTPS call to somebody else, you have put their p99 inside your p99 and their outage inside your outage.

Device-keyed probe windows. Distinct card fingerprints seen on one device token in the last ten minutes is a sorted set with a score of the timestamp. ZADD, ZREMRANGEBYSCORE, ZCARD. That single counter is what separates a card tester from a customer whose first card declined, and it is the one signal that turns card testing from something you notice tomorrow into something you decline right now.

Add those up and the request-time cost is one network hop to wherever the state lives, plus a few microseconds of comparison.

What has to go to a queue

Anything past one hop of the graph. One hop is a lookup. Two hops is a traversal with unbounded fan-out, and you cannot put an unbounded operation inside a bounded budget. Expand the cluster in a worker after the fact and write the marks back.

Third-party enrichment. Email reputation vendors, phone lookups, BIN databases hosted by someone else. Each one is a fourth party sitting inside your checkout, and their bad afternoon becomes your bad afternoon. If you need one, call it asynchronously after the response and let the result land as a mark on the identity for next time.

Model training, and any feature that needs a join. This is where "just run the ML model inline" falls over. Scoring a gradient-boosted tree on a prepared feature vector is fast. Computing the feature vector is not, if half the features need a query against your production Postgres to count this user's prior orders joined against dispute outcomes. That join is where 800 ms p99s come from. If a feature cannot be maintained as a counter or a precomputed attribute on the identity, it is a batch feature and it belongs in the version of the model you ship tomorrow, not in the request.

Outcomes. A chargeback arrives weeks after the charge. Stripe's early fraud warnings arrive sooner and are still nowhere near synchronous. Learning happens asynchronously. Reading what you learned happens in a microsecond. Keep those two apart and most of the architecture designs itself.

Fail open or fail closed

The deadline is enforced client-side, not hoped for.

const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 400);
try {
  const res = await fetch(url, { signal: ac.signal, ... });
  ...
} catch {
  verdict = { verdict: "allow", degraded: true };
} finally {
  clearTimeout(timer);
}

"Fail closed for safety" sounds responsible and is usually wrong for a small software business. Fail closed means an outage in your fraud vendor becomes an outage in your revenue, and vendor outages are more likely than a coordinated attack timed to one. An attacker cannot exploit your fail-open window unless they can also cause your vendor's outage, and if they can do that they have a more profitable business than defrauding you.

Fail closed is defensible when the action is irreversible and large: a payout, a gift card issued, a crypto withdrawal, a wire. For a signup, a trial start, or a $19 subscription, the expected loss from letting an attacker through for four minutes is smaller than the expected loss from turning away every legitimate customer for the same four minutes.

Then record the degraded events. If you failed open for four minutes you should be able to name the 300 signups that went unscreened, replay them through the queue when the check is back, and revoke the ones that turn out bad. Fail open without replay is just not checking.

Block inline, review out of band

There is an asymmetry in the cost of the two errors, and it is not close. On the Hacker News thread "Automatic fraud detection is making my life hell", chinchilla2020 put it plainly: "The cost of the false positives are much higher than the false negatives."

That asymmetry is what should decide which verdicts are allowed to be synchronous. Reserve an inline block for signals that can stand up with no human in the loop and no chance to appeal in the next second:

  • the identity belongs to a cluster you already convicted
  • the card fingerprint is one you already marked as confirmed abuse
  • six distinct cards on one device token in ten minutes

Everything softer than that should let the request through and get recorded. A new account on a residential VPN with a domain you have never seen is suspicious and is also a normal Tuesday for a privacy-conscious developer. Let them in, flag the event, and if you decide against them ten minutes later, revoke by webhook rather than by making them argue with a login form. I covered the revoke side in handling a fraud review webhook.

Where Stripe Radar already sits in the path

If you take card payments, you already have a synchronous fraud check, and it costs you nothing in latency because it sits inside a call you were making anyway. Radar screens the payment attempt, and its built-in CVC and postal-code rules also fire when you attach a card to a customer.

Its rule types map onto the same three verdicts you would build yourself. Stripe's rules documentation is explicit that review is not a hold on the customer: "Stripe still processes payments normally when they match a review rule's criteria." Request-3DS rules are evaluated before the others and review rules after block rules, while allow rules override everything, which is why the docs tell you to implement them minimally.

Turn it on. Then understand its shape, which is that it only sees payments. Radar is not in the path for the signup POST, the trial that starts with no card, the login from an unrecognized device, or the API key that starts burning your inference budget on a free plan. Stripe's card testing guidance is direct about the dependency in the other direction too: "the more data your integration provides, the more successful card testing prevention can be." The processor sees what you send it, at the moment you send it, and nothing before. More on that boundary in what Stripe Radar does and does not cover.

Your own check fills the four moments the processor never sees, plus whatever you know about the account that Stripe does not.

Wiring a real-time fraud detection system

The handler order, for any vendor or for something you build:

  1. Collect the device signal in the browser, before submit. It has to exist by the time the server asks, because the strongest card-testing signals are keyed on the device, not the IP.
  2. Call the check before the processor call. A blocked checkout attempt that never reaches Stripe does not add a decline to your authorization rate, and Stripe warns that a high decline rate damages your reputation with issuers and networks well after the attack stops.
  3. Bound it. Deadline, abort, fail open, mark the result degraded.
  4. On block, return a generic decline with the same shape and roughly the same timing as any other failure. A distinguishable error message is a free oracle for the person probing you.
  5. On allow or review, continue. Review does not hold the response.
  6. Send feedback when you know the outcome. The chargeback, the confirmed abuse, the account you cleared by hand. That is the only path by which today's losses become tomorrow's inline signal.

This is the tool I built for that, so read the next two paragraphs accordingly. Portreeve is one API call at five moments (signup, trial_start, trial_convert, checkout_attempt, login) returning allow, review, or block with reason codes in under 100 ms. The SDK fails open by default: on timeout or outage it returns allow with degraded: true and a *_failopen reason code inside a 400 ms budget, and fail-closed is a client-side option if your action is one of the irreversible ones.

A review never blocks the user, and a later deny arrives at your server as a signed webhook so you can revoke, which is what lets the engine flag aggressively without costing you customers (handling verdicts). Two of the block-strength card rules read a device-keyed counter, so the browser snippet is not optional if you want card testing blocked at ordinary amounts: without @portreeve/browser those shapes top out at review (device fingerprinting). It is in open beta with no uptime SLA yet, which is another argument for the fail-open default.

import { Portreeve } from "portreeve";
const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);

const result = await portreeve.verdict({
  event_type: "checkout_attempt",
  external_user_id: userId,
  ip,
  device_token,
  email,
  payment: { card_fingerprint, card_funding, amount, currency },
});

if (result.verdict === "block") {
  return res.status(402).json({ error: "Your card was declined." });
}
// allow or review: proceed to the PaymentIntent

Then, when the dispute lands weeks later:

await portreeve.feedback(
  { kind: "external_user_id", value: userId },
  "chargeback",
  "stripe dispute du_...",
);

What to do on Monday

Write down the p99 you will accept and the deadline you will enforce, put both in the code as constants, and add a metric for how often you fail open. Then sort your signals: memory access inline, network calls and joins to the queue. If a vendor cannot tell you their p99 or what happens at their deadline, you have learned the important thing about them.

If you want the inline half without building the counters and the graph yourself, start on the free tier: 1,000 screened events a month, no card, test mode isolated from live.

← Back to all posts