# ipwhois
[](https://crates.io/crates/ipwhois-rust)
[](https://docs.rs/ipwhois-rust)
[](LICENSE)
Official, async Rust client for the [ipwhois.io](https://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 `extra` bucket for forward compatibility
- ✅ `tokio` + `reqwest`, rustls TLS, no native OpenSSL needed
## Installation
```toml
[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.
## Free vs Paid plan
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.
```rust
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.
## Quick start — Free plan (no API key)
```rust
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(())
}
```
## Quick start — Paid plan (with API key)
Get an API key at <https://ipwhois.io> and pass it to `with_key`:
```rust
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.
## Lookup options
Every option below can be passed per call (via `lookup_with` /
`bulk_lookup_with`) or set once on the client as a default.
| `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) |
### Setting defaults once
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:
```rust
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));
```
```rust
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:
```rust
# 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 with `with_fields` (or the per-call
> `Options::with_fields`), the API only returns the fields you ask for.
> Always include `"success"` in the list if you rely on `info.success`
> for status checking — otherwise the field will be missing on responses.
> ℹ️ `with_security(true)` requires Business+ and `with_rate(true)`
> requires Basic+. See the table above for what's available where.
## HTTPS Encryption
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)`:
```rust
use ipwhois::IpWhois;
// Free plan
let ipwhois = IpWhois::new().with_ssl(false);
```
```rust
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.
## Bulk lookup (Paid plan only)
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.
```rust
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_lookup` without one will
> fail at the API level.
## Error handling
**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:
```rust
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.
### Error variants and metadata
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"`):
| `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 a `Retry-After`
header).
Convenience accessors are also available: `e.http_status()`,
`e.retry_after()`, `e.message()`.
## Response shape
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:
```jsonc
{
"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
}
}
```
For the full field reference, see the
[official documentation](https://ipwhois.io/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.
## Requirements
- Rust **1.75** or newer
- A `tokio` runtime (or any other compatible async runtime supported by
`reqwest`)
## Contributing
Issues and pull requests are welcome on
[GitHub](https://github.com/IPWhois/ipwhois-rust).
## License
[MIT](LICENSE) © ipwhois.io