A domain stops resolving and the first guess is DNS. Often it is not DNS: the registration expired, the registry put a hold on it, and the name servers are still listed and still answering nothing. The date you need is not in your monitoring and it is not in the zone.
WHOIS used to answer this. It runs on port 43 and returns free text that every registry formats differently, and most contact fields now come back redacted. It is also on the way out: ICANN states that “All gTLD registries and registrars are required to provide RDAP services using the gTLD RDAP Profile”, and since 28 January 2025 WHOIS is no longer required at all for most gTLDs, with .com, .name and .post as exceptions. RDAP returns the same registration data as JSON, which is what you want in a script.
This page is about three questions you actually ask when a domain is in trouble: when did it expire, is it on hold, and how long have you got. One short script answers all three from an RDAP response, and there is one trap in the middle of it that makes the obvious check silently return nothing.
Applies to: PowerShell 7 and Windows PowerShell 5.1, on any Windows version. No module is needed.
Quick answer
One line. Point it at the domain and read the three members that matter: status, events and nameservers.
# rdap.org redirects to whichever registry runs RDAP for that TLD, so one
# address works for most domains. The result is an object, not text.
$d = Invoke-RestMethod -Uri 'https://rdap.org/domain/microsoft.com'
$d.status
$d.events | Select-Object eventAction, eventDate
$d.nameservers.ldhName
You should see a handful of status strings, a short list of dated events with one of them marked expiration, and the name servers. If you only need to look one domain up by hand rather than from a script, the WHOIS lookup tool on this site does the same job in the browser.
The expiry date is not the date it stops working
Three different moments get called "the expiry", and a renewal reminder set to the wrong one is how a domain goes dark. ICANN documents the states a domain passes through on its EPP status codes page.
| What happens | What the registry calls it | What it means for you |
|---|---|---|
| The registration period ends | autoRenewPeriod | “This grace period is provided after a domain name registration period expires and is extended (renewed) automatically by the registry.” The domain usually still resolves. |
| The registrar stops it | clientHold | “This status code tells your domain’s registry to not activate your domain in the DNS and as a consequence, it will not resolve.” This is the day it goes dark. |
| The registrar asks for deletion | redemptionPeriod | “Your domain will be held in this status for 30 days.” You can still get it back, through your registrar, usually for a fee. |
| Redemption runs out | pendingDelete | “Your domain will remain in this status for several days, after which time your domain will be purged and dropped from the registry database.” After that anyone can register it. |
pendingDelete rather than a number, and the length is a registry policy rather than a single global rule. Treat the 30 days of redemptionPeriod as the figure you can plan against and everything after it as borrowed time.
The same status, spelled two different ways
Every page you will read, including ICANN’s, spells these codes in camelCase: clientHold, redemptionPeriod, pendingDelete. RDAP does not. RFC 8056, which exists purely to map the two vocabularies onto each other, converts each EPP status to “lowercase with a space character inserted between word boundaries”.
| In WHOIS output and on every ICANN page | In an RDAP response |
|---|---|
clientHold | client hold |
serverHold | server hold |
autoRenewPeriod | auto renew period |
redemptionPeriod | redemption period |
pendingDelete | pending delete |
clientTransferProhibited | client transfer prohibited |
$d.status -contains 'clientHold' against an RDAP response returns False on a domain that is on hold right now. Nothing errors, nothing is null, and the check reports healthy forever. The script below prints both spellings side by side so you can see it happen.
Before you start
Three steps. The lab is two scripts in one folder and it queries nothing: the sample response is written locally, so it runs on a machine with no internet access and gives the same answer every time.
- 1. Create the folder. Everything lives in
C:\domaincheck. - 2. Allow scripts in this window only. The execution policy line below is scoped to
Process, so it lapses when you close the window. - 3. Note the shell. Both scripts run on PowerShell 7 and on Windows PowerShell 5.1. Paths are written with single backslashes inside single-quoted strings, which PowerShell does not treat as escapes.
New-Item -Path 'C:\domaincheck' -ItemType Directory -Force | Out-Null
Set-Location -Path 'C:\domaincheck'
Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force
Two lines of silence and a new folder. Nothing else changes on the machine.
Write the sample response
This writes the RDAP response the reporting script reads. The member names and the structure are the ones RFC 9083 defines; the status strings and the event actions are values from the IANA RDAP JSON Values registry. The domain and the dates are invented, and the domain it describes expired eight days ago and has been put on hold.
Save this as New-RdapSample.ps1 in C:\domaincheck and run it with .\New-RdapSample.ps1.
# New-RdapSample.ps1
# Writes a sample RDAP response for the reporting script to read, so the numbers
# on this page are the numbers on your screen. The structure and the member names
# come from RFC 9083; the status and eventAction values come from the IANA RDAP
# JSON Values registry. The domain and the dates are invented.
$sample = [ordered]@{
objectClassName = 'domain'
ldhName = 'contoso-example.com'
status = @('client transfer prohibited', 'auto renew period', 'client hold')
events = @(
[ordered]@{ eventAction = 'registration'; eventDate = '2019-09-28T09:12:00Z' }
[ordered]@{ eventAction = 'expiration'; eventDate = '2026-09-28T09:12:00Z' }
[ordered]@{ eventAction = 'last changed'; eventDate = '2026-09-30T02:41:17Z' }
)
nameservers = @(
[ordered]@{ objectClassName = 'nameserver'; ldhName = 'ns1.example-dns.net' }
[ordered]@{ objectClassName = 'nameserver'; ldhName = 'ns2.example-dns.net' }
)
entities = @(
[ordered]@{ objectClassName = 'entity'; handle = '292'; roles = @('registrar') }
)
}
$path = Join-Path $PSScriptRoot 'rdap-sample.json'
$sample | ConvertTo-Json -Depth 6 | Set-Content -Path $path -Encoding utf8
'{0,-28}{1}' -f 'File written', (Split-Path $path -Leaf)
'{0,-28}{1}' -f 'Domain in the sample', $sample.ldhName
'{0,-28}{1}' -f 'Status values', $sample.status.Length
'{0,-28}{1}' -f 'Events', $sample.events.Length
'{0,-28}{1}' -f 'Nameservers', $sample.nameservers.Length
Five lines, then a file next to the script. The counts are worth a glance: three statuses, three events, two name servers.
File written rdap-sample.json
Domain in the sample contoso-example.com
Status values 3
Events 3
Nameservers 2
The file itself is what a registry would send you, trimmed to the members this page uses.
{
"objectClassName": "domain",
"ldhName": "contoso-example.com",
"status": [
"client transfer prohibited",
"auto renew period",
"client hold"
],
"events": [
{
"eventAction": "registration",
"eventDate": "2019-09-28T09:12:00Z"
},
{
"eventAction": "expiration",
"eventDate": "2026-09-28T09:12:00Z"
},
{
"eventAction": "last changed",
"eventDate": "2026-09-30T02:41:17Z"
}
],
"nameservers": [
{
"objectClassName": "nameserver",
"ldhName": "ns1.example-dns.net"
},
{
"objectClassName": "nameserver",
"ldhName": "ns2.example-dns.net"
}
],
"entities": [
{
"objectClassName": "entity",
"handle": "292",
"roles": [
"registrar"
]
}
]
}
Read the expiry, the holds and what they mean
Run New-RdapSample.ps1 first. This script reads the file it writes, converts each RDAP status back to the spelling you will recognise, and finishes with the comparison that catches the trap.
Save this as Get-DomainState.ps1 in C:\domaincheck and run it with .\Get-DomainState.ps1.
# Get-DomainState.ps1
# Reads an RDAP response and answers the three questions that matter most when a
# domain is in trouble: when did it expire, is it still resolving, and which
# status codes are on it. The reference date is a parameter with a fixed default
# so the numbers do not move between runs.
# Run New-RdapSample.ps1 first, or pass -Path to a response you saved yourself.
param(
[string]$Path = (Join-Path $PSScriptRoot 'rdap-sample.json'),
[datetime]$AsOf = '2026-10-06T00:00:00'
)
$d = Get-Content -Path $Path -Raw | ConvertFrom-Json
$asOfUtc = [datetime]::SpecifyKind($AsOf, 'Utc')
# RDAP spells a status in lower case with spaces; EPP, WHOIS output and every
# ICANN page spell the same status in camelCase. RFC 8056 defines the conversion
# as lowercase with a space at each word boundary, so it reverses cleanly.
function ConvertTo-EppStatus {
param([string]$RdapStatus)
$words = $RdapStatus -split ' '
$out = $words[0]
for ($i = 1; $i -lt $words.Length; $i++) {
$out += $words[$i].Substring(0, 1).ToUpper() + $words[$i].Substring(1)
}
$out
}
$meaning = @{
'clientHold' = 'registry told not to activate the domain in DNS, so it does not resolve'
'serverHold' = 'registry operator has not activated the domain in DNS'
'autoRenewPeriod' = 'grace period after expiry while the registry has auto renewed it'
'redemptionPeriod' = 'registrar asked the registry to delete it, held for 30 days'
'pendingDelete' = 'redemption has run out, the domain will be purged'
'clientTransferProhibited' = 'registry will reject a transfer to another registrar'
}
# ConvertFrom-Json turns an RDAP eventDate into a DateTime whose Kind is already
# Utc, so nothing here has to parse a string or apply a time zone.
$expiryUtc = ($d.events | Where-Object eventAction -eq 'expiration').eventDate
$registered = ($d.events | Where-Object eventAction -eq 'registration').eventDate
$fmt = 'yyyy-MM-dd HH:mm:ss'
'{0,-26}{1}' -f 'Domain', $d.ldhName
'{0,-26}{1}' -f 'Registered (UTC)', $registered.ToString($fmt)
'{0,-26}{1}' -f 'Expiry (UTC)', $expiryUtc.ToString($fmt)
'{0,-26}{1}' -f 'Kind of that value', $expiryUtc.Kind
'{0,-26}{1}' -f 'Reference date', $asOfUtc.ToString('yyyy-MM-dd')
'{0,-26}{1}' -f 'Days past expiry', [int]($asOfUtc - $expiryUtc).TotalDays
'{0,-26}{1}' -f 'Nameservers listed', @($d.nameservers).Length
''
'{0,-28}{1,-28}{2}' -f 'RDAP status', 'Same status in EPP', 'What it means'
'{0,-28}{1,-28}{2}' -f '-----------', '------------------', '-------------'
foreach ($s in $d.status) {
$epp = ConvertTo-EppStatus $s
$what = if ($meaning.ContainsKey($epp)) { $meaning[$epp] } else { 'not in this table' }
'{0,-28}{1,-28}{2}' -f $s, $epp, $what
}
''
# The check most monitoring scripts reach for first, and the one that works.
$holdEpp = $d.status -contains 'clientHold'
$holdRdap = $d.status -contains 'client hold'
'{0,-44}{1}' -f 'Looking for clientHold finds it', $holdEpp
'{0,-44}{1}' -f 'Looking for client hold finds it', $holdRdap
'{0,-44}{1}' -f 'On hold, so the domain does not resolve', ($holdRdap -or ($d.status -contains 'server hold'))
Seven summary lines, then one row per status with its EPP name and a plain-English meaning, then the two checks. Read the last three lines together: the camelCase check says there is no hold, the RDAP spelling says there is, and the domain is the same domain in both cases.
Domain contoso-example.com
Registered (UTC) 2019-09-28 09:12:00
Expiry (UTC) 2026-09-28 09:12:00
Kind of that value Utc
Reference date 2026-10-06
Days past expiry 8
Nameservers listed 2
RDAP status Same status in EPP What it means
----------- ------------------ -------------
client transfer prohibited clientTransferProhibited registry will reject a transfer to another registrar
auto renew period autoRenewPeriod grace period after expiry while the registry has auto renewed it
client hold clientHold registry told not to activate the domain in DNS, so it does not resolve
Looking for clientHold finds it False
Looking for client hold finds it True
On hold, so the domain does not resolve True
Invoke-RestMethod to a file with ConvertTo-Json, or replace the Get-Content line with the call itself. Everything after that line works on the live object unchanged.
eventDate arrives as a DateTime whose Kind is already Utc, because ConvertFrom-Json recognises the trailing Z. That is one of the few places in PowerShell where a timestamp needs no conversion at all; the guide to Windows timestamp values covers the places where it does.
Pointing it at a real domain
Two things change when you move from the sample to a live query. The first is where you send it: rdap.org is a redirector, and the authoritative answer to which server serves a given TLD is the bootstrap file IANA publishes for the registry RFC 9224 defines. The second is that not every TLD answers. ICANN requires RDAP of gTLD registries and registrars; a country-code registry is outside those contracts and may offer RDAP, WHOIS only, or a web form and nothing else.
# The bootstrap file maps every TLD to the RDAP service that serves it.
$boot = Invoke-RestMethod -Uri 'https://data.iana.org/rdap/dns.json'
$boot.services | Where-Object { $_[0] -contains 'com' } | ForEach-Object { $_[1] }
# And the query itself, saved so the reporting script can read it.
Invoke-RestMethod -Uri 'https://rdap.org/domain/microsoft.com' |
ConvertTo-Json -Depth 8 | Set-Content -Path 'C:\domaincheck\rdap-sample.json'
The first command prints the RDAP base address for .com. The second overwrites the sample with a real response, after which .\Get-DomainState.ps1 reports on that domain instead. Both commands need internet access and their output depends on the domain, which is why neither is shown here.
try and treat a failure as unknown rather than as healthy: a check that could not reach the registry has not told you anything about the domain.
Where this matters
- A site goes down and DNS looks fine. The name servers answer, the zone is correct, and the domain is on
clientHold. Checking the status takes a second and is not in anyone’s runbook. - Inherited estates. Domains registered by people who left, on cards that expired, renewing silently until one does not.
- Monitoring that reports healthy. A check written against the WHOIS spelling never matches an RDAP response, so it passes every day including the day the domain is deleted.
- The thirty days you did not know you had. A domain in
redemptionPeriodis recoverable through the registrar. One that has reachedpendingDeleteis close to being anyone’s.
Tips and limitations
- ICANN requires RDAP of gTLD registries and registrars, so a gTLD answers. A country-code registry is not bound by those contracts, so a ccTLD may not.
- The expiry in a registry response is the registry’s date. A registrar can hold a different one in its own system, which is why the IANA registry also defines a separate
registrar expirationevent action. - Contact details are mostly redacted and that is deliberate. The dates, the status codes and the name servers are not.
- The
statusmember is optional, so a response can arrive without one. Wrap it as@($d.status)before indexing, so one value and no value both behave. - Classic WHOIS on port 43 still works and still returns free text that every registry formats differently. If you have a parser for it that works, keep it; do not write a new one.
Official documentation
- EPP status codes, ICANN: what each status means, in plain language, including the thirty days of redemption
- RFC 9083, JSON Responses for RDAP: the response structure, the events array and the status member
- RFC 8056, EPP and RDAP status mapping: the conversion between the two spellings, and the reason they differ
- RFC 9224, finding the authoritative RDAP service: the bootstrap file and how a client picks a server
- RDAP JSON Values registry, IANA: every registered status value and event action, which is the list to validate against
- Bootstrap Service Registry for Domain Name Space, IANA: the registry the query address comes from, and the JSON file the command above downloads
- Registration Data Access Protocol, ICANN: the requirement on gTLD registries and registrars, and the status of WHOIS alongside it
Related tools
- WHOIS lookup: the same registration data for one domain, in the browser, when you do not want a shell.
- DNS lookup tool: checks whether the domain actually resolves, which is the other half of the answer when a status says it should not.
Related guides
- Troubleshoot DNS from Windows with nslookup: for the incidents that really are DNS.
- Convert objects to and from JSON in PowerShell: the depth limit that quietly truncates a nested response like this one.
- Read Windows and Unix timestamp values in PowerShell: what to do when a date does not arrive as politely as an RDAP event date.
Five cheat sheets, one PDF
Subnet masks, PowerShell, Linux commands, HTTP status codes and the ESXi command line - one page each, free to keep. Leave an address and it arrives in a minute.