A domain you depend on expires or its SSL certificate runs out, and you learn about it from an angry customer instead of from a script.

If you look after more than a handful of domains (your own, your clients', your vendors'), the facts you need live in four different places: registration data in WHOIS/RDAP, records in DNS, the certificate in the TLS handshake, and redirects in HTTP. Checking them by hand once a quarter is how expiry surprises happen.

I built a small tool that collects all four in one call and returns one JSON row per domain. In this post I show how to use it from Python and curl, with the real output I got today, and how to run it daily so you only see domains that changed.

Why the usual approach hurts

The usual path looks like this:

  1. WHOIS over port 43. Every registry formats its text differently, many redact fields, and parsing "Registry Expiry Date:" vs "Expiration Time:" vs "paid-till:" with regexes is a hobby nobody wants.
  2. A DNS library for MX, TXT, SPF and DMARC, plus some code to find the SPF record among 30 TXT records.
  3. An ssl socket to read notAfter and compute days left.
  4. Requests with redirects to see where http://domain ends up.
  5. State, so tomorrow's run can tell you what changed instead of dumping everything again.

None of it is hard, but it is four libraries, a lot of edge cases and a state file. The modern fix for step 1 is RDAP: the official JSON protocol that registries run today. The IANA bootstrap file (data.iana.org/rdap/dns.json) tells you which RDAP server serves which TLD.

What I built

Domain WHOIS Lookup API is an Apify actor that does steps 1-5 for a list of domains:

  • Registration from the official RDAP servers: registrar, created/updated/expiry dates, domainAgeDays, daysToExpiry, EPP status codes, nameservers, DNSSEC.
  • DNS: A, AAAA, MX, NS, TXT, CAA, plus parsed SPF and DMARC.
  • SSL: issuer, notBefore, notAfter, daysLeft, TLS version, SANs.
  • Redirects: the chain from http://domain and the final URL with status.
  • Only-new mode: with a monitorId, the next run outputs only domains whose registration, DNS or certificate changed, with a changes list.

No API key for any registry, no login, no proxies. It never outputs registrant names, emails or phones: you get the registrar (a company) and technical facts only.

Step 1: get an Apify token

Create a free Apify account and copy your API token from Settings -> API & Integrations. Keep it in an environment variable:

export APIFY_TOKEN="your-token-here" 

Step 2: one call with curl

curl -s -X POST \ "https://api.apify.com/v2/acts/martin10291~domain-whois-dns-lookup/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"domains":["python.org","dev.to","stripe.com","example.com"]}' 

run-sync-get-dataset-items starts the run, waits for it and returns the rows in the same HTTP response. For these 4 domains the whole run took about 3.5 seconds today, and the lookups themselves 0.7 seconds.

Real output (run on 2026-11-06)

Here is the example.com row, trimmed only where noted:

{ "domain": "example.com", "registrar": "RESERVED-Internet Assigned Numbers Authority", "createdDate": "1995-08-14T04:00:00Z", "expiryDate": "2027-08-13T04:00:00Z", "domainAgeDays": 11407, "daysToExpiry": 279, "status": ["client delete prohibited", "client transfer prohibited", "client update prohibited"], "nameservers": ["elliott.ns.cloudflare.com", "hera.ns.cloudflare.com"], "dnssec": true, "rdapSource": "https://rdap.verisign.com/com/v1/domain/example.com", "dns": { "MX": ["0 ."], "SPF": ["v=spf1 -all"], "DMARC": ["v=DMARC1;p=reject;sp=reject;adkim=s;aspf=s"], "A": ["104.20.23.154", "172.66.147.243"] }, "ssl": { "valid": true, "issuer": "SSL Corporation", "notAfter": "2026-12-25T22:56:35Z", "daysLeft": 49, "tlsVersion": "TLSv1.3" }, "redirect": {"finalUrl": "http://example.com/", "finalStatus": 200}, "changes": ["new"] } 

And the short version of all four:

domain registrar days to expiry SSL days left SSL issuer DMARC policy
example.com IANA (reserved) 279 49 SSL Corporation reject
stripe.com SafeNames Ltd. 308 184 DigiCert, Inc. reject
python.org Gandi SAS 2333 99 GlobalSign nv-sa none
dev.to Tonic 149 190 GlobalSign nv-sa reject

A few things you get for free from one row: python.org is registered until 2033, its DMARC policy is p=none (monitoring only), and its SPF includes Google, Mailgun and a status-page provider. stripe.com has 30 TXT records and no SPF record at the apex (SPF: []), but a strict DMARC p=reject. The tool keeps what DNS says and does not guess.

Step 3: a Python script for your own list

Put your domains in domains.txt, one per line. This script calls the actor, writes a CSV and prints anything that needs attention in the next 30 days:

import csv, os, sys import requests TOKEN = os.environ["APIFY_TOKEN"] URL = ("https://api.apify.com/v2/acts/martin10291~domain-whois-dns-lookup/" "run-sync-get-dataset-items") WARN_DAYS = 30 domains = [d.strip() for d in open("domains.txt") if d.strip()] payload = { "domains": domains, "includeDns": True, "includeSsl": True, "includeRedirects": False, # faster if you only care about expiry  "maxConcurrency": 5, } rows = requests.post(URL, params={"token": TOKEN}, json=payload, timeout=300).json() with open("domains.csv", "w", newline="") as f: w = csv.writer(f) w.writerow(["domain", "registrar", "expiryDate", "daysToExpiry", "sslDaysLeft", "dmarc"]) for r in rows: ssl = r.get("ssl") or {} dmarc = (r.get("dns") or {}).get("DMARC") or [""] w.writerow([r["domain"], r.get("registrar"), r.get("expiryDate"), r.get("daysToExpiry"), ssl.get("daysLeft"), dmarc[0]]) alerts = [] for r in rows: if (r.get("daysToExpiry") or 10**6) <= WARN_DAYS: alerts.append(f"{r['domain']}: domain expires in {r['daysToExpiry']} days") ssl = r.get("ssl") or {} if ssl.get("daysLeft") is not None and ssl["daysLeft"] <= WARN_DAYS: alerts.append(f"{r['domain']}: SSL expires in {ssl['daysLeft']} days") print("\n".join(alerts) or "nothing expires in the next 30 days") sys.exit(1 if alerts else 0) 

The non-zero exit code makes it easy to plug into cron, CI or any monitor that alerts on a failed job.

Step 4: run it daily and see only what changed

Add onlyNew and a monitorId to the payload:

payload.update({"onlyNew": True, "monitorId": "my-domains"}) 

The first run reports every domain as "changes": ["new"] (that is your baseline). After that, each run outputs only domains whose registrar, expiry, status, nameservers, DNSSEC, DNS records or certificate changed, and the changes field says what. Unchanged domains are not saved and not charged.

I ran it twice today, 20 seconds apart, on stripe.com, python.org and a made-up name (radar-sample-unregistered-7731.com). The second run returned 0 rows: nothing changed, so nothing was saved or charged.

An earlier version flagged stripe.com as changed on every run because its load-balanced apex A records rotate between requests. Now A/AAAA records are ignored in only-new mode unless you set "trackIpChanges": true, so the report stays quiet for normal load-balancer churn.

To schedule it, either save the input as a task in Apify and add a schedule (0 7 * * *) with an email/Slack/webhook integration, or keep the script above in your own cron:

0 7 * * * cd /home/me/domains && APIFY_TOKEN=xxx python3 check.py >> check.log 2>&1 

Not on Apify? Plain REST

The same lookup is also listed as a REST API on RapidAPI and API.market (search for "Domain WHOIS Lookup API"), which is handy if you already have a key there. The Apify route above is the one I tested for this post.

Honest limits

  • Not every TLD has RDAP. A few ccTLD registries do not publish RDAP, and some rate-limit heavy use. Those domains are skipped, not charged, and listed in the RUN_SUMMARY record with lastError. Lower maxConcurrency if you hit limits.
  • Unregistered domains come back as a row with "available": true, "registered": false (from an RDAP 404 plus DNS). The made-up name above was reported that way. It is a quick availability hint, not a registrar quote, so check price and premium status at your registrar.
  • expiringWithinDays keeps only domains whose registration or certificate ends within N days, if you want the alert list straight from the API.
  • Use the registered domain (example.co.uk), not a subdomain. URLs are accepted; the host is used.
  • DNS is live. Values come from the resolver at run time. MX, TXT, NS and CNAME changes are reported in only-new mode; A/AAAA only with trackIpChanges.
  • No personal data. Registrant names, emails and addresses are never collected. If you need those, this is the wrong tool.
  • I have timed small lists, not huge ones. The 4-domain run above took 0.7 s of lookups at concurrency 5. Big lists scale with registry rate limits, so start with a few hundred.

Price

Pay per result: $1.50 per 1,000 domains saved, no start fee, Apify compute included. The 4-domain run above cost $0.006, and in only-new mode you pay only for domains that changed. Apify's free plan includes a small monthly credit, enough to try it on your own list.

Wrap-up

I built this because I wanted one daily "what changed on my domains" report instead of four libraries and a calendar reminder. If a TLD does not work for you, or you need a field I do not return yet, leave a comment here or open an issue on the actor page and I will look at it.

Next in this series: chaining it after a lead list (for example a Google Maps export) to add domain age, mail provider and SPF/DMARC to each company.