Docs
Risk-score methodology
The GeoQ risk score is deliberately simple and fully documented. There is no machine-learning black box — you can reproduce any score by hand. Every response also carries reasons[]: the exact signal keys that contributed, so you can audit why a score fired.
The formula
score = min(100, Σ weight(signal) for each triggered signal) // then, if the IP is a benign network kind, cap it: if (is_relay || connection_type === "satellite" || is_public_resolver) score = min(score, 20) // reason: "benign_network_kind"
Signal weights
| Signal | Weight | Why |
|---|---|---|
is_tor | +45 | Tor exit node — strong anonymisation |
is_proxy | +40 | Open/anonymising proxy (residential-proxy detection in beta) |
is_drop_listed | +40 | IP is on the Spamhaus DROP list (do-not-route, known hostile) |
connection_type=="datacenter" | +35 | Hosting / cloud range, not a residential ISP |
is_bogon | +30 | Bogon — unallocated or reserved space that should never source traffic |
is_vpn | +30 | Known commercial VPN range |
rpki=="invalid" | +20 | Route origin fails RPKI validation (only "invalid" scores) |
Levels
| Level | Score range |
|---|---|
low | 0–29 |
medium | 30–59 |
high | 60–100 |
The benign-network suppressor
Some networks look like a datacenter but belong to ordinary people. Apple's iCloud Private Relay, Starlink satellite links and public DNS resolvers all exit from hosting-style ranges — and scoring them as fraud would punish real users.
So after summing the weights, GeoQ applies one rule: if the IP is a relay (is_relay), a satellite (connection_type === "satellite") or a public resolver (is_public_resolver), the score is capped at 20 and benign_network_kind is added to reasons[]. It is a cap, not a negative weight — the score never goes below what the other signals produced, it just can't exceed 20 for a benign network kind.
This is why an iCloud Private Relay user on what looks like a hosting IP comes back low, not medium. You can still see every signal that fired; the suppressor only changes the headline score so you don't have to special-case these networks yourself.
recent_abuse is beta and carries zero weight today — it's surfaced as a signal you can read, not yet a contributor to the score.
Why verified bots score zero
There is no weight for is_verified_bot, and it never appears in risk.reasons. That signal identifies a verified good crawler (Googlebot, Bingbot, …) matched against the operator's published ranges — the bot you must not block. A verified Googlebot is not fraud, so adding risk for it would be wrong. It is not behavioural bad-bot detection; that's on the roadmap. Use is_verified_bot to allow-list good crawlers, not to penalise traffic.
Worked examples
- Datacenter only → 35 →
medium. (reasons: ["connection_type:datacenter"]) - VPN + datacenter → 30 + 35 = 65 →
high. - Tor + datacenter → 45 + 35 = 80 →
high. - Drop-listed + bogon → 40 + 30 = 70 →
high. - Tor + proxy + datacenter → 45 + 40 + 35 = 120 → capped at 100 →
high. - iCloud Private Relay on a hosting IP → 35 from the network kind, but
is_relaycaps it → 20 →low. (reasonsincludesbenign_network_kind.) - Starlink (satellite) user → benign network kind → capped at 20 →
low. - Verified Googlebot on a datacenter IP → 35 (datacenter only; verified-bot adds 0) →
medium. - No signals → 0 →
low.
Reproduce it yourself
// is_verified_bot has no weight — a verified good crawler is not fraud. // recent_abuse is beta and weighted 0 today. const WEIGHTS = { is_tor: 45, is_proxy: 40, is_drop_listed: 40, is_vpn: 30, is_bogon: 30, // network kind: datacenter scores; satellite is benign (see suppressor). connection_type: (v) => (v === "datacenter" ? 35 : 0), rpki: (v) => (v === "invalid" ? 20 : 0), }; function scoreOf({ signals, network }) { const reasons = []; let score = 0; for (const k of ["is_tor", "is_proxy", "is_drop_listed", "is_vpn"]) { if (signals[k]) { score += WEIGHTS[k]; reasons.push(k); } } if (network.is_bogon) { score += WEIGHTS.is_bogon; reasons.push("is_bogon"); } if (signals.connection_type === "datacenter") { score += 35; reasons.push("connection_type:datacenter"); } if (network.rpki === "invalid") { score += 20; reasons.push("rpki:invalid"); } score = Math.min(100, score); // benign-network suppressor: relay / satellite / public resolver cap at 20. if (signals.is_relay || signals.connection_type === "satellite" || signals.is_public_resolver) { score = Math.min(score, 20); reasons.push("benign_network_kind"); } const level = score >= 60 ? "high" : score >= 30 ? "medium" : "low"; return { score, level, reasons }; }
Why we publish this
A score you can't explain is a score you can't defend — to your users, your auditors or a regulator. By documenting the formula we let you audit every decision, tune your own thresholds, and avoid relying on GeoQ as the sole basis of an automated decision about a person. See our acceptable use policy and the accuracy benchmark.