Diagnostic Domain Lifecycle
Auto-renewal enabled but payment failed
Auto-renew is on. You checked the box years ago and never thought about it again. But the expiration date at the registry keeps getting closer, because the card on file was declined and the renewal charge never actually went through. Auto-renew being on is a setting, not a guarantee. Here is why the charge fails quietly, how to catch it before the domain lapses, and a script that watches for it on a schedule.
Auto-renew only works if the charge to the payment method on file actually succeeds. When the card is expired, declined, or flagged by the bank as suspicious, the renewal attempt fails, no charge posts, and the domain's real expiration date at the registry does not move, even though the auto-renew toggle in the registrar dashboard still shows on. Check the true expiration date with RDAP, the modern replacement for WHOIS, and treat a domain that has not moved forward while inside its warning window as a payment failure. The fix happens at the registrar: update the card, retry the charge, or pay the renewal manually. Full checks, a script, and tests are below.
The problem in plain words
Auto-renew is a promise the registrar makes to attempt a charge before the domain expires, not a promise that the charge will succeed. When you turned it on, the registrar stored a payment method and a rule that says "charge this card automatically a few weeks before the deadline." That rule fires on schedule every time. What it does not guarantee is that the card is still good when the rule fires.
Cards expire, get replaced after a bank reissues them, get declined for insufficient funds, or get blocked by the bank's fraud system because a charge from a domain registrar looks unfamiliar. When any of that happens, the renewal attempt fails, the registrar usually retries once or twice over the following days, and then it gives up. The auto-renew toggle in the account still says on, because nobody turned it off. The domain simply never got renewed, and the expiration date at the registry keeps counting down exactly as if auto-renew had never been enabled.
Why it happens
This is a billing and account state at the registrar, not a DNS zone record, so it cannot be fixed by editing A, MX, or TXT records. A few common reasons the charge fails while auto-renew still shows as enabled:
- The card on file expired naturally, since most cards are valid two to five years and nobody connected updating the card to the domain's renewal date.
- The bank reissued a new card number after a fraud alert or a routine reissue, and the old number stored at the registrar was never updated.
- The charge was declined for insufficient funds, or the bank's fraud system blocked it because a recurring charge from a domain registrar does not look like a familiar merchant.
- The registrar retried the charge two or three times over a short window and then stopped, without a loud enough alert reaching anyone who actually manages the domain.
- PayPal or a stored wallet was the payment method, and the underlying funding source behind it changed or was removed, which the registrar has no way to detect until it tries to charge it.
The registrar dashboard almost always has a quiet billing or renewal history log that shows the failed attempt, but very few people check it unless something else prompts them to look.
Auto-renew on means "a charge will be attempted." It does not mean "the domain is renewed." The only fact that actually matters is whether the expiration date at the registry moved forward. RDAP tells you that directly. If the warning window keeps closing in and the date has not moved, the payment failed, no matter what the toggle in the dashboard says.
The fix, as a flow
Do not trust the toggle. Query RDAP for the domain's real expiration date and status on a schedule, and treat a date that has not moved while inside the warning window as a failed charge. Then go to the registrar directly to update the payment method and retry or pay manually. On the DNS side, make sure a CAA record with an iodef contact is in place, so that if the domain ever does slip into a grace period, anything watching certificate issuance for it also has a channel to alert someone.
How to fix it
Check the real expiration date with RDAP
RDAP is the modern, ICANN-mandated replacement for WHOIS. It returns a clean, structured expiration event. A bad result is an expiration date that has not moved forward from the last time you checked, even though auto-renew should have pushed it out by a year or more by now.
curl -s https://rdap.org/domain/example.com \
| jq -r '.events[] | select(.eventAction=="expiration") | .eventDate'
# Example bad output if this is unchanged since last month's check:
# 2026-08-05T04:00:00Z
Check the status field for a grace period flag
If the charge failed and the domain already slipped past its expiration date, the status field will say so before anything else does. A bad result is autoRenewPeriod, redemptionPeriod, or pendingDelete.
curl -s https://rdap.org/domain/example.com | jq -r '.status'
# Concerning statuses:
# "autoRenewPeriod"
# "redemptionPeriod"
# "pendingDelete"
Confirm the failed charge in the registrar's billing history
Log into the registrar account and look at the billing or renewal history for the domain. Nearly every registrar logs a declined or failed renewal attempt here, even when the alert email was never seen. This is the direct confirmation that the payment, not just the reminder, is the actual problem.
Update the payment method on file
There is no DNS record for this. Replace the expired or declined card, or reconnect the wallet, directly in the registrar account's billing settings, then trigger a manual retry of the renewal charge if the dashboard offers one instead of waiting for the next scheduled attempt.
Pay the renewal manually if the retry window already closed
If the registrar has stopped retrying, use the manual Renew now flow and pay for the extension directly, rather than waiting for auto-renew to try again on its own schedule, since some registrars only retry a fixed number of times.
Reconcile the CAA iodef alert record through the Cloudflare API
This is the one piece that is a real DNS zone record and is safe to automate. A CAA record with an iodef tag gives certificate authorities, and some monitoring tools, a mailbox to report problems to. Keeping it pointed at an address someone actually reads is a cheap safety net that a script can maintain on its own, separate from the registrar-side payment fix.
; alert channel for anything watching this domain's certificates and status
example.com. 3600 IN CAA 0 iodef "mailto:domains@example.com"
; keep existing issue records unchanged alongside it
; example.com. 3600 IN CAA 0 issue "letsencrypt.org"
How to check it worked
Re-run the RDAP check after the registrar confirms the renewal went through, and confirm the CAA iodef record resolves to the address you expect.
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'
dig +short CAA example.com
dig +short example.com
dig +short MX example.com
A good result looks like this: the expiration event date has jumped forward by roughly a year or more, for example from 2026-08-05 to 2027-08-05, and the status array no longer shows autoRenewPeriod, redemptionPeriod, or pendingDelete. The CAA lookup includes the iodef record pointing at a mailbox someone monitors. The A and MX lookups still return the expected records the whole time, since none of this ever touched the zone data itself.
The full code
This script detects a stalled renewal by comparing the RDAP expiration date against a previously recorded value and the current status, using a pure, I/O-free decision function. When it finds a real problem, it reconciles the one thing that is actually a DNS zone record, the CAA iodef alert contact, through the Cloudflare API. It never touches billing or the registrar account, since that is not something a DNS provider's API can reach.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect a stalled auto-renewal caused by a failed payment charge, using
RDAP as the source of truth, and reconcile the CAA iodef alert record
through the Cloudflare API. Renewing the domain itself always happens at
the registrar, since that is a billing action, not a DNS zone record.
Stays in dry run until DRY_RUN=false.
"""
import os
import logging
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("auto_renewal_payment_failure")
GRACE_STATUSES = {"autorenewperiod", "redemptionperiod", "pendingdelete"}
def evaluate_renewal(expiration_iso, previous_expiration_iso, now_iso, statuses, warn_days=30):
"""
Pure decision function. No I/O.
expiration_iso: current RDAP expiration eventDate string.
previous_expiration_iso: expiration eventDate recorded on a prior run,
or None if this is the first run.
now_iso: current time as an ISO string, injected for testability.
statuses: list of RDAP status strings for the domain.
warn_days: how many days out counts as inside the warning window.
Returns a dict:
days_remaining: int
stalled: bool, True if the expiration date did not move forward
since the previous check
in_grace_period: bool, True if any status flags a lapsed domain
payment_likely_failed: bool, True when the domain is inside the
warning window and either stalled or already in a grace
period, which points at a failed auto-renew charge
"""
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)
stalled = False
if previous_expiration_iso:
previous = datetime.fromisoformat(previous_expiration_iso.replace("Z", "+00:00")).astimezone(timezone.utc)
stalled = expiration <= previous
lowered = {s.lower() for s in statuses}
in_grace_period = bool(lowered & GRACE_STATUSES)
inside_window = days_remaining <= warn_days
payment_likely_failed = in_grace_period or (inside_window and stalled)
return {
"days_remaining": days_remaining,
"stalled": stalled,
"in_grace_period": in_grace_period,
"payment_likely_failed": payment_likely_failed,
}
def fetch_rdap(domain):
import requests
r = requests.get(f"https://rdap.org/domain/{domain}", timeout=30)
r.raise_for_status()
data = r.json()
expiration_iso = None
for event in data.get("events", []):
if event.get("eventAction") == "expiration":
expiration_iso = event["eventDate"]
if not expiration_iso:
raise ValueError(f"No expiration event found in RDAP response for {domain}")
return expiration_iso, data.get("status", [])
def find_caa_iodef_record(zone_id, headers, name):
import requests
r = requests.get(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
headers=headers,
params={"type": "CAA", "name": name},
timeout=30,
)
r.raise_for_status()
for record in r.json().get("result", []):
if record.get("data", {}).get("tag") == "iodef":
return record
return None
def reconcile_iodef_record(zone_id, headers, name, contact_uri, dry_run):
existing = find_caa_iodef_record(zone_id, headers, name)
body = {"type": "CAA", "name": name, "data": {"flags": 0, "tag": "iodef", "value": contact_uri}, "ttl": 3600}
if existing and existing.get("data", {}).get("value") == contact_uri:
log.info("CAA iodef record already points at %s, nothing to change.", contact_uri)
return
if dry_run:
action = "update" if existing else "create"
log.info("Dry run: would %s CAA iodef record on %s to %s", action, name, contact_uri)
return
import requests
if existing:
resp = requests.put(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{existing['id']}",
headers=headers, json=body, timeout=30,
)
else:
resp = requests.post(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
headers=headers, json=body, timeout=30,
)
resp.raise_for_status()
log.info("CAA iodef record on %s now points at %s", name, contact_uri)
def run():
domain = os.environ["DNS_DOMAIN"]
zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
api_token = os.environ["CLOUDFLARE_API_TOKEN"]
dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
contact_uri = os.environ.get("ALERT_CONTACT_URI", "mailto:domains@example.com")
previous_expiration_iso = os.environ.get("PREVIOUS_EXPIRATION_ISO") or None
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
expiration_iso, statuses = fetch_rdap(domain)
now_iso = datetime.now(timezone.utc).isoformat()
result = evaluate_renewal(expiration_iso, previous_expiration_iso, now_iso, statuses)
log.info(
"%s expires %s: %d day(s) remaining, stalled=%s, in_grace_period=%s",
domain, expiration_iso, result["days_remaining"], result["stalled"], result["in_grace_period"],
)
if not result["payment_likely_failed"]:
log.info("OK: %s renewed on schedule, no sign of a failed charge.", domain)
return
log.warning(
"Renewal for %s looks stalled. Auto-renew is likely failing at the payment step. "
"Update the card or pay manually at the registrar, this cannot be fixed through the DNS provider API.",
domain,
)
reconcile_iodef_record(zone_id, headers, domain, contact_uri, dry_run)
if __name__ == "__main__":
run()
/**
* Detect a stalled auto-renewal caused by a failed payment charge, using
* RDAP as the source of truth, and reconcile the CAA iodef alert record
* through the Cloudflare API. Renewing the domain itself always happens at
* the registrar, since that is a billing action, not a DNS zone record.
* Stays in dry run until DRY_RUN=false.
*/
import { pathToFileURL } from "node:url";
const GRACE_STATUSES = new Set(["autorenewperiod", "redemptionperiod", "pendingdelete"]);
/**
* Pure decision function. No I/O.
*
* expirationIso: current RDAP expiration eventDate string.
* previousExpirationIso: expiration eventDate recorded on a prior run,
* or null if this is the first run.
* nowIso: current time as an ISO string, injected for testability.
* statuses: array of RDAP status strings for the domain.
* warnDays: how many days out counts as inside the warning window.
*
* Returns:
* daysRemaining: number
* stalled: boolean, true if the expiration date did not move forward
* since the previous check
* inGracePeriod: boolean, true if any status flags a lapsed domain
* paymentLikelyFailed: boolean, true when the domain is inside the
* warning window and either stalled or already in a grace
* period, which points at a failed auto-renew charge
*/
export function evaluateRenewal(expirationIso, previousExpirationIso, nowIso, statuses, warnDays = 30) {
const expiration = new Date(expirationIso);
const now = new Date(nowIso);
const daysRemaining = Math.floor((expiration.getTime() - now.getTime()) / 86400000);
let stalled = false;
if (previousExpirationIso) {
const previous = new Date(previousExpirationIso);
stalled = expiration.getTime() <= previous.getTime();
}
const lowered = new Set(statuses.map((s) => s.toLowerCase()));
const inGracePeriod = [...lowered].some((s) => GRACE_STATUSES.has(s));
const insideWindow = daysRemaining <= warnDays;
const paymentLikelyFailed = inGracePeriod || (insideWindow && stalled);
return { daysRemaining, stalled, inGracePeriod, paymentLikelyFailed };
}
async function fetchRdap(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 { expirationIso: event.eventDate, statuses: data.status || [] };
}
async function findCaaIodefRecord(zoneId, headers, name) {
const url = `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records?type=CAA&name=${encodeURIComponent(name)}`;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`Cloudflare API returned ${res.status}`);
const data = await res.json();
return (data.result || []).find((r) => r.data && r.data.tag === "iodef") || null;
}
async function reconcileIodefRecord(zoneId, headers, name, contactUri, dryRun) {
const existing = await findCaaIodefRecord(zoneId, headers, name);
const body = { type: "CAA", name, data: { flags: 0, tag: "iodef", value: contactUri }, ttl: 3600 };
if (existing && existing.data.value === contactUri) {
console.log(`CAA iodef record already points at ${contactUri}, nothing to change.`);
return;
}
if (dryRun) {
console.log(`Dry run: would ${existing ? "update" : "create"} CAA iodef record on ${name} to ${contactUri}`);
return;
}
const url = existing
? `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${existing.id}`
: `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`;
const res = await fetch(url, { method: existing ? "PUT" : "POST", headers, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`Cloudflare API returned ${res.status}`);
console.log(`CAA iodef record on ${name} now points at ${contactUri}`);
}
export async function run() {
const domain = process.env.DNS_DOMAIN;
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const contactUri = process.env.ALERT_CONTACT_URI || "mailto:domains@example.com";
const previousExpirationIso = process.env.PREVIOUS_EXPIRATION_ISO || null;
const headers = { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" };
const { expirationIso, statuses } = await fetchRdap(domain);
const nowIso = new Date().toISOString();
const result = evaluateRenewal(expirationIso, previousExpirationIso, nowIso, statuses);
console.log(
`${domain} expires ${expirationIso}: ${result.daysRemaining} day(s) remaining, stalled=${result.stalled}, inGracePeriod=${result.inGracePeriod}`
);
if (!result.paymentLikelyFailed) {
console.log(`OK: ${domain} renewed on schedule, no sign of a failed charge.`);
return;
}
console.warn(
`Renewal for ${domain} looks stalled. Auto-renew is likely failing at the payment step. ` +
"Update the card or pay manually at the registrar, this cannot be fixed through the DNS provider API."
);
await reconcileIodefRecord(zoneId, headers, domain, contactUri, dryRun);
}
if (process.argv[1] && 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: whether the expiration date actually moved, whether the status flags a grace period, and whether those two facts together mean the payment likely failed. It takes plain values in and returns a plain object, so the tests use fixed timestamps and need no network access.
from auto_renewal_payment_failure import evaluate_renewal
def test_ok_when_date_moved_forward_and_far_out():
result = evaluate_renewal(
"2027-08-05T00:00:00Z", "2026-08-05T00:00:00Z", "2026-07-11T00:00:00Z", []
)
assert result["stalled"] is False
assert result["payment_likely_failed"] is False
def test_stalled_and_inside_window_flags_failure():
result = evaluate_renewal(
"2026-08-05T00:00:00Z", "2026-08-05T00:00:00Z", "2026-07-20T00:00:00Z", []
)
assert result["stalled"] is True
assert result["payment_likely_failed"] is True
def test_stalled_but_far_from_window_is_not_yet_a_failure():
result = evaluate_renewal(
"2027-08-05T00:00:00Z", "2027-08-05T00:00:00Z", "2026-07-11T00:00:00Z", []
)
assert result["stalled"] is True
assert result["payment_likely_failed"] is False
def test_grace_period_status_always_flags_failure():
result = evaluate_renewal(
"2027-08-05T00:00:00Z", None, "2026-07-11T00:00:00Z", ["autoRenewPeriod"]
)
assert result["in_grace_period"] is True
assert result["payment_likely_failed"] is True
def test_no_previous_expiration_defaults_to_not_stalled():
result = evaluate_renewal(
"2026-07-20T00:00:00Z", None, "2026-07-11T00:00:00Z", []
)
assert result["stalled"] is False
assert result["payment_likely_failed"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { evaluateRenewal } from "./auto-renewal-payment-failure.js";
test("ok when date moved forward and far out", () => {
const result = evaluateRenewal("2027-08-05T00:00:00Z", "2026-08-05T00:00:00Z", "2026-07-11T00:00:00Z", []);
assert.equal(result.stalled, false);
assert.equal(result.paymentLikelyFailed, false);
});
test("stalled and inside window flags failure", () => {
const result = evaluateRenewal("2026-08-05T00:00:00Z", "2026-08-05T00:00:00Z", "2026-07-20T00:00:00Z", []);
assert.equal(result.stalled, true);
assert.equal(result.paymentLikelyFailed, true);
});
test("stalled but far from window is not yet a failure", () => {
const result = evaluateRenewal("2027-08-05T00:00:00Z", "2027-08-05T00:00:00Z", "2026-07-11T00:00:00Z", []);
assert.equal(result.stalled, true);
assert.equal(result.paymentLikelyFailed, false);
});
test("grace period status always flags failure", () => {
const result = evaluateRenewal("2027-08-05T00:00:00Z", null, "2026-07-11T00:00:00Z", ["autoRenewPeriod"]);
assert.equal(result.inGracePeriod, true);
assert.equal(result.paymentLikelyFailed, true);
});
test("no previous expiration defaults to not stalled", () => {
const result = evaluateRenewal("2026-07-20T00:00:00Z", null, "2026-07-11T00:00:00Z", []);
assert.equal(result.stalled, false);
assert.equal(result.paymentLikelyFailed, false);
});
Case studies
The bank swapped the card number and nobody updated the registrar
A small agency's main domain had auto-renew on for four years without a problem. Then the bank reissued a new card number after an unrelated fraud alert on a different purchase. The registrar's renewal attempt failed twice over the next week, and the account's only notice was an email that landed in a folder nobody checked.
A scheduled RDAP check caught the stalled expiration date seventeen days out. The team updated the card in the registrar's billing page, retried the charge manually, and the expiration date jumped forward the same afternoon.
The renewal the bank mistook for fraud
A side project's yearly renewal charge got auto-declined by the cardholder's bank, which flagged the once-a-year charge from an unfamiliar merchant as suspicious. Auto-renew genuinely tried, twice, and both attempts were blocked before they ever reached the registrar's side of the transaction.
The owner only found out because a scheduled check flagged the domain as stalled nine days before expiry. A quick call to the bank to allow the charge, followed by a manual retry at the registrar, fixed it well ahead of the deadline.
Once a scheduled RDAP check is comparing the real expiration date against what it saw last time, a silently failed renewal charge gets caught days or weeks ahead of the deadline instead of the moment the site goes dark. Keep auto-renew on with a valid, current payment method as the primary defense, and treat the scheduled check as the backup that catches it the moment the charge quietly fails.
FAQ
Why is my domain still about to expire when auto-renew is turned on?
Auto-renew only works if the charge to the payment method on file actually succeeds. If the card expired, was declined, or the bank blocked the charge as suspicious, the renewal attempt fails silently and the domain keeps counting down toward its real expiration date at the registry, even though the auto-renew toggle still shows as on.
Can I fix a failed auto-renewal charge by changing a DNS record?
No. The failed charge itself is a billing event at the registrar, not a DNS zone record, so there is no A, MX, or TXT record that fixes it. You have to update the payment method or pay the renewal manually in the registrar account. What a DNS zone script can do is watch the expiration date and status over RDAP and make sure a CAA iodef alert record is in place so failures get noticed automatically.
How do I know the payment actually failed instead of just being delayed?
Check the registrar account for a billing or renewal notice, since most registrars log a failed charge attempt there even when the reminder email is missed. Cross check with RDAP: if the expiration date has not moved forward and the domain is inside its warning window with auto-renew supposedly on, treat that as a payment failure until the registrar dashboard says otherwise.
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:
- RFC 8659: DNS Certification Authority Authorization (CAA) Resource Record, the iodef tag. rfc-editor.org/rfc/rfc8659.html
- RDAP.ORG, the bootstrap lookup service. about.rdap.org
- Cloudflare API: DNS records, CAA record model. developers.cloudflare.com/api/resources/dns/subresources/records/models/caa_record
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 catch a stalled renewal for you?
If this helped you catch a failed charge before the domain 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