Guide

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.

Python

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:

Terminal
# 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.

Python
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:

Python
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, reasons

4Map 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.

Python
# 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.

Python
# 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 absent

6The 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).

Python
#!/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}")
Output
$ 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) []

Add a risk signal in one request

Free tier: 2,000 lookups/day, no card. Paid plans from $2.50/month. Read the full field reference here, or see the fraud use case here.