Official, async Rust client for the ipwhois.io IP Geolocation API.
- ✅ Single and bulk IP lookups (IPv4 and IPv6)
- ✅ Works with both the Free and Paid plans
- ✅ HTTPS by default
- ✅ Localisation, field selection, threat detection, rate info
- ✅ Errors as values — every fallible call returns
Result<_, Error> - ✅ Strongly-typed responses, with a
extrabucket for forward compatibility - ✅
tokio+reqwest, rustls TLS, no native OpenSSL needed
[dependencies]
ipwhois-rust = "1.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }The crate is published as ipwhois-rust but imported as ipwhois, so
your code reads use ipwhois::IpWhois; — the package name carries the
language suffix for crates.io disambiguation; the library identifier
stays short and on-brand.
The same IpWhois type is used for both plans. The only difference is
whether you pass an API key:
- Free plan — construct the client with
IpWhois::new(). No API key, no signup required. Suitable for low-traffic and non-commercial use. - Paid plan — construct with
IpWhois::with_key("…")using your key from https://ipwhois.io. Higher limits, plus access to bulk lookups and threat-detection data.
use ipwhois::IpWhois;
let free = IpWhois::new(); // Free plan — no API key
let paid = IpWhois::with_key("YOUR_API_KEY"); // Paid plan — with API key
// Or, if you'd rather catch an empty / whitespace-only key up-front
// instead of letting the API reject it:
let paid = IpWhois::try_with_key("YOUR_API_KEY")?;
# Ok::<_, ipwhois::Error>(())Everything else (lookup, options, error handling) is identical.
use ipwhois::IpWhois;
#[tokio::main]
async fn main() -> Result<(), ipwhois::Error> {
let ipwhois = IpWhois::new(); // no API key
let info = ipwhois.lookup("8.8.8.8").await?;
println!(
"{} {}",
info.country.as_deref().unwrap_or(""),
info.flag.as_ref().and_then(|f| f.emoji.as_deref()).unwrap_or(""),
);
// → United States 🇺🇸
println!(
"{}, {}",
info.city.as_deref().unwrap_or(""),
info.region.as_deref().unwrap_or(""),
);
// → Mountain View, California
Ok(())
}Get an API key at https://ipwhois.io and pass it to with_key:
use ipwhois::IpWhois;
#[tokio::main]
async fn main() -> Result<(), ipwhois::Error> {
let ipwhois = IpWhois::with_key("YOUR_API_KEY"); // with API key
let info = ipwhois.lookup("8.8.8.8").await?;
println!(
"{} {}",
info.country.as_deref().unwrap_or(""),
info.flag.as_ref().and_then(|f| f.emoji.as_deref()).unwrap_or(""),
);
println!(
"{}, {}",
info.city.as_deref().unwrap_or(""),
info.region.as_deref().unwrap_or(""),
);
Ok(())
}ℹ️ To look up your own public IP, call
ipwhois.lookup_self().await?— works on both plans.
Every option below can be passed per call (via lookup_with /
bulk_lookup_with) or set once on the client as a default.
| Option | Type | Plans needed | Description |
|---|---|---|---|
lang |
&str |
Free + Paid | One of: en, ru, de, es, pt-BR, fr, zh-CN, ja |
fields |
IntoIterator |
Free + Paid | Restrict the response to specific fields (e.g. ["country", "city"]) |
rate |
bool |
Basic and above | Include the rate block (limit, remaining) |
security |
bool |
Business and above | Include the security block (proxy/vpn/tor/hosting) |
Every option can be passed two ways: per call (as the second argument
to lookup_with / bulk_lookup_with) or once as a default on the
client. Per-call options always override the defaults, so it's safe to
set sensible defaults and only override what differs for a specific call.
Defaults are set with the consuming builder methods — with_language,
with_fields, with_security, with_rate, with_timeout,
with_connect_timeout, with_user_agent — and can be chained:
use ipwhois::IpWhois;
// Free plan
let ipwhois = IpWhois::new()
.with_language("en")
.with_fields(["success", "country", "city", "flag.emoji"])
.with_timeout(std::time::Duration::from_secs(8));use ipwhois::IpWhois;
// Paid plan
let ipwhois = IpWhois::with_key("YOUR_API_KEY")
.with_language("en")
.with_fields(["success", "country", "city", "flag.emoji"])
.with_timeout(std::time::Duration::from_secs(8));Either client behaves the same way at call time — per-call options always win over the defaults:
# use ipwhois::{IpWhois, Options};
# async fn run(ipwhois: IpWhois) -> Result<(), ipwhois::Error> {
ipwhois.lookup("8.8.8.8").await?; // uses lang=en, the field whitelist, and timeout=8
ipwhois
.lookup_with("1.1.1.1", &Options::new().with_lang("de"))
.await?; // overrides lang for this single call only
# Ok(()) }
⚠️ When you restrict fields withwith_fields(or the per-callOptions::with_fields), the API only returns the fields you ask for. Always include"success"in the list if you rely oninfo.successfor status checking — otherwise the field will be missing on responses.
ℹ️
with_security(true)requires Business+ andwith_rate(true)requires Basic+. See the table above for what's available where.
By default, all requests are sent over HTTPS. If you need to disable it
(for example, in environments without an up-to-date CA bundle), call
with_ssl(false):
use ipwhois::IpWhois;
// Free plan
let ipwhois = IpWhois::new().with_ssl(false);use ipwhois::IpWhois;
// Paid plan
let ipwhois = IpWhois::with_key("YOUR_API_KEY").with_ssl(false);ℹ️ HTTPS is strongly recommended for production traffic — your API key is sent in the query string and would otherwise travel in clear text.
The bulk endpoint sends up to 100 IPs in a single GET request. Each address counts as one credit. Available on the Business and Unlimited plans.
use ipwhois::IpWhois;
# async fn run() -> Result<(), ipwhois::Error> {
let ipwhois = IpWhois::with_key("YOUR_API_KEY");
let results = ipwhois
.bulk_lookup([
"8.8.8.8",
"1.1.1.1",
"208.67.222.222",
"2c0f:fb50:4003::", // IPv6 is fine — mix freely
])
.await?;
// `bulk_lookup` accepts anything that yields `AsRef<str>` items, so a
// `Vec<String>` you built up at runtime works without conversion:
let dynamic: Vec<String> = vec!["8.8.4.4".into(), "1.0.0.1".into()];
let results = ipwhois.bulk_lookup(dynamic).await?;
for row in &results {
if !row.success {
// Per-IP errors (e.g. "Invalid IP address") are returned inline,
// they don't fail the whole call — the rest of the batch is
// still usable.
println!(
"skip {}: {}",
row.ip.as_deref().unwrap_or("?"),
row.message.as_deref().unwrap_or(""),
);
continue;
}
println!(
"{} → {}",
row.ip.as_deref().unwrap_or(""),
row.country.as_deref().unwrap_or(""),
);
}
# Ok(()) }ℹ️ Bulk requires an API key. Calling
bulk_lookupwithout one will fail at the API level.
The public API returns Result for every fallible operation and does
not intentionally panic. Every failure — invalid IP, bad API key,
rate limit, network outage, bad options — comes back as Err(Error).
Match on the error or check error_type() for the category:
use ipwhois::{Error, IpWhois};
# async fn run() {
let ipwhois = IpWhois::new();
match ipwhois.lookup("8.8.8.8").await {
Ok(info) => println!("{}", info.country.as_deref().unwrap_or("")),
Err(Error::Api { http_status: Some(429), retry_after, .. }) => {
// Free plan rate limit hit — retry after `retry_after` seconds.
eprintln!("rate-limited, retry in {:?}s", retry_after);
}
Err(Error::Network { .. }) => {
// DNS failure, connection refused, timeout, …
}
Err(e) => eprintln!("Lookup failed ({}): {}", e.error_type(), e.message()),
}
# }This means an outage of the ipwhois.io API (or of your server's DNS,
connection, etc.) surfaces as a regular Result::Err you decide how to
react to, rather than an unwind through your code.
Every error carries a human-readable message. The category is exposed
both via the enum variant and via Error::error_type(), which returns
a stable string ("api", "network", or "invalid_argument"):
| Variant | error_type() |
When it's returned |
|---|---|---|
Error::Api |
"api" |
API-level failures: HTTP 4xx / 5xx, malformed JSON, HTTP 2xx + success: false |
Error::Network |
"network" |
DNS, connection, TLS, timeout — the request never reached the API meaningfully |
Error::InvalidArgument |
"invalid_argument" |
Caller-side: unsupported language, empty bulk list, more than 100 IPs |
Error::Api additionally carries:
http_status: Option<u16>— present on HTTP 4xx / 5xx responses.retry_after: Option<u64>— present on HTTP 429 responses on the free plan only (the paid endpoint does not send aRetry-Afterheader).
Convenience accessors are also available: e.http_status(),
e.retry_after(), e.message().
A successful response includes (depending on your plan and selected
options) — every field is Option<…> because the API only returns the
fields you ask for when fields is set:
For the full field reference, see the official documentation.
Any field not yet modelled by this crate lands in
LookupResponse::extra: HashMap<String, serde_json::Value> — so a
server-side schema bump won't break your code.
- Rust 1.75 or newer
- A
tokioruntime (or any other compatible async runtime supported byreqwest)
Issues and pull requests are welcome on GitHub.
MIT © ipwhois.io
{ "ip": "8.8.4.4", "success": true, "type": "IPv4", "continent": "North America", "continent_code": "NA", "country": "United States", "country_code": "US", "region": "California", "region_code": "CA", "city": "Mountain View", "latitude": 37.3860517, "longitude": -122.0838511, "is_eu": false, "postal": "94039", "calling_code": "1", "capital": "Washington D.C.", "borders": "CA,MX", "flag": { "img": "https://cdn.ipwhois.io/flags/us.svg", "emoji": "🇺🇸", "emoji_unicode": "U+1F1FA U+1F1F8" }, "connection": { "asn": 15169, "org": "Google LLC", "isp": "Google LLC", "domain": "google.com" }, "timezone": { "id": "America/Los_Angeles", "abbr": "PDT", "is_dst": true, "offset": -25200, "utc": "-07:00", "current_time": "2026-05-08T14:31:48-07:00" }, "currency": { "name": "US Dollar", "code": "USD", "symbol": "$", "plural": "US dollars", "exchange_rate": 1 }, "security": { "anonymous": false, "proxy": false, "vpn": false, "tor": false, "hosting": false }, "rate": { "limit": 250000, "remaining": 50155 } }