IP Geolocation API
Look up the approximate location, network, and risk signals for any public IP address.
No auth JSON Edge-cachedOverview
A lightweight JSON API that resolves an IP address to geolocation and network
metadata. Responses are English-only. All requests are served over HTTPS. The base
URL is your deployment's origin — for example https://your-app.vercel.app
(or http://localhost:3000 when running locally).
Authentication
None. The API is public and requires no key, token, or header. (Upstream credentials are held server-side and never exposed.)
Endpoints
Look up a specific IPv4 or IPv6 address.
| Parameter | In | Description |
|---|---|---|
ip | path | The IPv4 or IPv6 address to look up, e.g. 8.8.8.8. |
Look up the caller's own public IP (resolved from the request). This response is never cached, since it varies per caller.
Success response
200 Returns the resolved ip plus geolocation,
network (traits) and intelligence objects. Full example (a Tor exit
node, so the risk fields are populated):
{
"ip": "185.220.101.1",
"continent": { "code": "EU", "geoname_id": 6255148, "names": { "en": "Europe" } },
"country": { "iso_code": "DE", "geoname_id": 2921044, "is_in_european_union": true,
"names": { "en": "Germany" } },
"subdivisions": [ { "iso_code": "BE", "geoname_id": 2950157, "names": { "en": "State of Berlin" } },
{ "names": { "en": "Kreisfreie Stadt Berlin" } } ],
"city": { "geoname_id": 2950159, "names": { "en": "Berlin" } },
"postal": { "code": "10178" },
"location": { "latitude": 52.52, "longitude": 13.405,
"time_zone": "Europe/Berlin", "weather_code": "GMXX0007" },
"traits": {
"autonomous_system_number": 60729,
"autonomous_system_organization": "Stiftung Erneuerbare Freiheit",
"isp": "Stiftung Erneuerbare Freiheit",
"organization": "Artikel10 e.V",
"connection_type": "Corporate",
"user_type": "hosting",
"is_anycast": false
},
"intelligence": {
"summary": { "verdict": "very_high_risk",
"description": "This IP is associated with Tor exit traffic and recent malicious activity." },
"flags": { "is_anonymous": true, "is_proxy": false, "is_vpn": false, "is_tor": true,
"is_relay": false, "is_malicious": true, "is_hosting": false, "is_network_service": false },
"risk": { "score": 94, "level": "very_high" },
"anonymous": { "is_anonymous": true, "is_proxy": false, "is_vpn": false, "is_tor": true,
"is_relay": false, "types": ["tor_exit"], "confidence": 70,
"last_seen": "2026-07-03T07:43:37.996+00:00" },
"security": { "is_malicious": true, "threat_score": 70, "threat_level": "high",
"threat_types": ["web_attack", "bruteforce"], "confidence": 60,
"last_seen": "2026-07-03T07:42:05.83+00:00" },
"hosting": { "is_hosting": false, "types": [], "confidence": 0, "last_seen": null },
"network_services": { "is_network_service": false, "types": [], "is_public_dns": false,
"is_public_doh": false, "is_scanner": false, "confidence": 0, "last_seen": null },
"tags": ["web_attack", "bruteforce", "tor_exit"],
"categories": [ { "category": "tor", "subcategory": "tor_exit", "label": "Tor exit",
"confidence": 70, "signal_count": 4, "last_seen": "2026-07-03T07:43:37.996+00:00" } ],
"evidence": { "source_count": 7, "signal_count": 7,
"first_seen": "2026-05-31T07:46:06.227+00:00", "last_seen": "2026-07-03T07:43:37.996+00:00" }
}
}
names objects are reduced to English (en) only.
Every field is optional — treat anything as possibly missing and code defensively
(data.intelligence?.flags?.is_vpn).Location & place fields
| Field | Type | Description |
|---|---|---|
ip | string | The IP that was looked up (echoed by this API). |
continent.code | string | Two-letter continent code (NA, EU, AS…). |
country.iso_code | string | ISO 3166-1 alpha-2 country code (US, DE). |
country.is_in_european_union | boolean | Whether the country is an EU member. |
subdivisions[] | array | Region hierarchy, broad → specific. [0] is the top-level (state/province), later entries are finer (county/district). Each has names.en and usually iso_code. |
city.names.en | string | City name (English). |
postal.code | string | Postal / ZIP code. |
location.latitude / longitude | number | Approximate city-level coordinates — not the exact device location. |
location.time_zone | string | IANA time zone (Europe/Berlin). |
*.geoname_id | number | GeoNames database ID for that place. |
Network fields — traits
| Field | Type | Description |
|---|---|---|
autonomous_system_number | number | ASN of the network. |
autonomous_system_organization | string | Operator that owns the ASN. |
isp | string | Internet service provider name. |
organization | string | Organization using the IP (can differ from the ISP). |
connection_type | string | e.g. Corporate, Cellular, Cable/DSL. |
user_type | string | e.g. hosting, residential, business. |
is_anycast | boolean | IP is announced from multiple locations (anycast). |
Intelligence & risk — read this first
The intelligence object is where the same flag names
(is_anonymous, is_proxy, is_vpn, is_tor,
is_malicious…) appear more than once. They aren't
contradictory — it's a summary layer plus detailed source objects. Here's exactly which to use.
intelligence.flags.* for a
quick yes/no in your gating logic. When you need why — confidence, sub-type, or when it
was last observed — read the matching detail object below. The flag and its detail object always
agree on the boolean; the detail object just adds metadata.The duplicated is_* flags — which is which
| Question | Quick flag (rollup) | Authoritative detail object (adds confidence / types / last_seen) |
|---|---|---|
| Any anonymization? | intelligence.flags.is_anonymous | intelligence.anonymous.is_anonymous |
| VPN? | intelligence.flags.is_vpn | intelligence.anonymous.is_vpn (+ types, confidence) |
| Proxy? | intelligence.flags.is_proxy | intelligence.anonymous.is_proxy |
| Tor? | intelligence.flags.is_tor | intelligence.anonymous.is_tor |
| Relay (e.g. Apple Private Relay)? | intelligence.flags.is_relay | intelligence.anonymous.is_relay |
| Known malicious? | intelligence.flags.is_malicious | intelligence.security.is_malicious (+ threat_types, threat_score) |
| Hosting / datacenter? | intelligence.flags.is_hosting | intelligence.hosting.is_hosting (+ types) |
| Public network service? | intelligence.flags.is_network_service | intelligence.network_services.is_network_service (+ is_public_dns/is_public_doh/is_scanner) |
Two different scores — don't mix them up
| Field | Range | What it measures |
|---|---|---|
intelligence.risk.score / .level | 0–100 / enum | Overall risk, blending anonymity + threat + hosting signals. Use this for a single "how risky is this IP" number. |
intelligence.security.threat_score / .threat_level | 0–100 / enum | Malicious activity only — independent of whether it's a VPN/proxy. Use this to answer "has this IP done bad things." |
*.confidence | 0–100 | How sure the provider is about that specific signal (per detail object / per category). |
Example: a clean corporate IP can be a VPN exit (risk.level: low) yet still
carry a moderate security.threat_score; a Tor exit with recent attacks scores high on both.
Detail objects
| Object | Key fields |
|---|---|
intelligence.summary | verdict (e.g. clean, anonymous_traffic, high_risk, very_high_risk), description (human-readable). |
intelligence.anonymous | is_anonymous/is_vpn/is_proxy/is_tor/is_relay, types (e.g. ["vpn_exit"], ["tor_exit"]), confidence, last_seen. |
intelligence.security | is_malicious, threat_score, threat_level, threat_types (e.g. ["web_attack","bruteforce"]), confidence, last_seen. |
intelligence.hosting | is_hosting, types, confidence, last_seen. |
intelligence.network_services | is_network_service, is_public_dns, is_public_doh, is_scanner, types, confidence, last_seen. |
intelligence.tags | string[] — flat list of all labels across categories (e.g. ["tor_exit","web_attack"]). Handy for quick display/filtering. |
intelligence.categories[] | Per-signal detail: category, subcategory, label, confidence, signal_count, last_seen. |
intelligence.evidence | source_count, signal_count, first_seen, last_seen — how much data backs the assessment and its time window. |
Private / reserved IPs
200 Private, loopback, link-local, CGNAT, documentation, multicast and other reserved ranges (IPv4 & IPv6) are detected locally and answered without an upstream lookup — they can't be geolocated and consume no quota.
{
"ip": "192.168.1.1",
"reserved": true,
"category": "Private network (192.168.0.0/16, RFC 1918)",
"message": "Private or reserved IP address — not publicly geolocatable."
}
Detect this case by checking for reserved === true before reading geo fields.
Errors & status codes
Error bodies are { "error": string, "status"?: number }. Always check the
HTTP status before parsing.
| Status | Meaning |
|---|---|
| 200 | Success — geolocation result, or a reserved result for private IPs. |
| 400 | Malformed IP address ({ error, ip }), or the upstream rejected the request. |
| 404 | IP not present in the geolocation database. |
| 405 | Method not allowed (only GET / OPTIONS). |
| 429 | Rate limited upstream. A Retry-After header is passed through when available. |
| 500 | Server misconfigured, or an upstream server error. |
| 502 | Could not reach the geolocation service — retry shortly. |
Caching
Successful concrete-IP lookups are cached at the edge CDN for 30 days
(Cache-Control: s-maxage=2592000). Each IP is a distinct URL, cached
independently. /api/ipgeo/me and all error responses are never cached
(no-store).
CORS
Cross-origin requests are allowed from any origin (Access-Control-Allow-Origin: *)
by default, so the API can be called directly from browser code. Deployments may restrict
this to a single origin via configuration.
Code examples
curl https://your-app.vercel.app/api/ipgeo/8.8.8.8
# your own IP
curl https://your-app.vercel.app/api/ipgeo/me
const res = await fetch("https://your-app.vercel.app/api/ipgeo/8.8.8.8");
if (!res.ok) throw new Error(`Lookup failed: ${res.status}`);
const data = await res.json();
if (data.reserved) {
console.log(data.ip, "is", data.category);
} else {
console.log(data.country?.names?.en, data.city?.names?.en);
}
import requests
r = requests.get("https://your-app.vercel.app/api/ipgeo/8.8.8.8", timeout=10)
r.raise_for_status()
data = r.json()
if data.get("reserved"):
print(data["ip"], "is", data["category"])
else:
print(data.get("country", {}).get("names", {}).get("en"))