Check a domain expiry date and status from PowerShell with RDAP

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.

Note: The output of that command is different for every domain and changes over time, so it is not reproduced here. Everything below runs against a sample response saved to disk, so the numbers on this page are the numbers on your screen.

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 happensWhat the registry calls itWhat it means for you
The registration period endsautoRenewPeriod“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 itclientHold“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 deletionredemptionPeriod“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 outpendingDelete“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.
Note: ICANN says “several days” for 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 pageIn an RDAP response
clientHoldclient hold
serverHoldserver hold
autoRenewPeriodauto renew period
redemptionPeriodredemption period
pendingDeletepending delete
clientTransferProhibitedclient transfer prohibited
The trap: A monitoring script that asks $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
Result: The reference date is a parameter, so pointing this at a real response is one change: save the output of 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.
Note: An RDAP 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.

Note: A country-code TLD may have no RDAP service at all, in which case the call fails instead of returning a document. Wrap it in 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 redemptionPeriod is recoverable through the registrar. One that has reached pendingDelete is 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 expiration event action.
  • Contact details are mostly redacted and that is deliberate. The dates, the status codes and the name servers are not.
  • The status member 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


  • 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.

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.