Engineering

How to Query WHOIS Data in Python Using a REST API (2026 Guide)

March 13, 20269 min readPython · WHOIS · REST API

Introduction

WHOIS data is useful across a surprising range of Python projects. Security engineers feed it into threat-intelligence pipelines to catch newly-registered phishing domains before they send their first email. SaaS platforms verify domain ownership at onboarding. Compliance scripts check registrar and expiry data on a schedule. In all these scenarios, you need structured, reliable domain data — not a raw text dump.

The two most common Python approaches — the python-whois library and a subprocess call to the system whois binary — both inherit the fragility of the legacy WHOIS protocol: inconsistent field names across registrars, missing data on privacy-protected domains, no structured error codes, and hundreds of parsing edge cases you have to maintain yourself. A REST API eliminates all of that. You send a GET request, you get back consistent JSON. This guide shows you how to integrate the WhoisJSON WHOIS API into Python — from a single lookup to a production-grade async bulk scanner.

Prerequisites

  • Python 3.8+ — all examples use standard syntax compatible with 3.8 and above.
  • requests library — install with pip install requests.
  • A free WhoisJSON API key sign up here for 1,000 free requests per month. No credit card required.
  • For the async section: aiohttp — install with pip install aiohttp.

Basic WHOIS Lookup in Python

The simplest integration is a single requests.get call. Your API key goes in the Authorization header as Token=YOUR_API_KEY. The base URL for all WhoisJSON endpoints is https://whoisjson.com/api/v1.

whois_lookup.pyPython
import requests

API_KEY = "YOUR_API_KEY"  # from your dashboard
BASE_URL = "https://whoisjson.com/api/v1"

def whois_lookup(domain: str) -> dict:
    response = requests.get(
        f"{BASE_URL}/whois",
        params={"domain": domain},
        headers={"Authorization": f"Token={API_KEY}"},
        timeout=10,
    )
    response.raise_for_status()
    return response.json()

data = whois_lookup("example.com")
print(data)

The API returns a normalized JSON object. Here is a representative excerpt — notice the pre-computed helper objects that save you from manual date arithmetic:

Response (JSON)JSON
{
  "name":       "example.com",
  "registered": true,
  "source":     "rdap",
  "created":    "1995-08-14",
  "changed":    "2024-08-13",
  "expires":    "2026-08-13",

  "age": {
    "days":               10743,
    "months":             354,
    "years":              29,
    "isNewlyRegistered":  false,
    "isYoung":            false
  },

  "expiration": {
    "daysLeft":        152,
    "isExpiringSoon":  false,
    "isExpired":       false
  },

  "registrar": {
    "name": "IANA",
    "url":  "https://www.iana.org"
  },

  "nameserver": ["a.iana-servers.net", "b.iana-servers.net"]
}
Remaining requests: every response includes a Remaining-Requests header (e.g. Remaining-Requests: 952). Access it with response.headers.get("Remaining-Requests") to monitor your quota programmatically.

Parsing the Response

Use .get() for safe key access — some fields are absent when a domain is not registered or when WHOIS data is redacted for privacy. The source field tells you whether the response came from RDAP (richer data, structured contacts) or legacy WHOIS.

parse_whois.pyPython
data = whois_lookup("github.com")

registrar   = data.get("registrar", {}).get("name", "N/A")
created     = data.get("created", "N/A")
expires     = data.get("expires", "N/A")
nameservers = data.get("nameserver", [])
source      = data.get("source", "whois")

# Pre-computed expiration helpers (rdap only)
expiration  = data.get("expiration") or {}
days_left   = expiration.get("daysLeft")
is_expiring = expiration.get("isExpiringSoon", False)

print(f"Registrar : {registrar}")
print(f"Created   : {created}")
print(f"Expires   : {expires}")
print(f"Days left : {days_left}")
print(f"Expiring  : {is_expiring}")
print(f"NS        : {', '.join(nameservers)}")
print(f"Source    : {source}")
RDAP vs WHOIS source: when source is rdap, the response includes extra enriched fields — age, expiration, statusAnalysis, and nsAnalysis — that are not present in legacy WHOIS responses.

Bulk Domain Lookup

For sequential batch processing, loop over your list and insert a short delay between requests. A 300 ms pause keeps you well inside the rate limit while processing hundreds of domains without issues.

bulk_lookup.pyPython
import time
import requests

API_KEY = "YOUR_API_KEY"
BASE_URL = "https://whoisjson.com/api/v1"

DOMAINS = [
    "github.com",
    "stripe.com",
    "notion.so",
    "linear.app",
    "vercel.com",
]

def whois_lookup(session: requests.Session, domain: str) -> dict | None:
    try:
        r = session.get(
            f"{BASE_URL}/whois",
            params={"domain": domain},
            timeout=10,
        )
        r.raise_for_status()
        return r.json()
    except requests.RequestException as exc:
        print(f"[WARN] {domain}: {exc}")
        return None

results = {}
with requests.Session() as session:
    session.headers["Authorization"] = f"Token={API_KEY}"
    for domain in DOMAINS:
        results[domain] = whois_lookup(session, domain)
        time.sleep(0.3)  # respect the rate limit

for domain, data in results.items():
    if data:
        print(f"{domain} — expires {data.get('expires', 'N/A')}")

Using a requests.Session reuses the underlying TCP connection across requests, reducing latency and avoiding TLS handshake overhead on every call.

Async Bulk Lookup with aiohttp

For high-throughput pipelines — thousands of domains or time-sensitive enrichment tasks — switch to an async model with aiohttp and asyncio.gather. A Semaphore caps concurrent in-flight requests to stay within the API rate limit.

async_bulk_lookup.pyPython
import asyncio
import aiohttp

API_KEY  = "YOUR_API_KEY"
BASE_URL = "https://whoisjson.com/api/v1"
MAX_CONCURRENT = 5  # max parallel requests

DOMAINS = [
    "github.com",
    "stripe.com",
    "notion.so",
    "linear.app",
    "vercel.com",
    "figma.com",
    "discord.com",
]

async def fetch_whois(
    session: aiohttp.ClientSession,
    semaphore: asyncio.Semaphore,
    domain: str,
) -> tuple[str, dict | None]:
    async with semaphore:
        try:
            async with session.get(
                f"{BASE_URL}/whois",
                params={"domain": domain},
                timeout=aiohttp.ClientTimeout(total=10),
            ) as response:
                response.raise_for_status()
                return domain, await response.json()
        except Exception as exc:
            print(f"[WARN] {domain}: {exc}")
            return domain, None

async def bulk_whois(domains: list[str]) -> dict:
    semaphore = asyncio.Semaphore(MAX_CONCURRENT)
    headers   = {"Authorization": f"Token={API_KEY}"}
    async with aiohttp.ClientSession(headers=headers) as session:
        tasks = [
            fetch_whois(session, semaphore, domain)
            for domain in domains
        ]
        pairs = await asyncio.gather(*tasks)
    return dict(pairs)

results = asyncio.run(bulk_whois(DOMAINS))

for domain, data in results.items():
    if data:
        print(f"{domain} — registrar: {data.get('registrar', {}).get('name', 'N/A')}")
Semaphore sizing: keep MAX_CONCURRENT at 5 or below on free plans. On paid plans with higher rate limits you can safely raise it to 10–20 depending on your subscription tier.

Error Handling & Edge Cases

The WhoisJSON API uses standard HTTP status codes. The most common ones you will encounter in production:

StatusMeaningWhat to do
200SuccessParse the JSON body normally.
400Bad requestCheck that the domain parameter is a valid FQDN.
401Invalid tokenVerify your API key and the Token= prefix in the header.
403Email not validatedConfirm your account email then retry.
429Rate limit exceededBack off and retry after a delay. Upgrade your plan for higher limits.
500Internal errorRetry once; if persistent, check the status page.

Here is a robust wrapper with retry logic for transient errors and graceful handling for unregistered domains:

whois_robust.pyPython
import time
import requests

API_KEY  = "YOUR_API_KEY"
BASE_URL = "https://whoisjson.com/api/v1"

def whois_lookup(domain: str, retries: int = 2) -> dict | None:
    headers = {"Authorization": f"Token={API_KEY}"}
    for attempt in range(retries + 1):
        try:
            r = requests.get(
                f"{BASE_URL}/whois",
                params={"domain": domain},
                headers=headers,
                timeout=10,
            )

            if r.status_code == 429:
                # Rate limited — wait and retry
                wait = 2 ** attempt
                print(f"[429] Rate limited. Retrying in {wait}s...")
                time.sleep(wait)
                continue

            r.raise_for_status()
            data = r.json()

            # Domain exists but is not registered
            if not data.get("registered", True):
                print(f"[INFO] {domain} is not registered.")
                return None

            return data

        except requests.Timeout:
            print(f"[WARN] {domain}: request timed out (attempt {attempt + 1})")
        except requests.HTTPError as exc:
            # 4xx errors are not retryable
            print(f"[ERROR] {domain}: HTTP {exc.response.status_code}")
            return None
        except requests.RequestException as exc:
            print(f"[ERROR] {domain}: {exc}")
            return None
    return None

Real-World Use Case: Flag Newly Registered Domains

Domains registered within the last 30 days are a well-known high-risk signal: phishing kits, malware C2 infrastructure, and brand-impersonation campaigns almost always run on freshly registered domains. The WhoisJSON API computes age.isNewlyRegistered for you — no date parsing required.

The following script takes a list of suspected domains, looks them up, and outputs a report of the ones that are newly registered:

flag_new_domains.pyPython
import time
import requests

API_KEY  = "YOUR_API_KEY"
BASE_URL = "https://whoisjson.com/api/v1"

# Domains extracted from logs, threat feeds, or user reports
SUSPECTS = [
    "paypa1-secure-login.com",
    "netflix-account-verify.net",
    "apple-id-support-team.org",
    "github.com",       # control — known-old domain
    "stripe.com",       # control — known-old domain
]

def check_domain(session: requests.Session, domain: str) -> dict | None:
    try:
        r = session.get(
            f"{BASE_URL}/whois",
            params={"domain": domain},
            timeout=10,
        )
        r.raise_for_status()
        return r.json()
    except requests.RequestException as exc:
        print(f"[WARN] {domain}: {exc}")
        return None

flagged = []

with requests.Session() as session:
    session.headers["Authorization"] = f"Token={API_KEY}"

    for domain in SUSPECTS:
        data = check_domain(session, domain)
        if not data:
            time.sleep(0.3)
            continue

        age         = data.get("age") or {}
        is_new      = age.get("isNewlyRegistered", False)
        is_young    = age.get("isYoung", False)
        days_old    = age.get("days")
        registrar   = data.get("registrar", {}).get("name", "N/A")
        registered  = data.get("registered", False)
        source      = data.get("source", "whois")

        if is_new:
            risk = "HIGH"
        elif is_young:
            risk = "MEDIUM"
        else:
            risk = "LOW"

        entry = {
            "domain":    domain,
            "risk":      risk,
            "days_old":  days_old,
            "registrar": registrar,
            "source":    source,
        }

        if risk in ("HIGH", "MEDIUM"):
            flagged.append(entry)

        print(
            f"[{risk:6}] {domain:<40} "
            f"age={days_old}d  registrar={registrar}  source={source}"
        )
        time.sleep(0.3)

print(f"\n{'='*60}")
print(f"Flagged domains: {len(flagged)}")
for d in flagged:
    print(f"  - {d['domain']}  ({d['days_old']}d old — {d['risk']} risk)")
Note: the age and isNewlyRegistered fields are only populated when source is rdap. For legacy WHOIS responses, fall back to parsing the created string manually or treat the absence of age as a signal worth investigating on its own.

Conclusion

You now have a complete Python toolkit for WHOIS lookups: a single-domain function with requests, a sequential bulk scanner with rate-limit-safe pacing, a high-throughput async version with aiohttp, production-grade error handling, and a real-world fraud detection script that flags newly registered domains in seconds.

The WhoisJSON API handles TLD routing, RDAP fallback, GDPR redaction normalization, and structured error codes — so your Python code stays clean and focused on your actual use case rather than parser maintenance.

Start querying WHOIS data now

Create a free account and get 1,000 API requests per month. No credit card required.

Ready to build?

WHOIS, DNS, SSL, subdomain discovery and domain monitoring — one API token, 1,000 free requests every month.