Skip to content

IPWhois/ipwhois-rust

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ipwhois

Crates.io Documentation License

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 extra bucket for forward compatibility
  • tokio + reqwest, rustls TLS, no native OpenSSL needed

Installation

[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.
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)

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:

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.

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)

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:

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 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):

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.

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.

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:

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"):

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 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:

{
    "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.

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.

License

MIT © ipwhois.io

Packages

 
 
 

Contributors

Languages