DEV Community

Cover image for Using RDAP to Check a Domain's Expiry Date and Lock Status
Pro Gineous
Pro Gineous

Posted on Originally published at blog.progineous.com

Using RDAP to Check a Domain's Expiry Date and Lock Status

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 .
Enter fullscreen mode Exit fullscreen mode

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
}'
Enter fullscreen mode Exit fullscreen mode

Terminal screenshot of an RDAP lookup: the IANA bootstrap file returning Verisign's RDAP base URL for .com, then a curl and jq query for example.com showing client delete, transfer and update prohibited statuses, a 1995 registration date, a 2027-08-13 expiration date, two Cloudflare nameservers and DNSSEC enabled, followed by example.org returning the three server prohibited statuses

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]'
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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

Flat diagram grouping RDAP domain status values into three columns: the usual registrar lock (client transfer, update and delete prohibited, plus active), the registry lock that only the registry can lift (server transfer, update and delete prohibited), and statuses that need action now (client or server hold, pending transfer, auto renew period, redemption period and pending delete)

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 hold or server 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 ... prohibited on 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
Enter fullscreen mode Exit fullscreen mode

Terminal screenshot of the domain-expiry.sh script checking five domains: example.com, example.net and example.org show their expiry dates and days left as ok, example.de has no RDAP server in the IANA bootstrap file, a misspelled domain returns HTTP 404, the script exits with status 1, and a second run with a 300-day threshold flags example.com as RENEW SOON

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 in gdate.

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
Enter fullscreen mode Exit fullscreen mode

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

Originally published on the Pro Gineous blog.

Top comments (1)

Collapse
 
furqan_ashraf profile image
Furqan Ashraf •

The auto renew period warning 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?