How to Score Fraud & Risk from an IP Address
Turn one IP lookup into a risk score — network type, anonymiser flags, threat level, and country mismatch — with a full Python example tested against the live API.
You can score risk from a single IP lookup using four fields — and this guide builds that scorer end to end. Fraud teams don't need another verdict engine; they need signals: whether a request comes from a consumer broadband line or a cloud/business network, whether it's hiding behind VPN or Tor, whether it carries a known-threat flag, and whether its country matches the billing address. Below, each signal becomes a few points of an additive score you feed into the risk stack you already run.
The examples here are Python, standard library only — but the same logic maps directly onto our official SDKs for TypeScript, Go, Ruby, Java and 7 more. Field paths and the worked outputs below are all verified against the live API.
1Get a key, and call the API securely
Create a free account here — no credit card, 2,000 lookups/day. Paid plans start at $2.50/month with higher quotas and the bulk endpoint for scoring events at scale.
You can pass the key as an ?apiKey= query parameter, but the safer
option is to send it as an Authorization: Bearer header — that keeps
it out of request URLs, server logs, and shell history. Load it from an
environment variable rather than hardcoding it. Both styles below return the same
response:
# Query param (quickest to try):
curl -sS "https://api.ipwho.org/ip/185.220.101.1?apiKey=YOUR_API_KEY"
# Or send the key as a header, so it never lands in URLs, shell history, or logs:
curl -sS -H "Authorization: Bearer YOUR_API_KEY" \
"https://api.ipwho.org/ip/185.220.101.1"2Read the four fields that carry the signal
Everything you need ships in the standard response — no add-ons, no tiers.
Fields nest under data: network type at
connection.connection_type (one of Cable/DSL,
Cellular, Corporate, Satellite),
anonymiser flags at security.isVpn / security.isTor,
the threat level at security.isThreat (a string —
"low" / "medium" / "high", not a boolean),
and location at geoLocation.countryCode.
import os, urllib.request, json
API_KEY = os.environ["IPWHO_API_KEY"] # load from the environment, never hardcode
def lookup(ip):
req = urllib.request.Request(
f"https://api.ipwho.org/ip/{ip}",
headers={
"Authorization": f"Bearer {API_KEY}", # key stays out of the URL
"User-Agent": "MyApp/1.0",
},
)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read())
data = lookup("185.220.101.1")["data"]
# The four fields that carry the fraud signal — all in the standard response:
conn_type = (data.get("connection") or {}).get("connection_type") # Cable/DSL | Cellular | Corporate | Satellite
asn_org = (data.get("connection") or {}).get("asn_org") # "Amazon.com, Inc.", ...
country = (data.get("geoLocation") or {}).get("countryCode") # "US" (can be None)
sec = data.get("security") or {}
is_vpn = sec.get("isVpn") # bool
is_tor = sec.get("isTor") # bool
threat = sec.get("isThreat") # STRING level: "low" | "medium" | "high"3Network type is a useful signal — weight it, but don't over-trust it
Automated fraud often originates from cloud and hosting networks. In the response
those report connection_type: "Corporate", while home users typically
show Cable/DSL or Cellular. But be careful:
Corporate is a mixed bucket — it also covers office networks,
universities, business ISPs and corporate VPN egresses, all full of legitimate
people. So on a flow you expect a consumer to use, a Corporate network
is worth points toward review, not an auto-decline. Keep the score additive
so no single signal is a verdict:
def risk_score(data, expect_consumer=True, billing_country=None):
"""Turn one IP lookup into an additive risk score. Signals, not a verdict."""
conn = data.get("connection") or {}
geo = data.get("geoLocation") or {}
sec = data.get("security") or {}
score, reasons = 0, []
# 1. Network type — a useful signal, not proof. connection_type is one of
# Cable/DSL, Cellular, Corporate, Satellite. "Corporate" covers cloud and
# hosting BUT ALSO offices, universities and corporate VPNs — real people.
# So on a consumer flow, treat it as points toward review, not a verdict.
if expect_consumer and conn.get("connection_type") == "Corporate":
score += 40; reasons.append(f"corporate/hosting network ({conn.get('asn_org')})")
# 2. Anonymiser flags — raise risk, never hard-block on their own.
if sec.get("isTor"): score += 35; reasons.append("Tor exit node")
if sec.get("isVpn"): score += 20; reasons.append("VPN detected")
# 3. Known-threat level — isThreat is a STRING, not a boolean.
threat = (sec.get("isThreat") or "low").lower()
if threat == "high": score += 30; reasons.append("known high threat")
elif threat == "medium": score += 15; reasons.append("known medium threat")
# 4. Country mismatch vs billing — context, not proof.
ip_country = geo.get("countryCode")
if billing_country and ip_country and ip_country != billing_country:
score += 25; reasons.append(f"IP {ip_country} != billing {billing_country}")
return score, reasons4Map the score to bands, never a hard block
Plenty of legitimate users browse via VPN or from a corporate VPN egress. Use the score to allow, challenge (MFA / email verification), or review — and always let a real customer prove themselves through your normal flow.
# Turn the score into an action — bands, not a binary block.
def decide(score):
if score >= 60: return "review" # manual review / step-up verification
if score >= 30: return "challenge" # MFA / email verification
return "allow"
data = lookup("185.220.101.1")["data"]
score, reasons = risk_score(data, expect_consumer=True, billing_country="US")
print(decide(score), score, reasons)
# -> review 115 ['corporate/hosting network (...)', 'Tor exit node', 'known medium threat', 'IP DE != billing US']5Null fields are normal — default every access
Infrastructure and mobile IPs often return null for region, city,
and postal code. Country-level accuracy is high; city/region can be absent — so
fall back to country-level logic rather than assuming the fine-grained fields
exist.
# Null fields are normal — handle them, don't crash on them.
# geoLocation.region / city / postal_Code are often None (infra & mobile IPs).
# Default every nested access rather than assuming the field exists:
geo = data.get("geoLocation") or {}
region = geo.get("region") # may be None — that's expected, not an error
if region is None:
pass # fall back to country-level logic; country accuracy is high, city/region can be absent6The full script — copy, paste, run
Everything together. Export your key, pass an IP and optional billing country. Standard library only — no pip install. Tested against the live API (2026-10-26).
#!/usr/bin/env python3
"""Score a single IP for fraud risk using the IPWho IP geolocation API.
Signals, not verdicts — feed the score into your existing risk engine.
Usage: IPWHO_API_KEY=sk.xxx python3 risk_score.py <ip> [billing_country]
"""
import os, sys, urllib.request, json
API_KEY = os.environ["IPWHO_API_KEY"] # export it; never commit a key to source
def lookup(ip):
req = urllib.request.Request(
f"https://api.ipwho.org/ip/{ip}",
headers={"Authorization": f"Bearer {API_KEY}", "User-Agent": "IPWho-Risk/1.0"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read())
def risk_score(data, expect_consumer=True, billing_country=None):
conn = data.get("connection") or {}
geo = data.get("geoLocation") or {}
sec = data.get("security") or {}
score, reasons = 0, []
if expect_consumer and conn.get("connection_type") == "Corporate":
score += 40; reasons.append(f"corporate/hosting network ({conn.get('asn_org')})")
if sec.get("isTor"): score += 35; reasons.append("Tor exit node")
if sec.get("isVpn"): score += 20; reasons.append("VPN detected")
threat = (sec.get("isThreat") or "low").lower()
if threat == "high": score += 30; reasons.append("known high threat")
elif threat == "medium": score += 15; reasons.append("known medium threat")
ip_country = geo.get("countryCode")
if billing_country and ip_country and ip_country != billing_country:
score += 25; reasons.append(f"IP {ip_country} != billing {billing_country}")
return score, reasons
def decide(score):
if score >= 60: return "review"
if score >= 30: return "challenge"
return "allow"
if __name__ == "__main__":
ip = sys.argv[1] if len(sys.argv) > 1 else "185.220.101.1"
billing = sys.argv[2] if len(sys.argv) > 2 else None
r = lookup(ip)
if not r.get("success"):
print(f"Lookup failed: {r}", file=sys.stderr); sys.exit(1)
score, reasons = risk_score(r["data"], expect_consumer=True, billing_country=billing)
print(f"{ip}: {decide(score)} (score={score}) {reasons}")$ export IPWHO_API_KEY=sk.xxx
$ python3 risk_score.py 185.220.101.1 US # Tor exit node
185.220.101.1: review (score=115) ['corporate/hosting network (...)', 'Tor exit node', 'known medium threat', 'IP DE != billing US']
$ python3 risk_score.py 52.95.110.1 US # AWS IP on a consumer signup
52.95.110.1: challenge (score=40) ['corporate/hosting network (Amazon.com, Inc.)']
$ python3 risk_score.py 76.102.11.203 US # residential broadband
76.102.11.203: allow (score=0) []