Bulk-check domain expiry, SSL certificates and DMARC for a list of domains (Python + Node.js)
Expired domains and expired TLS certificates are among the most embarrassing outages, because they're completely predictable. If you look after more than a handful of domains (your own, clients', old campaign sites), a w
Expired domains and expired TLS certificates are among the most embarrassing outages, because they're completely predictable. If you look after more than a handful of domains (your own, clients', old campaign sites), a weekly check that says "this expires in 12 days" is worth a lot.
This post covers:
- A dependency-free Python check: domain expiry via RDAP, certificate expiry via
ssl, and the DMARC policy via DNS-over-HTTPS. - The pitfalls: ccTLDs without expiry data, subdomains vs registrable domains, and certificate errors.
- A hosted bulk checker (registrar, dates, DNS, SPF/DMARC, SSL, HTTPS redirect, HSTS) that you can run from cron or CI, from Python and Node.js.
Disclosure: I built the Domain WHOIS & DNS Checker Actor used in part 3. Parts 1 and 2 don't need it.
1. A minimal checker (Python standard library)
RDAP is the JSON successor of WHOIS. Registries publish registration events (including expiration) in a documented format, and rdap.org redirects you to the right registry. Certificate dates come straight from the TLS handshake. The DMARC record is a TXT record at _dmarc.<domain>, which you can read from Google's DNS-over-HTTPS JSON API without installing a DNS library.
import json
import socket
import ssl
import sys
import urllib.request
from datetime import datetime, timezone
UA = {"User-Agent": "domain-check script", "Accept": "application/json"}
def get_json(url):
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=20) as r:
return json.load(r)
def ssl_expiry(host):
"""Days until the TLS certificate of host:443 expires (verification on)."""
ctx = ssl.create_default_context()
with socket.create_connection((host, 443), timeout=10) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as tls:
not_after = tls.getpeercert()["notAfter"] # e.g. 'Jan 27 23:59:59 2027 GMT'
expires = datetime.fromtimestamp(ssl.cert_time_to_seconds(not_after), timezone.utc)
return (expires - datetime.now(timezone.utc)).days
def domain_expiry(domain):
"""Registration expiry via RDAP (the JSON successor of WHOIS). rdap.org redirects
to the right registry; many ccTLDs (e.g. .de) don't publish an expiry date."""
data = get_json(f"https://rdap.org/domain/{domain}")
for event in data.get("events", []):
if event.get("eventAction") == "expiration":
expires = datetime.fromisoformat(event["eventDate"].replace("Z", "+00:00"))
return (expires - datetime.now(timezone.utc)).days
return None
def dmarc_policy(domain):
"""Read the _dmarc TXT record via DNS-over-HTTPS (no dnspython needed)."""
data = get_json(f"https://dns.google/resolve?name=_dmarc.{domain}&type=TXT")
for answer in data.get("Answer", []):
txt = answer["data"].strip('"').replace('" "', "")
if txt.lower().startswith("v=dmarc1"):
tags = dict(t.strip().split("=", 1) for t in txt.split(";") if "=" in t)
return tags.get("p")
return None
if __name__ == "__main__":
for domain in sys.argv[1:] or ["github.com", "example.com"]:
row = {"domain": domain}
for key, check in (("domainDays", domain_expiry), ("sslDays", ssl_expiry), ("dmarc", dmarc_policy)):
try:
row[key] = check(domain)
except Exception as exc: # one failing check shouldn't hide the others
row[key] = f"error: {exc}"
print(row)
Output (03.11.2026):
$ python domain_stdlib.py github.com example.com tchibo.de expired.badssl.com
{'domain': 'github.com', 'domainDays': 706, 'sslDays': 85, 'dmarc': 'quarantine'}
{'domain': 'example.com', 'domainDays': 282, 'sslDays': 52, 'dmarc': 'reject'}
{'domain': 'tchibo.de', 'domainDays': 'error: HTTP Error 404: Not Found', 'sslDays': 45, 'dmarc': 'none'}
{'domain': 'expired.badssl.com', 'domainDays': 'error: HTTP Error 404: Not Found', 'sslDays': 'error: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1029)', 'dmarc': None}
The .com domains work. The other two rows are what part 2 is about.
2. Pitfalls
-
Not every TLD has RDAP, and not every RDAP server has dates.
rdap.orgreturns 404 for.de, because DENIC doesn't run a public RDAP service with registration data. Its public WHOIS only tells you whether a domain is registered, with no registrar, no creation date and no expiry date. Several other ccTLDs (for example.at,.eu,.io,.it,.es,.co) either have no RDAP service in the IANA bootstrap file or don't publish expiry dates. For those, you can at least check that the domain still resolves (has NS records). For the actual renewal date, ask your registrar, because .de domains renew automatically as long as the contract runs. -
Subdomains aren't registered domains.
expired.badssl.comhas no registration of its own. You have to look upbadssl.com. Getting the registrable domain right needs the Public Suffix List (shop.example.co.ukβexample.co.uk, notco.uk). -
An invalid certificate is a result, not a crash. With verification on,
wrap_socketraises an exception for expired, self-signed or wrong-host certificates. That's what you want to report. If you also want the expiry date of a broken certificate, you need a second, non-verifying connection, and you then have to parse the binary certificate yourself. -
Check the email side too. A missing SPF record, a missing DMARC record, or
p=none(monitoring only) makes domain spoofing easier. These are the same TXT lookups, so they cost almost nothing. - Rate limits and terms. Registries throttle RDAP and WHOIS. Spread out bulk lookups and cache results, because expiry dates change at most once a year. WHOIS/RDAP terms (DENIC's, for example) allow lookups for technical and administrative purposes, not for building marketing lists.
3. Hosted option: Domain WHOIS & DNS Checker on Apify
The Domain WHOIS & DNS Checker takes domains, subdomains or full URLs and returns one record per input. Each record includes:
- Registrar, creation/update/expiry dates, status, name servers and DNSSEC from the official RDAP server
- A/AAAA/MX/NS/TXT/CAA records (via DNS-over-HTTPS)
- SPF and DMARC policy
- TLS certificate validity, issuer, days left and TLS version
- HTTP status, HTTPS redirect, HSTS and response time
- A plain-English
issueslist
It resolves subdomains to the registrable domain, falls back to "does it resolve?" for TLDs without RDAP data, and never collects registrant contact data.
Set APIFY_TOKEN from a free Apify account (Console β Settings β API & Integrations).
Python (pip install apify-client)
"""Check a list of domains for expiry, DNS, SPF/DMARC and SSL problems.
Actor: https://apify.com/paperlinenstudio/domain-whois-dns-checker
Usage: APIFY_TOKEN=... python domain_expiry_check.py example.com github.com
"""
import os
import sys
from apify_client import ApifyClient
domains = sys.argv[1:] or ["github.com", "tchibo.de", "expired.badssl.com"]
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("paperlinenstudio/domain-whois-dns-checker").call(
run_input={
"domains": domains,
"expiryWarningDays": 30, # flag domains/certificates expiring within 30 days
}
)
rows = list(client.dataset(run.default_dataset_id).iterate_items())
for r in rows:
ssl = r.get("ssl") or {}
mail = r.get("emailSecurity") or {}
print(
f"{r['host']:<28} registered={r.get('registered')} "
f"domain expires in {r.get('daysUntilExpiry')} d "
f"SSL valid={ssl.get('valid')} ({ssl.get('daysUntilExpiry')} d) "
f"DMARC={mail.get('dmarcPolicy')}"
)
for issue in r["issues"]:
print(f" ! {issue}")
sys.exit(1 if any(r["issues"] for r in rows) else 0) # handy for a cron/CI monitor
Output (03.11.2026):
$ python domain_expiry_check.py github.com tchibo.de expired.badssl.com
github.com registered=True domain expires in 706 d SSL valid=True (85 d) DMARC=quarantine
expired.badssl.com registered=True domain expires in 155 d SSL valid=False (None d) DMARC=None
! No SPF record
! No DMARC record
! SSL certificate invalid: certificate has expired
tchibo.de registered=True domain expires in None d SSL valid=True (45 d) DMARC=none
! DMARC policy is p=none (monitoring only)
$ echo $?
1
Compare this with part 1. expired.badssl.com now gets the expiry date of badssl.com (155 days), and tchibo.de is confirmed as registered even though .de has no expiry date. Because the script exits with 1 when there are issues, cron or CI can alert on it.
Node.js (npm i apify-client)
// Check domains for expiry, DNS, SPF/DMARC and SSL problems.
// Exits with code 1 when any domain has an issue, so it works as a cron/CI monitor.
// Usage: APIFY_TOKEN=... node domain_expiry_check.mjs example.com github.com
import { ApifyClient } from 'apify-client';
const domains = process.argv.slice(2);
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('paperlinenstudio/domain-whois-dns-checker').call({
domains: domains.length ? domains : ['github.com', 'tchibo.de', 'expired.badssl.com'],
expiryWarningDays: 30,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const r of items) {
console.log(
`${r.host.padEnd(28)} registered=${r.registered} domain expires in ${r.daysUntilExpiry} d `
+ `SSL valid=${r.ssl?.valid} (${r.ssl?.daysUntilExpiry} d) DMARC=${r.emailSecurity?.dmarcPolicy}`,
);
for (const issue of r.issues) console.log(` ! ${issue}`);
}
process.exitCode = items.some((r) => r.issues.length) ? 1 : 0;
$ node domain_expiry_check.mjs github.com example.com
github.com registered=true domain expires in 706 d SSL valid=true (85 d) DMARC=quarantine
example.com registered=true domain expires in 282 d SSL valid=true (52 d) DMARC=reject
A single record (real, from 03.11.2026; long DNS lists shortened):
{
"input": "github.com",
"host": "github.com",
"domain": "github.com",
"tld": "com",
"rdapServer": "https://rdap.verisign.com/com/v1/",
"registered": true,
"registrar": "MarkMonitor Inc.",
"registrarIanaId": "292",
"createdAt": "2007-10-09T18:20:50+00:00",
"updatedAt": "2026-09-07T09:22:52+00:00",
"expiresAt": "2028-10-09T18:20:50+00:00",
"domainStatus": [
"client delete prohibited",
"client transfer prohibited",
"client update prohibited"
],
"nameservers": [
"dns1.p08.nsone.net",
"dns2.p08.nsone.net",
"β¦"
],
"dnssec": false,
"domainAgeDays": 6965,
"daysUntilExpiry": 706,
"dns": {
"A": [
"140.82.114.3"
],
"MX": [
{
"priority": 0,
"host": "github-com.mail.protection.outlook.com"
}
],
"NS": [
"β¦ (8 entries)"
],
"TXT": [
"β¦"
],
"CAA": [
"β¦"
]
},
"dnsResolves": true,
"registeredSource": "rdap",
"emailSecurity": {
"spf": "v=spf1 ip4:192.30.252.0/22 include:spf.protection.outlook.com β¦ ~all",
"spfRecordCount": 1,
"dmarc": "v=DMARC1; p=quarantine; sp=reject; pct=100; rua=mailto:[email protected]; ruf=mailto:[email protected]; fo=1",
"dmarcPolicy": "quarantine",
"hasMx": true
},
"ssl": {
"host": "github.com",
"valid": true,
"issuer": "Sectigo Limited",
"subject": "github.com",
"validFrom": "2026-10-30T00:00:00+00:00",
"validTo": "2027-01-27T23:59:59+00:00",
"daysUntilExpiry": 85,
"subjectAltNames": [
"github.com",
"www.github.com"
],
"tlsVersion": "TLSv1.3",
"error": null
},
"http": {
"url": "https://github.com/",
"statusCode": 200,
"finalUrl": "https://github.com/",
"redirectsToHttps": true,
"server": "github.com",
"hsts": true,
"responseTimeMs": 95,
"error": null
},
"issues": [],
"errors": [],
"checkedAt": "2026-11-03T13:25:26+00:00"
}
Run it weekly
The easiest option is an Apify schedule on a saved task, plus a webhook or one of the Slack/email integrations to forward the results. If you'd rather use your own server, cron and the exit code are enough. Keep the token in a file only you can read, not in the crontab itself:
# ~/.apify_env contains: export APIFY_TOKEN=your_token_here (chmod 600)
# Every Monday 07:00; cron mails the output only when the check fails
0 7 * * 1 . "$HOME/.apify_env" && cd "$HOME/domains" && python3 domain_expiry_check.py $(cat domains.txt) > last.log 2>&1 || cat last.log
In CI, store the token as a masked secret variable in your CI system and pass it to the job as the environment variable APIFY_TOKEN. The scripts only read os.environ["APIFY_TOKEN"] / process.env.APIFY_TOKEN.
Inputs you'll probably change
{
"domains": ["example.com", "shop.example.de", "https://www.example.co.uk/page"],
"expiryWarningDays": 45,
"dnsRecordTypes": ["A", "MX", "TXT"],
"checkHttp": false,
"maxConcurrency": 10
}
expiryWarningDays adds an issue when the domain expires within that many days. Certificates are flagged at that value or 14 days, whichever is lower, because 90-day certificates renew automatically about 30 days before they expire. Every check (checkWhois, checkDns, checkEmailSecurity, checkSsl, checkHttp) can be switched off for faster runs.
Wrap-up
-
A few
.com/.net/.orgdomains: the ~50-line RDAP +sslscript is all you need. Treat errors as results. - Mixed TLDs, subdomains, and SPF/DMARC/HTTPS on top, as a scheduled report: Domain WHOIS & DNS Checker.
Feedback and feature requests are welcome, either in the comments or on the Actor's Issues tab.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.