IPWho (ipwho.org) Python SDK
Official Python SDK for the IPWho IP geolocation API β geoip lookup, IP location, IP to country / latitude / longitude, ASN/ISP, timezone, currency, flag, and proxy/VPN detection with typed responses. Works as an IP lookup / ip-geolocation client for IPv4 and IPv6 (lookup, me, bulk).
- Product: ipwho.org
- API docs: ipwho.org/docs
- Get an API key: ipwho.org/free-plan (free Lavrox account)
- Live API host:
https://api.ipwho.org
Open a free Lavrox account to get an API key for IPWho. Create your key at ipwho.org/free-plan β no credit card required.
pip install ipwho-ip-geolocation-apiFrom source:
git clone https://github.com/lavrox/SDK-IPWho-IP-Geolocation-Python.git
cd SDK-IPWho-IP-Geolocation-Python
pip install -e .Requires Python 3.8+ and requests.
import os
from ipwho import IPWhoClient
client = IPWhoClient(api_key=os.environ["IPWHO_API_KEY"])
resp = client.lookup("8.8.8.8") # GET /ip/{ip}
me = client.me() # GET /me
bulk = client.bulk(["8.8.8.8", "1.1.1.1"]) # GET /bulk/{a,b,c}Every successful JSON call returns an IpGeoResponse:
IpGeoResponse
βββ success: bool
βββ message: str | None # errors only
βββ data: GeoData
βββ ip: str
βββ geo_location: GeoLocation
βββ timezone: Timezone
βββ flag: Flag
βββ currency: Currency
βββ connection: Connection
βββ security: Security
βββ user_agent: UserAgent # often present on /me
βββ response_array: list # bulk only
Values below match the live IPWho API (Google DNS: United States, ASN 15169, timezone America/Chicago, dial code +1). Nested objects can be None for some IPs β always check.
resp = client.lookup("8.8.8.8")
assert resp.success
data = resp.data
print(data.ip) # "8.8.8.8"
geo = data.geo_location
print(geo.continent) # e.g. "North America"
print(geo.continent_code) # "NA"
print(geo.country) # "United States"
print(geo.country_code) # "US"
print(geo.capital)
print(geo.region)
print(geo.region_code)
print(geo.city)
print(geo.postal_code)
print(geo.dial_code) # "+1"
print(geo.is_in_eu) # False
print(geo.latitude, geo.longitude)
print(geo.accuracy_radius) # e.g. 1000
tz = data.timezone
print(tz.time_zone) # "America/Chicago"
print(tz.abbr, tz.offset, tz.is_dst, tz.utc, tz.current_time)
flag = data.flag
print(flag.flag_icon) # "πΊπΈ"
print(flag.flag_unicode) # "U+1F1FA U+1F1F8"
cur = data.currency
print(cur.code, cur.symbol, cur.name)
print(cur.name_plural) # "US dollars"
print(cur.hex_unicode)
conn = data.connection
print(conn.asn_number) # 15169
print(conn.asn_org) # "Google LLC"
print(conn.isp, conn.org, conn.domain)
print(conn.connection_type) # "Corporate"
sec = data.security
print(sec.is_vpn, sec.is_tor, sec.is_threat) # threat: "low" | "medium" | "high"
if data.user_agent:
print(data.user_agent.browser.name, data.user_agent.os.name)
print(data.user_agent.device.type, data.user_agent.cpu.architecture)
me = client.me()
print(me.data.ip) # the caller's public IP
bulk = client.bulk(["8.8.8.8", "1.1.1.1"])
for item in bulk.data.response_array or []:
print(item.data.ip, item.data.geo_location.country)What lookup("8.8.8.8") looks like after the SDK maps the wire payload:
{
"success": true,
"data": {
"ip": "8.8.8.8",
"geo_location": {
"continent": "North America",
"continent_code": "NA",
"country": "United States",
"country_code": "US",
"capital": "Washington",
"region": "California",
"region_code": "CA",
"city": null,
"postal_code": null,
"dial_code": "+1",
"is_in_eu": false,
"latitude": 37.751,
"longitude": -97.822,
"accuracy_radius": 1000
},
"timezone": {
"time_zone": "America/Chicago",
"abbr": "CDT",
"offset": -18000,
"is_dst": true,
"utc": "UTC-05:00",
"current_time": "2026-08-07T12:00:00-05:00"
},
"flag": {
"flag_icon": "πΊπΈ",
"flag_unicode": "U+1F1FA U+1F1F8"
},
"currency": {
"code": "USD",
"symbol": "$",
"name": "US Dollar",
"name_plural": "US dollars",
"hex_unicode": "0024"
},
"connection": {
"asn_number": 15169,
"asn_org": "Google LLC",
"isp": "Google LLC",
"org": "Google LLC",
"domain": "google.com",
"connection_type": "Corporate"
},
"security": {
"is_vpn": false,
"is_tor": false,
"is_threat": "low"
},
"user_agent": null
}
}City/region on anycast DNS IPs may be empty; country, ASN, timezone, flag, and currency are populated. Exact coordinates vary.
| v1 | v2 |
|---|---|
get_ip(ip) / get_location(ip) |
lookup(ip) then resp.data.geo_location |
get_me() / get_location() |
me() |
get_timezone(ip) |
lookup(ip).data.timezone |
get_connection(ip) |
lookup(ip).data.connection |
get_security(ip) |
lookup(ip).data.security |
| (missing) | bulk(ips) |
Client class is IPWhoClient (was IPWho). Calls are synchronous.
- api_key: IPWho API key (sent as query
apiKey). Required. - base_url: default
https://api.ipwho.org. - timeout: seconds (default
30). - Raises:
ValueErrorif the key is empty.
GET /ip/{ip}. format: json (typed), xml, or csv. fields: optional comma-separated objects, e.g. "geoLocation,timezone".
GET /me β same shape as lookup, for the caller's IP.
GET /bulk/{ip1,ip2,...}. ips must be non-empty. Per-IP rows: resp.data.response_array (list of IpGeoResponse).
InvalidIPErrorβ HTTP 404RateLimitErrorβ HTTP 429APIResponseErrorβ other HTTP /success: falseIPWhoErrorβ base class
@dataclass
class IpGeoResponse:
success: bool
data: Optional[GeoData]
message: Optional[str]
@dataclass
class GeoData:
ip: str
geo_location: Optional[GeoLocation]
timezone: Optional[Timezone]
flag: Optional[Flag]
currency: Optional[Currency]
connection: Optional[Connection]
security: Optional[Security]
user_agent: Optional[UserAgent]
response_array: Optional[List[IpGeoResponse]] # bulk
@dataclass
class GeoLocation:
continent: Optional[str]
continent_code: Optional[str]
country: Optional[str]
country_code: Optional[str]
capital: Optional[str]
region: Optional[str]
region_code: Optional[str]
city: Optional[str]
postal_code: Optional[str]
dial_code: Optional[str]
is_in_eu: Optional[bool]
latitude: Optional[float]
longitude: Optional[float]
accuracy_radius: Optional[float]
@dataclass
class Timezone:
time_zone: Optional[str]
abbr: Optional[str]
offset: Optional[int]
is_dst: Optional[bool]
utc: Optional[str]
current_time: Optional[str]
@dataclass
class Flag:
flag_icon: Optional[str]
flag_unicode: Optional[str]
@dataclass
class Currency:
code: Optional[str]
symbol: Optional[str]
name: Optional[str]
name_plural: Optional[str]
hex_unicode: Optional[str]
@dataclass
class Connection:
asn_number: Optional[int]
asn_org: Optional[str]
isp: Optional[str]
org: Optional[str]
domain: Optional[str]
connection_type: Optional[str]
@dataclass
class Security:
is_vpn: Optional[bool]
is_tor: Optional[bool]
is_threat: Optional[str] # "low" | "medium" | "high"
@dataclass
class UserAgent:
browser: Optional[Browser] # name, version
engine: Optional[Engine]
os: Optional[OS]
device: Optional[Device] # type, vendor, model
cpu: Optional[CPU] # architectureThe live JSON mixes camelCase and snake_case (postal_Code, flag_Icon, isVpn). The SDK maps those onto the fields above.
- API key is required: create a key at ipwho.org.
- HTTP 403: blank User-Agent is rejected. This SDK sends
ipwho-python-sdk/2.0.0. - HTTP 401 / invalid key:
APIResponseError. - HTTP 429:
RateLimitErrorβ back off and retry. - HTTP 404:
InvalidIPError. - None nested objects: not every IP has city, postal code, or user-agent.
IPWHO_API_KEY=your_key python3 test_ipwho.pyThe live check is test_ipwho.py.
lookup/me/bulkmatching api.ipwho.org- Full
IpGeoResponse(geo, timezone, flag, currency, connection, security, user-agent) - Breaking change from v1
IPWho.get_location/get_ip
MIT License β see LICENSE.
- Documentation: ipwho.org/docs
- Contact: ipwho.org/contact
- GitHub Issues: lavrox/SDK-IPWho-IP-Geolocation-Python
- Website: ipwho.org
IPWho β a Lavrox network API.
Lavrox β Independent API infrastructure. Lower latency, lower cost.