Every domain has a record at its registry that says when it expires, which locks are on it, which registrar controls it and which nameservers it hands visitors to. For years you read that record with whois, and every registry formatted the answer a little differently. That job now belongs to RDAP, the Registration Data Access Protocol. Since January 28, 2025, ICANN treats RDAP as the definitive source of registration data for generic TLDs, and most gTLD registries and registrars no longer have to run WHOIS at all (.com, .name and .post are the exceptions for now).
The nice part, if you look after more than a couple of domains: RDAP is plain HTTPS that returns JSON. You can query it with curl, filter it with jq, and turn it into an expiry check that runs from cron. Below is how to run an RDAP lookup from the terminal, read what comes back, make sense of the status values, and set up a small script that warns you before a renewal slips.
One RDAP lookup, nothing new to install
An RDAP server answers at a predictable path: its base URL, then domain/, then the name. For .com that server belongs to Verisign:
curl -s https://rdap.verisign.com/com/v1/domain/example.com | jq .
If you don't know which server handles a TLD, the public redirector at rdap.org looks it up and sends you there with a 302, so curl -sL https://rdap.org/domain/example.com works for any TLD it knows. ICANN offers the same lookup in a browser at lookup.icann.org.
The full reply is long and mostly notices and links. The parts you care about fit in one jq filter:
curl -s https://rdap.verisign.com/com/v1/domain/example.com | jq '{
name: .ldhName,
status,
events: ([.events[] | {(.eventAction): .eventDate}] | add),
nameservers: [.nameservers[].ldhName],
dnssec: .secureDNS.delegationSigned
}'
Where the server address comes from
Hard-coding Verisign works for .com and .net. The proper way to find a server is the IANA bootstrap file at https://data.iana.org/rdap/dns.json, which maps each TLD to its RDAP base URL. It's the same file rdap.org and ICANN's lookup read:
curl -s https://data.iana.org/rdap/dns.json \
| jq -r '.services[] | select(.[0] | index("com")) | .[1][0]'
That prints https://rdap.verisign.com/com/v1/. Every gTLD is in the file, plus a growing number of country-code TLDs, but not all of them. When we checked this week, .uk was listed while .de, .io and .co were not. Country-code registries aren't bound by ICANN's contracts, so for those you still use the registry's own WHOIS or web lookup.
Reading the reply
Take the fields in the order you'd check them when something has gone wrong.
events is a list of dated actions. registration is when the name was first created, expiration is the date the registry has on file, and last changed is the most recent update to the domain itself, such as new nameservers, a status change or a renewal. Every date is in UTC. last update of RDAP database only tells you how fresh the answer is.
status uses the same values registrars and registries exchange over EPP, written out in plain words. client transfer prohibited is the transfer lock your registrar sets, the one you toggle in its panel. The server ... versions come from the registry, and only the registry can lift them.
nameservers is the delegation, the servers the TLD sends resolvers to. If this list doesn't match your DNS provider, nothing you edit in that provider's zone will ever be seen. It's the same mismatch that dig +trace exposes, viewed from the registry's side.
secureDNS says whether a DS record is published. delegationSigned: true means resolvers will validate DNSSEC for the zone. If you're moving DNS to a new provider and this says true, sort out the DS record before you switch nameservers, or the domain fails validation the moment the change lands.
entities holds the registrar's name and IANA ID, plus an abuse contact. Registrant details are usually missing or marked as redacted. That's privacy policy at work, not a broken lookup.
Registry answer or registrar answer
For .com and .net the registry holds only part of the record. The rest sits with the registrar, and the registry reply points there with a link of type related:
curl -s https://rdap.verisign.com/com/v1/domain/example.com \
| jq -r '.links[] | select(.rel == "related") | .href'
example.com is a reserved name held by IANA, so it has no such link, but a normally registered .com returns a URL on its registrar's own RDAP server. Query that one as well when you want the registrar's view.
It matters most around expiry. When a .com reaches its expiration date, the registry renews it automatically for a year, and the registrar then has a grace window of up to 45 days to keep it or delete it. So the registry's date can jump a year ahead even though nobody has paid. If the status list includes auto renew period, don't trust that date. Log in to your registrar account and check that the renewal really went through.
Status values worth a second look
Most domains show some mix of the three client ... prohibited values. That's the normal registrar lock, and it's what you want on a name you aren't moving. These are the ones that mean something is happening:
-
client holdorserver hold: the domain is pulled from the zone and doesn't resolve at all. Registrars set it for things like an unpaid renewal or a registrant email that was never verified. -
pending transfer: a move to another registrar is in progress. If nobody on your side asked for one, call your registrar today. -
redemption period: the domain was deleted and can still be restored, usually for a much higher fee than a renewal. -
pending delete: redemption is over. The name gets released within days and can't be renewed. -
server ... prohibitedon its own: a registry-level lock. It's either a paid registry lock or a name the registry is protecting, which is why example.org carries all three. -
active: no statuses at all. Fine for a moment during a transfer, but on a name you're keeping it means only the auth code stands between the domain and a transfer request.
If you're about to move a domain, unlocking a domain before a transfer covers which of these you can clear yourself.
A small expiry check for cron
Registrars send renewal reminders, but they go to whatever email was on the account when it was opened. A script that reads the registry directly doesn't care about a dead mailbox. This one takes a file with one domain per line, finds each TLD's server in the bootstrap file, and prints the days left. It exits with 1 when anything needs a look, so it slots into cron or a monitoring check.
#!/usr/bin/env bash
# Usage: domain-expiry.sh domains.txt [warn_days]
# Needs curl, jq and GNU date.
list=$1; warn=${2:-30}; rc=0
boot=$(curl -fsS https://data.iana.org/rdap/dns.json) || exit 2
while read -r d; do
[ -z "$d" ] && continue
tld=${d##*.}
base=$(jq -r --arg t "$tld" \
'first(.services[] | select(.[0] | index($t)) | .[1][0]) // empty' <<<"$boot")
if [ -z "$base" ]; then
printf '%-22s no RDAP server for .%s, check by hand\n' "$d" "$tld"; continue
fi
json=$(curl -s -w '\n%{http_code}' "${base%/}/domain/$d")
code=${json##*$'\n'}; json=${json%$'\n'*}
if [ "$code" != 200 ]; then
printf '%-22s lookup failed: HTTP %s\n' "$d" "$code"; rc=1; continue
fi
exp=$(jq -r 'first(.events[] | select(.eventAction=="expiration") | .eventDate) // empty' <<<"$json")
flags=$(jq -r '[.status[] | select(test("hold|redemption|pending delete|auto renew period"))] | join(", ")' <<<"$json")
if [ -z "$exp" ]; then
printf '%-22s no expiration date in the RDAP reply\n' "$d"; rc=1; continue
fi
days=$(( ($(date -d "$exp" +%s) - $(date +%s)) / 86400 ))
note=ok
[ "$days" -lt "$warn" ] && { note="RENEW SOON"; rc=1; }
[ -n "$flags" ] && { note="CHECK: $flags"; rc=1; }
printf '%-22s expires %s %4d days %s\n' "$d" "${exp%%T*}" "$days" "$note"
sleep 1
done < "$list"
exit $rc
A few notes on what it does and why:
- It sleeps a second between lookups. Registries rate-limit RDAP, and a burst of queries can earn you HTTP 429 replies instead of data.
- A 404 means the registry has no such name. In a list of domains you own, that's either a typo or a domain that's already gone. If it's gone, run a domain search right away to see whether you can register it again.
- TLDs missing from the bootstrap file get reported, not skipped silently, so a .de name doesn't fall off your radar.
-
It uses GNU
date -d. On macOS, install coreutils and swap ingdate.
To run it every Monday and only hear about problems, wrap it in chronic from the moreutils package, which stays quiet unless the command exits with an error:
MAILTO=ops@example.com
0 8 * * 1 chronic /usr/local/bin/domain-expiry.sh /etc/domains.txt 45
A 45-day threshold leaves room to fix a declined card or a dead login before anything breaks. Want to see the warning fire once? Pass a big number, such as 300, like the second run in the screenshot.
What RDAP can't tell you
RDAP shows what the registry knows. It can't tell you whether the card on file still works, whether auto-renew is switched on, or who at your company holds the registrar login. Those live in the registrar account, and they're covered in renewing a domain name before it expires. Keep the script running next to that checklist, not instead of it.
If the audit turns up names scattered across old registrar accounts, you can bring them under one login with a domain transfer to Pro Gineous, and every lookup above works the same afterwards.
Related reading
- Unlocking a Domain Before a Transfer
- Renewing a Domain Name Before It Expires
- What to Check After a Domain Transfer
- How to Read dig Output When DNS Looks Wrong
Originally published on the Pro Gineous blog.



Top comments (1)
The
auto renew periodwarning is the part most expiry scripts miss. The registry date jumping a year ahead with nobody paying has caught out a lot of people. On the ccTLD gap you mention (.de, .io, .co missing from the bootstrap file), your script could fall back to the registry's port-43 server per TLD, but then you're writing a parser for each one.Full disclosure, I'm on the WhoisFreaks.com team: our WHOIS API queries both RDAP and port 43 and returns one JSON shape. A spot check on .io returned create and expiry dates. Coverage really is registry by registry, though. .at only gave a create date, because nic.at doesn't publish expiry. Did you hit any other ccTLDs where the registry's own lookup was the only option?