Diagnostic Domain Lifecycle
Domain nearing expiration without renewal action
Every domain has a hard expiration date sitting quietly at the registry. Most of the time auto-renew handles it and nobody thinks about it again. Then one day auto-renew is off, the card on file expired months ago, or the renewal reminder emails went to an inbox nobody reads, and the domain drifts through its 30 or 60 day warning window with no action taken. If it lapses, the registry deletes it and the site, DNS, and email for that domain all go dark at the same time.
A domain's expiration date lives at the registry, not in any DNS record, so it cannot be fixed by editing A, MX, or TXT records. It gets missed when auto-renew is off, the payment method on file has expired, or the registrar's renewal reminder emails go to an address nobody actively reads. Check the real expiration date with RDAP, the modern replacement for WHOIS, and if it is inside your warning window, log into the registrar and renew now or turn on auto-renew with a valid card. Full code, tests, and the check are below.
The problem in plain words
Every domain name has a hard expiration date set at the registry, the authoritative database that tracks who owns a domain and until when. When a domain is registered or renewed, that date moves forward by a year or more, up to a maximum registration length ICANN sets at ten years. Nothing about your website or DNS zone changes when that clock is running out. The domain simply keeps working, right up until the moment it does not.
The gap that causes trouble is that nobody is watching that expiration date on a schedule. Auto-renew was supposed to handle it, but auto-renew silently fails when the payment method on file has expired, or it was never turned on in the first place. ICANN requires registrars to send renewal reminder emails, roughly one month and one week before expiration, but those emails go to whatever registrant or admin contact address was set when the domain was first registered, which is often stale, filtered as spam, or simply not checked by anyone anymore. The domain drifts inside the 30 or 60 day warning window with no renewal ever triggered, and if it lapses, the registry deletes it and the site, DNS, and email for that domain all go dark at once.
Why it happens
This is a registrar and registry billing and account state, not a DNS zone record, so it cannot be fixed by editing A, MX, or TXT records. A few common reasons a domain drifts toward expiration unnoticed:
- Auto-renew was never turned on for the domain in the registrar account, so nothing happens automatically when the expiration date approaches.
- The payment method on file has expired or was declined, so auto-renew tries to run and silently fails, often with no obvious alert to the account owner.
- ICANN requires registrars to send renewal reminders roughly one month and one week before expiration, but those emails go to a registrant or admin contact address that is stale, filtered, or simply not read by anyone actively managing the domain.
- Nobody has a scheduled check of the RDAP or WHOIS expiration date, so the domain quietly enters its 30 or 60 day warning window with no one watching.
Once the registration date passes with no renewal, the registry marks the domain as expired and it can move through an Auto-Renew Grace Period and then a Redemption Grace Period before actual deletion, at which point it becomes available for anyone else to register.
The DNS zone has nothing to do with this problem. A domain can have perfect A, MX, and TXT records right up until the day it is deleted at the registry, because expiration is an account and billing state above the zone, not inside it. The only reliable source of truth is the expiration date returned by RDAP or WHOIS, checked on a schedule, not whatever a reminder email inbox happens to catch.
The fix, as a flow
Do not wait for a reminder email to arrive. Query RDAP for the real expiration date on a schedule, compare it against a warning threshold, and when the domain crosses into the warning window, act at the registrar directly: renew now or confirm auto-renew is on with a valid payment method. If the domain already lapsed, use the registrar's restore flow immediately, since a later restore from the Redemption Grace Period usually costs an extra fee.
How to fix it
Check the real expiration date with RDAP
RDAP is the modern, ICANN-mandated replacement for WHOIS as of January 2025. It follows the bootstrap redirect to the authoritative registry server, for example rdap.verisign.com for .com, and returns a clean, structured date. A bad result is an expiration date less than your warning threshold, commonly 30 or 60 days, away from today.
curl -s https://rdap.org/domain/example.com \
| jq -r '.events[] | select(.eventAction=="expiration") | .eventDate'
# Example good output:
# 2027-08-05T04:00:00Z
Fall back to legacy WHOIS if RDAP is unavailable
Some registries still answer WHOIS more reliably than RDAP in certain client setups. Look for the Registry Expiry Date line. A bad result is the same as above, a date inside your warning window.
whois example.com | grep -i "Registry Expiry Date"
# Example good output:
# Registry Expiry Date: 2027-08-05T04:00:00Z
Check whether the domain already lapsed into a grace period
Also check the domain status field. A bad result is pendingDelete, redemptionPeriod, or autoRenewPeriod, which signal the domain is already past expiration and inside the roughly 0 to 45 day Auto-Renew Grace Period or the 30 day Redemption Grace Period.
whois example.com | grep -i "Domain Status"
# Concerning statuses:
# Domain Status: autoRenewPeriod
# Domain Status: redemptionPeriod
# Domain Status: pendingDelete
Turn on auto-renew or renew now at the registrar
There is no DNS record to change here. Log into the registrar account, for example Cloudflare Registrar, Namecheap, or GoDaddy, and turn on auto-renew for the domain, or manually click Renew now and pay for the extension. Registrations typically renew a year at a time, often capped at ten years total per ICANN rules.
Confirm the payment method on file is valid
An expired card is the most common reason auto-renew silently fails. Update the payment method in the registrar account so future renewals actually go through instead of failing quietly again.
Restore the domain immediately if it already lapsed
If the domain is inside the Auto-Renew Grace Period, typically 0 to 45 days post-expiry depending on the registrar, or the 30 day Redemption Grace Period after registry deletion, use the registrar's restore flow right away. A Redemption Grace Period restore usually carries an extra redemption fee on top of the normal renewal fee, and the longer you wait the more likely the domain is released to anyone else once it reaches pendingDelete.
Point the contact email at an address someone actually reads
As a safety net going forward, set the registrant and admin contact email on the registrar account to an address that is actively monitored. ICANN requires registrars to send reminders roughly one month and one week before expiration, plus a post-expiration notice if the registration is deleted, and those reminders are worthless if they land in an inbox nobody checks.
How to check it worked
Re-run the RDAP check and confirm the expiration event date has moved out beyond your warning threshold and the domain status no longer shows a pending or grace period state.
curl -s https://rdap.org/domain/example.com \
| jq -r '.events[] | select(.eventAction=="expiration") | .eventDate'
curl -s https://rdap.org/domain/example.com | jq -r '.status'
whois example.com | grep -iE "Expiry|Status"
dig +short example.com
dig +short MX example.com
A good result looks like this: the expiration event date has jumped roughly one year or more further out, for example from 2026-08-05 to 2027-08-05, and the status array contains active with no pendingDelete, redemptionPeriod, or autoRenewPeriod flags. The dig commands still return the expected A and MX records, proving the domain was never actually pulled from the zone.
The full code
This one automates detection end to end. The script queries RDAP for the domain's expiration event, computes days remaining with a pure, I/O-free function, classifies the severity against a set of thresholds, and logs or alerts when the domain crosses one. There is no Cloudflare zone DNS API call in the repair path, since renewal is a registrar and billing action, not a DNS record change, and even Cloudflare's own Registrar API does not expose a renew endpoint as of 2025-2026, only an auto_renew toggle for domains registered through Cloudflare Registrar itself.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Check a domain's real expiration date over RDAP and alert when it crosses
a warning threshold. Renewal itself always happens at the registrar, since
this is a billing state, not a DNS zone record. DRY_RUN only reports until
turned off, and even then this script only sends alerts, it never renews
anything on your behalf.
"""
import os
import logging
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_domain_expiry")
DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
WARN_THRESHOLDS_DAYS = [30, 14, 7, 1]
def days_until_expiry(expiration_iso: str, now_iso: str, warn_thresholds_days: list = None) -> dict:
"""
Pure decision logic, no I/O.
Input: expiration_iso (RDAP eventDate string, e.g. '2026-08-05T04:00:00Z'),
now_iso (current time as ISO string, injected for testability),
warn_thresholds_days (sorted descending list of alert thresholds).
Output: {
'days_remaining': int,
'severity': 'ok' | 'warning' | 'critical' | 'expired',
'triggered_threshold': int | None # smallest threshold crossed, or None
}
"""
if warn_thresholds_days is None:
warn_thresholds_days = [30, 14, 7, 1]
expiration = datetime.fromisoformat(expiration_iso.replace("Z", "+00:00")).astimezone(timezone.utc)
now = datetime.fromisoformat(now_iso.replace("Z", "+00:00")).astimezone(timezone.utc)
days_remaining = int((expiration - now).total_seconds() // 86400)
if days_remaining < 0:
return {"days_remaining": days_remaining, "severity": "expired", "triggered_threshold": None}
candidates = [t for t in warn_thresholds_days if t >= days_remaining]
triggered_threshold = min(candidates) if candidates else None
if triggered_threshold is None:
severity = "ok"
elif triggered_threshold <= 7:
severity = "critical"
else:
severity = "warning"
return {
"days_remaining": days_remaining,
"severity": severity,
"triggered_threshold": triggered_threshold,
}
def fetch_rdap_expiration(domain: str) -> str:
"""Query RDAP for a domain and return the expiration eventDate string."""
import requests
r = requests.get(f"https://rdap.org/domain/{domain}", timeout=30)
r.raise_for_status()
data = r.json()
for event in data.get("events", []):
if event.get("eventAction") == "expiration":
return event["eventDate"]
raise ValueError(f"No expiration event found in RDAP response for {domain}")
def fetch_rdap_status(domain: str) -> list:
"""Query RDAP for a domain and return its status list."""
import requests
r = requests.get(f"https://rdap.org/domain/{domain}", timeout=30)
r.raise_for_status()
return r.json().get("status", [])
def send_alert(domain: str, result: dict, statuses: list) -> None:
"""Send an alert. Replace with email, Slack, or a webhook call in production."""
if DRY_RUN:
log.info(
"[dry run] would alert: %s is %s, %d day(s) remaining, statuses=%s",
domain, result["severity"], result["days_remaining"], statuses,
)
return
log.warning(
"ALERT: %s is %s, %d day(s) remaining, statuses=%s. Renew at the registrar, "
"this cannot be fixed through the DNS provider API.",
domain, result["severity"], result["days_remaining"], statuses,
)
def run():
log.info("Checking domain expiration for %s (DRY_RUN=%s)", DNS_DOMAIN, DRY_RUN)
expiration_iso = fetch_rdap_expiration(DNS_DOMAIN)
statuses = fetch_rdap_status(DNS_DOMAIN)
now_iso = datetime.now(timezone.utc).isoformat()
result = days_until_expiry(expiration_iso, now_iso, WARN_THRESHOLDS_DAYS)
log.info(
"%s expires %s: %d day(s) remaining, severity=%s",
DNS_DOMAIN, expiration_iso, result["days_remaining"], result["severity"],
)
grace_flags = {"pendingdelete", "redemptionperiod", "autorenewperiod"}
lowered_statuses = [s.lower() for s in statuses]
if any(flag in lowered_statuses for flag in grace_flags):
log.warning("%s is already in a grace or pending-delete state: %s", DNS_DOMAIN, statuses)
send_alert(DNS_DOMAIN, result, statuses)
return
if result["severity"] in ("warning", "critical", "expired"):
send_alert(DNS_DOMAIN, result, statuses)
else:
log.info("OK: %s has plenty of runway left before renewal is needed.", DNS_DOMAIN)
if __name__ == "__main__":
run()
/**
* Check a domain's real expiration date over RDAP and alert when it crosses
* a warning threshold. Renewal itself always happens at the registrar, since
* this is a billing state, not a DNS zone record. DRY_RUN only reports until
* turned off, and even then this script only sends alerts, it never renews
* anything on your behalf.
*/
import { pathToFileURL } from "node:url";
const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const WARN_THRESHOLDS_DAYS = [30, 14, 7, 1];
/**
* Pure decision logic, no I/O.
* Input: expirationIso (RDAP eventDate string, e.g. '2026-08-05T04:00:00Z'),
* nowIso (current time as ISO string, injected for testability),
* warnThresholdsDays (sorted descending array of alert thresholds).
* Output: {
* daysRemaining: number,
* severity: 'ok' | 'warning' | 'critical' | 'expired',
* triggeredThreshold: number | null
* }
*/
export function daysUntilExpiry(expirationIso, nowIso, warnThresholdsDays = [30, 14, 7, 1]) {
const expiration = new Date(expirationIso);
const now = new Date(nowIso);
const daysRemaining = Math.floor((expiration.getTime() - now.getTime()) / 86400000);
if (daysRemaining < 0) {
return { daysRemaining, severity: "expired", triggeredThreshold: null };
}
const candidates = warnThresholdsDays.filter((t) => t >= daysRemaining);
const triggeredThreshold = candidates.length > 0 ? Math.min(...candidates) : null;
let severity;
if (triggeredThreshold === null) {
severity = "ok";
} else if (triggeredThreshold <= 7) {
severity = "critical";
} else {
severity = "warning";
}
return { daysRemaining, severity, triggeredThreshold };
}
/** Query RDAP for a domain and return the expiration eventDate string. */
async function fetchRdapExpiration(domain) {
const res = await fetch(`https://rdap.org/domain/${domain}`);
if (!res.ok) throw new Error(`RDAP returned ${res.status} for ${domain}`);
const data = await res.json();
const event = (data.events || []).find((e) => e.eventAction === "expiration");
if (!event) throw new Error(`No expiration event found in RDAP response for ${domain}`);
return event.eventDate;
}
/** Query RDAP for a domain and return its status list. */
async function fetchRdapStatus(domain) {
const res = await fetch(`https://rdap.org/domain/${domain}`);
if (!res.ok) throw new Error(`RDAP returned ${res.status} for ${domain}`);
const data = await res.json();
return data.status || [];
}
/** Send an alert. Replace with email, Slack, or a webhook call in production. */
function sendAlert(domain, result, statuses) {
if (DRY_RUN) {
console.log(
`[dry run] would alert: ${domain} is ${result.severity}, ${result.daysRemaining} day(s) remaining, statuses=${JSON.stringify(statuses)}`
);
return;
}
console.warn(
`ALERT: ${domain} is ${result.severity}, ${result.daysRemaining} day(s) remaining, statuses=${JSON.stringify(statuses)}. ` +
"Renew at the registrar, this cannot be fixed through the DNS provider API."
);
}
export async function run() {
console.log(`Checking domain expiration for ${DNS_DOMAIN} (DRY_RUN=${DRY_RUN})`);
const expirationIso = await fetchRdapExpiration(DNS_DOMAIN);
const statuses = await fetchRdapStatus(DNS_DOMAIN);
const nowIso = new Date().toISOString();
const result = daysUntilExpiry(expirationIso, nowIso, WARN_THRESHOLDS_DAYS);
console.log(
`${DNS_DOMAIN} expires ${expirationIso}: ${result.daysRemaining} day(s) remaining, severity=${result.severity}`
);
const graceFlags = new Set(["pendingdelete", "redemptionperiod", "autorenewperiod"]);
const loweredStatuses = statuses.map((s) => s.toLowerCase());
if (loweredStatuses.some((s) => graceFlags.has(s))) {
console.warn(`${DNS_DOMAIN} is already in a grace or pending-delete state: ${JSON.stringify(statuses)}`);
sendAlert(DNS_DOMAIN, result, statuses);
return;
}
if (["warning", "critical", "expired"].includes(result.severity)) {
sendAlert(DNS_DOMAIN, result, statuses);
} else {
console.log(`OK: ${DNS_DOMAIN} has plenty of runway left before renewal is needed.`);
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((e) => { console.error(e); process.exit(1); });
}
Add a test
The part worth testing is the pure decision logic: how many days remain, and what severity that maps to against the threshold list. The function takes plain ISO strings in and returns a plain object, so the tests use fixed timestamps and need no network access.
from check_domain_expiry import days_until_expiry
def test_ok_when_plenty_of_runway():
result = days_until_expiry("2026-09-15T00:00:00Z", "2026-08-01T00:00:00Z")
assert result["severity"] == "ok"
assert result["triggered_threshold"] is None
assert result["days_remaining"] == 45
def test_warning_at_thirty_day_boundary():
result = days_until_expiry("2026-08-31T00:00:00Z", "2026-08-01T00:00:00Z")
assert result["days_remaining"] == 30
assert result["severity"] == "warning"
assert result["triggered_threshold"] == 30
def test_warning_inside_fourteen_day_window():
result = days_until_expiry("2026-08-10T00:00:00Z", "2026-08-01T00:00:00Z")
assert result["days_remaining"] == 9
assert result["severity"] == "warning"
assert result["triggered_threshold"] == 14
def test_critical_at_seven_day_boundary():
result = days_until_expiry("2026-08-08T00:00:00Z", "2026-08-01T00:00:00Z")
assert result["days_remaining"] == 7
assert result["severity"] == "critical"
assert result["triggered_threshold"] == 7
def test_critical_one_day_left():
result = days_until_expiry("2026-08-02T00:00:00Z", "2026-08-01T00:00:00Z")
assert result["days_remaining"] == 1
assert result["severity"] == "critical"
assert result["triggered_threshold"] == 1
def test_expired_when_negative():
result = days_until_expiry("2026-07-25T00:00:00Z", "2026-08-01T00:00:00Z")
assert result["days_remaining"] == -7
assert result["severity"] == "expired"
assert result["triggered_threshold"] is None
def test_custom_thresholds():
result = days_until_expiry(
"2026-08-21T00:00:00Z", "2026-08-01T00:00:00Z", warn_thresholds_days=[60, 20, 5]
)
assert result["days_remaining"] == 20
assert result["severity"] == "warning"
assert result["triggered_threshold"] == 20
import { test } from "node:test";
import assert from "node:assert/strict";
import { daysUntilExpiry } from "./check-domain-expiry.js";
test("ok when plenty of runway", () => {
const result = daysUntilExpiry("2026-09-15T00:00:00Z", "2026-08-01T00:00:00Z");
assert.equal(result.severity, "ok");
assert.equal(result.triggeredThreshold, null);
assert.equal(result.daysRemaining, 45);
});
test("warning at thirty day boundary", () => {
const result = daysUntilExpiry("2026-08-31T00:00:00Z", "2026-08-01T00:00:00Z");
assert.equal(result.daysRemaining, 30);
assert.equal(result.severity, "warning");
assert.equal(result.triggeredThreshold, 30);
});
test("warning inside fourteen day window", () => {
const result = daysUntilExpiry("2026-08-10T00:00:00Z", "2026-08-01T00:00:00Z");
assert.equal(result.daysRemaining, 9);
assert.equal(result.severity, "warning");
assert.equal(result.triggeredThreshold, 14);
});
test("critical at seven day boundary", () => {
const result = daysUntilExpiry("2026-08-08T00:00:00Z", "2026-08-01T00:00:00Z");
assert.equal(result.daysRemaining, 7);
assert.equal(result.severity, "critical");
assert.equal(result.triggeredThreshold, 7);
});
test("critical one day left", () => {
const result = daysUntilExpiry("2026-08-02T00:00:00Z", "2026-08-01T00:00:00Z");
assert.equal(result.daysRemaining, 1);
assert.equal(result.severity, "critical");
assert.equal(result.triggeredThreshold, 1);
});
test("expired when negative", () => {
const result = daysUntilExpiry("2026-07-25T00:00:00Z", "2026-08-01T00:00:00Z");
assert.equal(result.daysRemaining, -7);
assert.equal(result.severity, "expired");
assert.equal(result.triggeredThreshold, null);
});
test("custom thresholds", () => {
const result = daysUntilExpiry("2026-08-21T00:00:00Z", "2026-08-01T00:00:00Z", [60, 20, 5]);
assert.equal(result.daysRemaining, 20);
assert.equal(result.severity, "warning");
assert.equal(result.triggeredThreshold, 20);
});
Case studies
The domain registered by an employee who left three years ago
A small business's main domain was originally registered under a former employee's personal email address. Every renewal reminder for two years landed in an inbox nobody at the company could access. Auto-renew had been on, but the card tied to it had expired the previous year, so it failed silently every time.
A routine RDAP check as part of an unrelated migration turned up an expiration date eleven days out. The team logged into the registrar, reset the account recovery email to a shared team address, updated the card, and renewed manually with days to spare.
The domain that actually lapsed over a holiday week
A side project's domain expired while its owner was traveling. Auto-renew was never enabled, and the one reminder email that would have caught it was buried under vacation-mode inbox noise. By the time anyone noticed, the site was down and dig returned nothing for the domain.
Whois showed status redemptionPeriod. The registrar's restore flow brought the domain back within a day, for the renewal fee plus a redemption fee, and DNS and mail resumed working as soon as the restore completed, since the zone records had never actually been touched.
Once a scheduled RDAP check is watching the real expiration date, a domain lapsing becomes something that gets caught weeks in advance instead of discovered the day the site goes dark. Keep auto-renew on with a valid payment method as the primary defense, and treat the scheduled check as the backup that catches it when auto-renew quietly fails.
FAQ
Why is my domain about to expire even though I never got a warning?
Registrars are required to send renewal reminder emails, but they usually go to whatever contact address was set years ago, which may be unmonitored, filtered as spam, or simply the wrong inbox. Nobody is checking the actual expiration date at the registry on a schedule, so the domain drifts through its warning window unnoticed.
Can I fix an expiring domain by editing DNS records?
No. Expiration is a registrar and registry billing state, not a DNS zone record. There is no A, MX, or TXT record to change. You have to log into the registrar account and renew the domain, or turn on auto-renew and fix the payment method on file.
What happens if the domain actually expires and I do nothing?
The domain first enters the Auto-Renew Grace Period, typically 0 to 45 days depending on the registrar, where the site, DNS, and email may already be down but the registrar can often still restore it for the normal renewal price. After that it enters a 30 day Redemption Grace Period at the registry, where restoring it usually costs an extra redemption fee on top of renewal.
Related field notes
Citations
On the problem:
- ICANN: FAQs for Registrants, Domain Name Renewals and Expiration. icann.org/resources/pages/domain-name-renewal-expiration-faqs
- ICANN: Auto-Renew Grace Period, Acronyms and Terms. icann.org/en/icann-acronyms-and-terms/auto-renew-grace-period-en
- RFC 9083, JSON Responses for the Registration Data Access Protocol (RDAP). datatracker.ietf.org/doc/rfc9083
On the solution:
- ICANN: 5 Things every Registrant Should Know about ERRP. icann.org/resources/pages/registrant-about-errp
- RDAP.ORG, the bootstrap lookup service. about.rdap.org
- Cloudflare Registrar API documentation, Update Domain (auto_renew). developers.cloudflare.com/api/resources/registrar/subresources/domains/methods/update
Stuck on a tricky one?
If you have a DNS, domain, or registrar automation problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this save your domain from lapsing?
If this caught an expiring domain before it went dark, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi