DEV Community

Cover image for I Built 3 Free IP Geolocation APIs — Here's What I Learned
Onizuka
Onizuka

Posted on • Edited on

I Built 3 Free IP Geolocation APIs — Here's What I Learned

The problem with free IP data

Every free geolocation product I tried before building mine had the same smell: a credit card to get 1,000 requests a month, "pinpoint accuracy" in the marketing copy, accuracy pages quoting country-level numbers and hoping you don't read too closely.

The numbers nobody advertises: country detection is genuinely good, around 99% with fresh data. City detection is a coin flip dressed up as science. VPN detection is a knife fight between list maintainers and providers who rotate IP ranges weekly.

Geolocation has real uses: fraud pre-screening, content localization, analytics, rough regional routing. What it can't do is prove where a human is. I didn't want to ship another API that returned confident JSON built on guesses. I wanted one that tells you when it's guessing. Three attempts later, I sort of have it.

API #1: The one that lied with decimals

Version one took a single afternoon: MaxMind's GeoLite2 database, a FastAPI wrapper, done.

# v1: a GeoLite2 wrapper in 15 lines. It worked. That was the problem.
from fastapi import FastAPI
import geoip2.database

app = FastAPI()
reader = geoip2.database.Reader("GeoLite2-City.mmdb")

@app.get("/lookup")
def lookup(ip: str):
    r = reader.city(ip)
    return {
        "ip": ip,
        "country": r.country.name,
        "city": r.city.name,
        "latitude": r.location.latitude,
        "longitude": r.location.longitude,
    }
Enter fullscreen mode Exit fullscreen mode

It was fast, around 4 ms per lookup on my laptop, so I shipped it and told my friends.

Then a friend in São Paulo got coordinates in the Atlantic. Another, in rural Poland, was placed in a field outside Warsaw. Both were country-only records, but GeoLite2 still hands you lat/long for those: the geographic center of the country, with four decimal places of fake confidence.

The famous version of this bug played out in Kansas. For years, MaxMind's default coordinates for unknown US IPs pointed at a farmhouse, and around 600 million addresses resolved there. The family in that house spent half a decade getting accused of computer fraud and identity theft by strangers and law enforcement. Kashmir Hill's 2016 piece on the whole mess should be required reading for anyone touching IP data.

My v1 repeated the same lie, just quieter. Four decimals implies "within about 11 meters" to anyone reading the response, and my API had no business implying that. The fix was to return null coordinates when the record is country-level and pass through accuracy_radius when it exists. That one change killed an entire category of bug reports overnight.

API #2: The one that cried VPN

Version two added the fun stuff: Tor, VPN, proxy, and datacenter detection.

The Tor part is easy and actually reliable, because the Tor Project publishes a bulk exit list, updated constantly:

import requests

TOR_EXIT_LIST = "https://check.torproject.org/torbulkexitlist"

def load_tor_exits() -> set:
    resp = requests.get(TOR_EXIT_LIST, timeout=10)
    resp.raise_for_status()
    return set(resp.text.strip().splitlines())

def is_tor(ip: str, exits: set) -> bool:
    return ip in exits
Enter fullscreen mode Exit fullscreen mode

That list holds roughly 7,000 IPs on a typical day, and if an IP is on it, it's Tor. This is the one detection category I fully trust. (There's a catch, but I'll get to it.)

Datacenters are almost as easy. Keep a list of ASNs belonging to hosting providers and flag anything originating there:

# A few of the big ones: DigitalOcean, AWS, Hetzner, OVH
DATACENTER_ASNS = {14061, 16509, 24940, 16276}

def is_datacenter(asn: int) -> bool:
    return asn in DATACENTER_ASNS
Enter fullscreen mode Exit fullscreen mode

VPNs are where things get uncomfortable. There is no master list. Commercial providers rotate ranges, resell capacity, and rent space inside the same datacenters you're already flagging. I tested 200 exit IPs from five big commercial VPN providers and my API caught 84% of them; the rest were clean-looking residential or unlisted ranges. I'd treat 84% as a ceiling, not a floor.

Then the false positives started coming in. The first was Apple iCloud Private Relay. Apple's relay egress IPs are, technically, proxies, and millions of normal iPhone users browse through them without knowing. My API flagged the range as proxy plus datacenter, which pushed an ordinary iOS user in my test checkout flow to a score of 55 and blocked them. Apple publishes those egress ranges, so the fix was a separate relay category with a tiny weight. I only caught it because a beta tester complained.

The second was carrier-grade NAT, the thing that flagged me at 2 a.m. Mobile carriers park thousands of subscribers behind a handful of shared IPs, and one spammer getting the shared IP onto a blocklist means 40,000 strangers inherit the reputation. I pulled 300 mobile carrier IPs through my own API and 4% came back flagged as proxy or datacenter. Those weren't proxies; they were phones.

And the Tor catch: my cron pulled the exit list every 6 hours, while exit nodes churn hourly. In a 500-IP test against fresh exits I caught 97%, and every miss traced back to that stale cache. The list now refreshes every 30 minutes, and the response carries data_age_hours so you can see how stale my data is before you trust it.

API #3: The one that admits things

Version three is the current one, and the big change isn't really technical. Every field that used to be a confident boolean now ships with a confidence level, and the fraud score comes with its recipe printed on the label.

Every fraud score is weighted vibes until you calibrate it against your own traffic, so here are mine, weights and all:

WEIGHTS = {
    "tor": 40,
    "vpn": 25,
    "proxy": 15,
    "datacenter": 20,
    "relay": 5,  # Apple Private Relay: technically a proxy, practically harmless
}

def fraud_score(signals: dict) -> int:
    score = sum(w for k, w in WEIGHTS.items() if signals.get(k))
    return min(score, 100)
Enter fullscreen mode Exit fullscreen mode

Tor sits at 40 because Tor plus a signup form is, in my data, trouble about 80% of the time. Datacenter is only 20, because plenty of legit traffic comes from cloud IPs: corporate SSO callbacks, CI systems, other people's APIs. Your weights should differ from mine. Run a sneaker drop site and datacenter traffic is bots; run a B2B dashboard and datacenter traffic is Tuesday.

Full response for 1.1.1.1:

{
  "ip": "1.1.1.1",
  "country": "Australia",
  "country_code": "AU",
  "city": null,
  "latitude": null,
  "longitude": null,
  "accuracy_radius_km": 1000,
  "asn": 13335,
  "org": "Cloudflare, Inc.",
  "is_vpn": false,
  "is_proxy": false,
  "is_tor": false,
  "is_datacenter": true,
  "is_relay": false,
  "fraud_score": 20,
  "confidence": "high",
  "data_age_hours": 3
}
Enter fullscreen mode Exit fullscreen mode

city is null there because 1.1.1.1 is anycast. It exists in 300+ places at once, and any API giving you a single city for it is fibbing. Three fields in that response exist purely because I got burned: confidence, accuracy_radius_km, and data_age_hours. They're the API admitting, in machine-readable form, how much you should believe it.

Performance, since everyone asks: p50 latency of 41 ms and p95 of 230 ms, measured over 10,000 requests from a $6 VPS. The p95 isn't the lookup; the mmdb read is microseconds. It's the cheap box waking up. Don't host fraud checks on a server that naps.

How to use it

Two ways: hosted via RapidAPI with a free tier, or self-host from the GitHub repo.

curl:

curl -s "https://ip-geolocation-api1.p.rapidapi.com/lookup?ip=8.8.8.8" \
  -H "X-RapidAPI-Key: YOUR_API_KEY" \
  -H "X-RapidAPI-Host: ip-geolocation-api1.p.rapidapi.com"
Enter fullscreen mode Exit fullscreen mode

Python:

import requests

def lookup_ip(ip: str, api_key: str) -> dict:
    resp = requests.get(
        "https://ip-geolocation-api1.p.rapidapi.com/lookup",
        params={"ip": ip},
        headers={
            "X-RapidAPI-Key": api_key,
            "X-RapidAPI-Host": "ip-geolocation-api1.p.rapidapi.com",
        },
        timeout=5,
    )
    resp.raise_for_status()
    return resp.json()

data = lookup_ip("8.8.8.8", "YOUR_API_KEY")
print(data["country_code"], data["fraud_score"], data["confidence"])
Enter fullscreen mode Exit fullscreen mode

Self-hosting needs a free MaxMind license key for GeoLite2 (they've required registration since 2019), then:

git clone https://github.com/On13uka/ip-geolocation-api.git
cd ip-geolocation-api
MAXMIND_LICENSE_KEY=your_key docker compose up
Enter fullscreen mode Exit fullscreen mode

The Tor exit list and ASN data refresh on their own schedule after that.

What I still get wrong

The stuff that's still broken, because I'd rather you hear it from me.

City data is still a coin flip across half the world. In my own test of 1,000 ground-truth IPs: 99.1% correct on country, 68% on city within 40 km. Don't make business decisions on the city field.

IPv6 coverage is thinner than IPv4 everywhere in this industry, and mine is no exception.

Residential proxies are still basically undetectable. Nobody can reliably catch them, and anyone who claims otherwise is selling something.

There's an open issue where a Starlink IP geolocates to the wrong continent. I haven't fixed it and I'm not sure I can, since the "location" of a satellite link is as much a philosophical question as a technical one.

If you want to try breaking it, the free tier is on RapidAPI and the code is on GitHub. False positive reports go in the issue tracker. I read every one, because each is a bug my confidence scores missed. And given that this whole project started with my own API flagging me on my own couch, I try not to argue with them.


.

How many of these endpoints surprised you?
Enter fullscreen mode Exit fullscreen mode

Top comments (0)