IP Geolocation API

Look up the approximate location, network, and risk signals for any public IP address.

No auth JSON Edge-cached

Overview

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

GET /api/ipgeo/{ip}

Look up a specific IPv4 or IPv6 address.

ParameterInDescription
ippathThe IPv4 or IPv6 address to look up, e.g. 8.8.8.8.
GET /api/ipgeo/me

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" }
  }
}
All 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

FieldTypeDescription
ipstringThe IP that was looked up (echoed by this API).
continent.codestringTwo-letter continent code (NA, EU, AS…).
country.iso_codestringISO 3166-1 alpha-2 country code (US, DE).
country.is_in_european_unionbooleanWhether the country is an EU member.
subdivisions[]arrayRegion 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.enstringCity name (English).
postal.codestringPostal / ZIP code.
location.latitude / longitudenumberApproximate city-level coordinates — not the exact device location.
location.time_zonestringIANA time zone (Europe/Berlin).
*.geoname_idnumberGeoNames database ID for that place.

Network fields — traits

FieldTypeDescription
autonomous_system_numbernumberASN of the network.
autonomous_system_organizationstringOperator that owns the ASN.
ispstringInternet service provider name.
organizationstringOrganization using the IP (can differ from the ISP).
connection_typestringe.g. Corporate, Cellular, Cable/DSL.
user_typestringe.g. hosting, residential, business.
is_anycastbooleanIP 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.

Rule of thumb. 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

QuestionQuick flag (rollup)Authoritative detail object (adds confidence / types / last_seen)
Any anonymization?intelligence.flags.is_anonymousintelligence.anonymous.is_anonymous
VPN?intelligence.flags.is_vpnintelligence.anonymous.is_vpn (+ types, confidence)
Proxy?intelligence.flags.is_proxyintelligence.anonymous.is_proxy
Tor?intelligence.flags.is_torintelligence.anonymous.is_tor
Relay (e.g. Apple Private Relay)?intelligence.flags.is_relayintelligence.anonymous.is_relay
Known malicious?intelligence.flags.is_maliciousintelligence.security.is_malicious (+ threat_types, threat_score)
Hosting / datacenter?intelligence.flags.is_hostingintelligence.hosting.is_hosting (+ types)
Public network service?intelligence.flags.is_network_serviceintelligence.network_services.is_network_service (+ is_public_dns/is_public_doh/is_scanner)

Two different scores — don't mix them up

FieldRangeWhat it measures
intelligence.risk.score / .level0–100 / enumOverall risk, blending anonymity + threat + hosting signals. Use this for a single "how risky is this IP" number.
intelligence.security.threat_score / .threat_level0–100 / enumMalicious activity only — independent of whether it's a VPN/proxy. Use this to answer "has this IP done bad things."
*.confidence0–100How 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

ObjectKey fields
intelligence.summaryverdict (e.g. clean, anonymous_traffic, high_risk, very_high_risk), description (human-readable).
intelligence.anonymousis_anonymous/is_vpn/is_proxy/is_tor/is_relay, types (e.g. ["vpn_exit"], ["tor_exit"]), confidence, last_seen.
intelligence.securityis_malicious, threat_score, threat_level, threat_types (e.g. ["web_attack","bruteforce"]), confidence, last_seen.
intelligence.hostingis_hosting, types, confidence, last_seen.
intelligence.network_servicesis_network_service, is_public_dns, is_public_doh, is_scanner, types, confidence, last_seen.
intelligence.tagsstring[] — 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.evidencesource_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.

StatusMeaning
200Success — geolocation result, or a reserved result for private IPs.
400Malformed IP address ({ error, ip }), or the upstream rejected the request.
404IP not present in the geolocation database.
405Method not allowed (only GET / OPTIONS).
429Rate limited upstream. A Retry-After header is passed through when available.
500Server misconfigured, or an upstream server error.
502Could 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"))