DEV Community

Onizuka
Onizuka

Posted on

5 Free Domain Investigate APIs That Cut Due Diligence Time

security, #api, #webdev, #sideprojects

That TXT record saved me $8,500

Last Tuesday I almost wired $8,500 to a domain broker. The landing page looked legit. Escrow was ready. Then I ran one DNS lookup.

import dns.resolver

def txt_flags(domain):
    try:
        answers = dns.resolver.resolve(domain, "TXT")
        return [r.to_text().strip('"') for r in answers]
    except dns.resolver.NXDOMAIN:
        return ["domain does not exist"]
    except dns.resolver.NoAnswer:
        return []
    except Exception as e:
        return [f"lookup failed: {e}"]

domain = "example.com"
records = txt_flags(domain)
print(records)

if any("for sale" in r.lower() for r in records):
    print("STOP: domain is listed for sale in DNS")
Enter fullscreen mode Exit fullscreen mode

One TXT record said "this domain is for sale contact broker@...". The seller had called the name "off-market". The DNS disagreed. That 0.3-second query killed the wire before I signed anything.

DNS is where domains advertise themselves now. A TXT record is faster than a landing page and harder to fake than a polished website. It also lives in the authoritative zone, so a broker can't just take it down. But "for sale" is only one signal. Before I send real money, I want WHOIS age, IP location, company identity, email health, and sanctions hits together.

Why domain due diligence still eats your afternoon

Buying a domain means betting on someone else's story. You're trusting a string of characters, a stranger's email, and a registrar you've never heard of. One missed red flag costs money, reputation, or a compliance headache. I've seen all three.

I used to open six browser tabs. WHOIS in one. IP lookup in another. Company search, email validator, sanctions list, and the domain's own site. One name ate three to four hours. Each service spat out a different JSON shape. Each had its own rate limit. And every API key expired at the worst possible moment.

Finding the data was never the hard part. Stitching it together was.

I learned this the hard way. I once trusted a clean WHOIS record and skipped IP geolocation. The seller was routing through an ASN that got flagged three weeks later. Legal killed the deal. I lost a full week. That failure taught me parallel checks beat any single check.

Five free APIs I chain together

I now run five lightweight checks in parallel. Each API has a free tier that covers occasional domain deals. I wrapped them in a small Python script. Point it at any domain. Two seconds later, I have a dossier. No more tab switching.

The full wrapper is on GitHub: github.com/On13uka/portfolio-api.

1. WHOIS age and registrar

A fresh domain can be fine; a ten-year-old domain can still be trouble. I use age plus registrar reputation as a first filter, not a verdict.

curl -s "https://www.whoisxmlapi.com/whoisserver/WhoisService?apiKey=$WHOIS_KEY&domainName=example.com&outputFormat=JSON" | jq '.WhoisRecord.createdDate, .WhoisRecord.registrarName'
Enter fullscreen mode Exit fullscreen mode

2. IP geolocation and ASN

The server location and ASN show where the site actually lives. A "US company" hosted somewhere unexpected deserves a second look.

import socket, requests

def ip_geo(domain):
    try:
        ip = socket.gethostbyname(domain)
    except socket.gaierror as e:
        return {"error": str(e)}

    url = f"http://ip-api.com/json/{ip}?fields=status,country,countryCode,isp,org,as"
    try:
        r = requests.get(url, timeout=10)
        r.raise_for_status()
        data = r.json()
        return {
            "ip": ip,
            "country": data.get("country"),
            "asn": data.get("as")
        }
    except Exception as e:
        return {"error": str(e)}
Enter fullscreen mode Exit fullscreen mode

3. Company enrichment

If the domain matches a real company, I want employee count, founding year, and whether the domain actually belongs to that company or is just squatting on a similar name.

curl -s "https://company.clearbit.com/v2/companies/find?domain=example.com" \
  -H "Authorization: Bearer $CLEARBIT_KEY" | jq '.name, .metrics.employees'
Enter fullscreen mode Exit fullscreen mode

4. Email health

A domain with no reachable abuse contact, or MX records pointing to a disposable provider, isn't one I rush to buy.

curl -s "https://api.zerobounce.net/v2/validate?api_key=$ZEROBOUNCE_KEY&email=abuse@example.com" | jq '.status'
Enter fullscreen mode Exit fullscreen mode

5. Sanctions screening

This is the one that saved me. A domain or its associated entity showing up on a sanctions list turns interest into a hard no.

import requests

def sanctions_check(domain):
    url = f"https://api.opensanctions.org/search/?q={domain}&limit=5"
    try:
        r = requests.get(url, timeout=15)
        r.raise_for_status()
        data = r.json()
        return [h.get("caption") for h in data.get("results", [])]
    except Exception as e:
        return {"error": str(e)}
Enter fullscreen mode Exit fullscreen mode

Putting them together

I run all five checks in a thread pool so one slow API can't block the rest.

import os, json, socket, requests
from concurrent.futures import ThreadPoolExecutor, as_completed

HEADERS = {"User-Agent": "domain-dd/0.1"}

def whois_check(domain):
    url = (
        "https://www.whoisxmlapi.com/whoisserver/WhoisService"
        f"?apiKey={os.getenv('WHOIS_KEY')}&domainName={domain}&outputFormat=JSON"
    )
    try:
        r = requests.get(url, timeout=10, headers=HEADERS)
        r.raise_for_status()
        data = r.json()
        rec = data.get("WhoisRecord", {})
        return {
            "source": "whois",
            "created": rec.get("createdDate"),
            "registrar": rec.get("registrarName"),
        }
    except Exception as e:
        return {"source": "whois", "error": str(e)}

def ip_geo(domain):
    try:
        ip = socket.gethostbyname(domain)
    except socket.gaierror as e:
        return {"source": "ipgeo", "error": str(e)}

    url = f"http://ip-api.com/json/{ip}?fields=status,country,countryCode,isp,org,as"
    try:
        r = requests.get(url, timeout=10, headers=HEADERS)
        r.raise_for_status()
        data = r.json()
        return {
            "source": "ipgeo",
            "ip": ip,
            "country": data.get("country"),
            "asn": data.get("as"),
        }
    except Exception as e:
        return {"source": "ipgeo", "error": str(e)}

def company_check(domain):
    url = f"https://company.clearbit.com/v2/companies/find?domain={domain}"
    try:
        r = requests.get(
            url,
            timeout=10,
            headers={"Authorization": f"Bearer {os.getenv('CLEARBIT_KEY')}"},
        )
        r.raise_for_status()
        data = r.json()
        return {
            "source": "company",
            "name": data.get("name"),
            "employees": data.get("metrics", {}).get("employees"),
        }
    except Exception as e:
        return {"source": "company", "error": str(e)}

def email_check(domain):
    email = f"abuse@{domain}"
    url = (
        "https://api.zerobounce.net/v2/validate"
        f"?api_key={os.getenv('ZEROBOUNCE_KEY')}&email={email}"
    )
    try:
        r = requests.get(url, timeout=10, headers=HEADERS)
        r.raise_for_status()
        data = r.json()
        return {"source": "email", "address": email, "status": data.get("status")}
    except Exception as e:
        return {"source": "email", "error": str(e)}

def sanctions_check(domain):
    url = f"https://api.opensanctions.org/search/?q={domain}&limit=5"
    try:
        r = requests.get(url, timeout=15, headers=HEADERS)
        r.raise_for_status()
        data = r.json()
        hits = [h.get("caption") for h in data.get("results", [])]
        return {"source": "sanctions", "hits": hits}
    except Exception as e:
        return {"source": "sanctions", "error": str(e)}

def investigate(domain):
    checks = [whois_check, ip_geo, company_check, email_check, sanctions_check]
    results = {}
    with ThreadPoolExecutor(max_workers=5) as ex:
        futures = {ex.submit(fn, domain): fn.__name__ for fn in checks}
        for fut in as_completed(futures):
            res = fut.result()
            results[res["source"]] = res
    return results

if __name__ == "__main__":
    import sys
    print(json.dumps(investigate(sys.argv[1]), indent=2))
Enter fullscreen mode Exit fullscreen mode

On my machine this finishes in about 2.1 seconds. Before the wrapper, the same coverage took me three to four hours of tab switching. The bigger win: I no longer skip a check because I'm in a hurry.

WHOIS age has lied to me; a ten-year-old domain can change hands overnight. Sanctions hits have also lied to me; a match can be a false positive from a shared name. I keep both in the report and let the human decide.

How to use Portfolio Investigate API

If you don't want to juggle five API keys, five rate limits, and five JSON parsers, there's an aggregated option. The Portfolio Investigate API on RapidAPI runs the same five checks in one call and returns a unified dossier with a plain-English verdict, which is exactly what I want when I'm showing results to someone who doesn't parse JSON for fun.

One-call domain report

curl -X POST "https://portfolio-investigate.p.rapidapi.com/v1/investigate" \
  -H "X-RapidAPI-Key: $RAPIDAPI_KEY" \
  -H "X-RapidAPI-Host: portfolio-investigate.p.rapidapi.com" \
  -H "Content-Type: application/json" \
  -d '{"domain":"example.com"}'
Enter fullscreen mode Exit fullscreen mode

Python client

import os, requests

url = "https://portfolio-investigate.p.rapidapi.com/v1/investigate"
headers = {
    "X-RapidAPI-Key": os.getenv("RAPIDAPI_KEY"),
    "X-RapidAPI-Host": "portfolio-investigate.p.rapidapi.com",
    "Content-Type": "application/json",
}
payload = {"domain": "example.com"}

r = requests.post(url, json=payload, headers=headers, timeout=20)
r.raise_for_status()
print(r.json())
Enter fullscreen mode Exit fullscreen mode

Ask a natural-language question

The POST /ask endpoint is useful when you're showing the result to a non-technical reviewer.

r = requests.post(
    "https://portfolio-investigate.p.rapidapi.com/v1/ask",
    headers=headers,
    json={
        "domain": "example.com",
        "question": "Is this domain tied to any sanctioned entity?",
    },
    timeout=20,
)
r.raise_for_status()
print(r.json())
Enter fullscreen mode Exit fullscreen mode

I use this when I need a one-page summary for a compliance officer or a cofounder who won't read raw JSON. The wrapper on GitHub is free if you prefer to self-host the same logic. I keep both options around.

When one call beats five

The wrapper works. I still run it for side projects where I want full control. But maintaining five free tiers is a part-time job. Keys expire. Rate limits change. Response shapes drift. Last month, one geolocation API started returning 403 for requests without a referer header. I spent an hour debugging before I checked their changelog. Edge cases like that are why I keep a backup provider for IP data.

An aggregated API trades flexibility for consistency. That's a good trade when the audience is a compliance dashboard or a transaction-review workflow. You get one JSON shape, one SLA, and one invoice.

The real win isn't automation; it's that you stop making excuses. A four-hour process gets skipped on small deals. A two-second process gets run on every name.

What's the first signal you check before you buy a domain?

DNS now talks back. A simple TXT record can expose a broker's lie before you ever open escrow. Add WHOIS age, IP location, company data, email health, and sanctions screening, and the habit scales past one-off checks.

If you want the one-call version, the Portfolio Investigate API is on RapidAPI, and the open wrapper is on GitHub.

What's the first signal you check before you send real money for a domain: DNS, WHOIS age, sanctions hits, or something else entirely?

Top comments (0)