Automating Domain Registration Checks with Python and Scheduled Scripts
A domain record can change while nobody is watching. A registrar transfer finishes. Nameservers move. An expiration date shifts after renewal. Checking each record by hand gets tedious, especially when you need to answer a simple question: what changed since the last check?
A small Python script can give you a useful history. It can request registration data, save a dated snapshot, compare it with the previous successful result, and print a clear message. Add cron, and those checks can run on a schedule without another browser tab or calendar reminder.
Build a history you can trust, one successful check at a time.
- Save each successful response with a UTC timestamp.
- Compare registration data while excluding the observation timestamp.
- Keep failed requests separate from successful snapshots.
Decide What Your Registration Check Should Tell You
This tutorial checks one domain per run. It uses RDAP, a structured format for registration information, through an HTTP endpoint. JSON is easier to process than loosely formatted text, although individual responses still need inspection.
The goal is to flag changes in the returned registration data. That is different from proving that a domain is available to buy or that its website is working. Treat a changed record as a reason to investigate. A missing record or failed lookup is not a purchase guarantee.
Your first successful run establishes a baseline. Later runs compare their results with the most recent successful snapshot. A failed request leaves that baseline untouched. This distinction prevents a temporary outage from looking like every registration field disappeared.
Prepare a Small Python Workspace
Use Python 3.9 or later on a machine with cron. Create a dedicated directory and virtual environment. The examples use /home/alex/domain-monitor; replace that path with your own throughout the tutorial.
mkdir -p /home/alex/domain-monitor
cd /home/alex/domain-monitor
python3 -m venv .venv
.venv/bin/python -m pip install requests
Save the finished program as check_domain.py inside that directory. It will create its snapshot directory beside the script. This makes its behavior independent of the working directory cron happens to use.
Keep the first version focused. One domain, one request, one comparison. Once that works reliably, you can decide whether a larger monitoring system is worth maintaining.
Build the HTTP Request With Clear Failure Boundaries
Start with a function that retrieves and validates a response. The endpoint takes the lookup identifier and domain in the request path. Encode the domain component before adding it to that path, and provide a timeout explicitly.
Use lookup API documentation to check request parameters and response fields. It describes the response envelope, protocol-specific data, and documented errors. Its public endpoint is intended for occasional interactive use, so keep this learning exercise modest and check suitability before running an ongoing schedule.
The function below accepts a response only after checking its HTTP status, decoding JSON, and confirming that the expected data object exists. Those checks catch different problems. A server error is not the same as malformed JSON, and valid JSON can still have an unexpected structure.
import requests
from urllib.parse import quote
def fetch_record(domain):
endpoint = (
"https://whoisrequest.org/api/v1/lookup/rdap/"
+ quote(domain, safe="")
)
response = requests.get(
endpoint,
headers={"Accept": "application/json"},
timeout=(5, 25),
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
raise ValueError("Expected a JSON object")
data = payload.get("data")
if not isinstance(data, dict) or not data:
raise ValueError("Missing or empty registration data")
return payload
The timeout tuple gives connection setup five seconds and socket reads twenty-five seconds. It does not impose a strict thirty-second deadline on the entire program. A read timeout limits how long the client waits between incoming bytes.
For this small checker, avoid automatic retries at first. A failed run will be recorded in the log, and the next scheduled run can try again. If you later add retries, limit them, use increasing delays, and respect any retry instructions returned by the service. Repeating invalid requests will not repair their parameters.
Choose What Counts as a Change
Saving the complete response preserves context for later review. Comparing every byte, however, can create noise. JSON key order can vary. The observation timestamp should change whenever a new lookup happens.
The example compares the data object after removing fields named checked_at. It sorts dictionary keys when writing JSON, while preserving array order and all other values. This is deliberately conservative: it flags any remaining difference rather than guessing which registration fields matter to you.
How Snapshot and Comparison Data Differ
| Data | Saved in Snapshot | Used for Comparison |
|---|---|---|
| Complete response | Yes, for later inspection | Only its data object |
| Observation timestamp | Yes | Excluded when named checked_at |
| Registration events and nameservers | Yes, when returned | Included |
After inspecting real snapshots, you may want a narrower comparison. For example, you might monitor only expiration events and nameservers. Do that by selecting verified fields from the actual response. Avoid inventing field paths from assumptions about another provider.
Array order can still trigger a change even when the same items remain present. Sort a specific array only after confirming that its order has no meaning. Keep registration event timestamps intact: those describe the record and may be exactly what you want to monitor.
Save Dated Snapshots and Report Differences
Combine the request function above with the following code in the same file. Place this section beneath it. Each successful run writes a new JSON snapshot, prints a status, and prints a unified diff when comparison data changes.
import difflib
import json
import os
from pathlib import Path
import re
import sys
from datetime import datetime, timezone
ROOT = Path(__file__).resolve().parent
def normalize_domain(value):
domain = value.strip().rstrip(".").encode("idna").decode("ascii").lower()
labels = domain.split(".")
if len(domain) > 253 or len(labels) < 2:
raise ValueError("Provide a complete domain name")
for label in labels:
if not re.fullmatch(
r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label
):
raise ValueError("Invalid domain label")
return domain
def comparison_data(value):
if isinstance(value, dict):
return {
key: comparison_data(item)
for key, item in value.items()
if key != "checked_at"
}
if isinstance(value, list):
return [comparison_data(item) for item in value]
return value
def formatted(value):
return json.dumps(
value,
indent=2,
sort_keys=True,
ensure_ascii=False,
) + "\n"
def run(domain):
directory = ROOT / "snapshots" / domain
directory.mkdir(parents=True, exist_ok=True)
previous_files = sorted(directory.glob("*.json"))
previous = None
if previous_files:
previous = json.loads(
previous_files[-1].read_text(encoding="utf-8")
)
payload = fetch_record(domain)
observed = datetime.now(timezone.utc)
snapshot = {
"domain": domain,
"observed_at": observed.isoformat(),
"response": payload,
}
current_text = formatted(comparison_data(payload["data"]))
changes = None
if previous is not None:
previous_text = formatted(
comparison_data(previous["response"]["data"])
)
if previous_text != current_text:
changes = "".join(
difflib.unified_diff(
previous_text.splitlines(keepends=True),
current_text.splitlines(keepends=True),
fromfile="previous",
tofile="current",
)
)
filename = observed.strftime("%Y%m%dT%H%M%S.%fZ") + ".json"
destination = directory / filename
temporary = directory / (filename + ".tmp")
try:
temporary.write_text(formatted(snapshot), encoding="utf-8")
os.replace(temporary, destination)
finally:
temporary.unlink(missing_ok=True)
if previous is None:
status = "BASELINE"
elif changes is not None:
status = "CHANGED"
else:
status = "UNCHANGED"
print(f"{snapshot['observed_at']} {status} {domain}")
if changes:
print(changes, end="")
def main():
if len(sys.argv) != 2:
print("Usage: check_domain.py DOMAIN", file=sys.stderr)
return 1
try:
domain = normalize_domain(sys.argv[1])
run(domain)
except (
requests.exceptions.RequestException,
ValueError,
OSError,
KeyError,
TypeError,
) as exc:
timestamp = datetime.now(timezone.utc).isoformat()
print(
f"{timestamp} ERROR {type(exc).__name__}: {exc}",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
The temporary file and replacement step help prevent an interrupted write from leaving a partial snapshot with a .json extension. Both files live in the same directory. Failed requests never reach the save step.
A corrupt previous snapshot produces an error instead of silently creating a fresh baseline. That behavior protects your comparison history. Inspect the damaged file and restore it or move it aside deliberately before trying again.
The script exits successfully for both changed and unchanged results. A detected change is an observation, not a program failure. Request, parsing, and storage failures return exit code 1.
Keep Every Timestamp on the Same Clock
The script records its own observation time immediately after receiving a valid response. It uses a timezone-aware UTC datetime. That timestamp describes when this machine finished the lookup, not when a registry changed the record.
Filenames use UTC too. Their fixed format sorts chronologically and avoids colons, which can complicate moving files between systems. Microseconds reduce filename collisions, although preventing overlapping runs remains necessary.
Preserve upstream timestamps inside the saved response. If you later calculate days until expiration, parse the relevant event timestamp and compare timezone-aware datetimes. Do not strip offsets merely to silence a Python error. A timestamp without an offset needs an explicit interpretation before comparison.
Keep the host clock synchronized through its normal time service. Incorrect system time affects filenames, logs, and the order in which snapshots are selected.
Run the Script Manually Before Scheduling It
Run the checker from your terminal using the virtual environment’s Python interpreter:
/home/alex/domain-monitor/.venv/bin/python /home/alex/domain-monitor/check_domain.py example.com
The first successful run should print BASELINE. Open the saved JSON and inspect the returned fields. A later successful run should print UNCHANGED unless the comparison data differs.
Before enabling cron, check these behaviors:
- An invalid domain exits with an error and creates no successful snapshot.
- A repeated successful lookup preserves both dated files.
- A changed comparison fixture produces a readable diff in a separate test directory.
- A simulated request failure preserves the previous successful baseline.
Use saved response fixtures for repeated comparison tests. They let you test changes without sending unnecessary requests. Avoid editing your actual monitoring history to force an alert.
Schedule Checks With Cron and Prevent Overlap
Once the endpoint’s usage terms suit your intended schedule, open your user crontab with crontab -e. This Linux example runs once each Monday at 06:15 according to the cron daemon’s configured timezone:
15 6 * * 1 /usr/bin/flock -n /home/alex/domain-monitor/check.lock /home/alex/domain-monitor/.venv/bin/python /home/alex/domain-monitor/check_domain.py example.com >> /home/alex/domain-monitor/check.log 2>&1
Confirm the location of flock on your machine before copying that entry. It holds a lock while Python runs. If another scheduled instance still holds the lock, the new invocation exits without starting another lookup.
Cron has a smaller environment than an interactive shell. Absolute paths avoid reliance on shell startup files or an activated virtual environment. The redirection appends standard output and errors to one log.
The schedule’s timezone and the snapshot timezone serve different purposes. Cron decides when the process starts. Python records observations in UTC regardless of that scheduling timezone. Check your host’s cron configuration if daylight saving changes would affect your preferred run time.
Do not assume the existence of a cron entry proves that checks are working. Read the log after the first scheduled execution and confirm that a new snapshot appeared.
Make Change Flags Useful Over Time
A line beginning with CHANGED gives you a straightforward signal for a later notification system. The diff supplies context. Before sending alerts elsewhere, keep the checker running long enough to understand how much variation the source returns.
Review the small operational details that keep the history useful:
- Rotate the log and choose a snapshot retention period.
- Restrict file access if responses contain contact information.
- Check for overdue successful snapshots as well as changed records.
- Review unexpected changes against the preserved response and its source context.
A silent log does not prove stability. The host might be offline, cron might have stopped, or the filesystem might be full. Monitoring the age of the latest successful snapshot catches failures that a change-only alert misses.
Let the Next Run Leave a Clear Record
You now have the pieces for a modest registration checker: a bounded HTTP request, validated JSON, dated snapshots, a repeatable comparison, and a scheduled execution path. Each piece answers a different question about reliability.
Start with one domain and inspect the first few results. Adjust comparison rules only after seeing real data. The useful outcome is a readable history that lets you trace a change, understand a failed run, and know exactly when your script last obtained a successful response.