Check Domain Expiration with Code
February 27, 2026
Domain portfolio managers track hundreds of domains across clients. Security teams monitor phishing domains waiting for them to expire. Registrar tools need expiration data to trigger renewal workflows.
Since ICANN deprecated WHOIS for gTLDs in January 2025, the standard way to get this data is RDAP — structured JSON over HTTPS.
Quick answer: raw RDAP
No API key, no libraries — just curl:
curl -s https://rdap.verisign.com/com/v1/domain/google.com | jq '.events'
[
{
"eventAction": "registration",
"eventDate": "1997-09-15T04:00:00Z"
},
{
"eventAction": "expiration",
"eventDate": "2028-09-14T04:00:00Z"
},
{
"eventAction": "last changed",
"eventDate": "2019-09-09T15:39:04Z"
},
{
"eventAction": "last update of RDAP database",
"eventDate": "2024-05-25T18:00:00Z"
}
]
The expiration date isn't a top-level field — it's buried in the events array. You need to find the object where eventAction is "expiration" and read eventDate. The array order isn't guaranteed, and some registries don't include an expiration event at all.
This works for .com because you already know the RDAP server (rdap.verisign.com). For other TLDs, you also need:
- Bootstrap discovery — find which RDAP server handles each TLD by querying IANA's registry
- Rate limit handling — each server has different limits, some ban for 24 hours on a single 429
- Inconsistent responses — some ccTLDs omit expiration dates entirely, and the fields present vary by registry
For one-off .com lookups, this is fine. For production monitoring across many TLDs, the maintenance cost adds up fast. See our migration guide for the full picture.
Using the RDAP API
The RDAP API handles bootstrap discovery, rate limits, and response normalization. One endpoint, every TLD, structured dates:
curl -s -H "Authorization: Bearer YOUR_API_KEY" \
"https://rdapapi.io/api/v1/domain/google.com" | jq '.dates'
{
"registered": "1997-09-15T04:00:00Z",
"expires": "2028-09-14T04:00:00Z",
"updated": "2019-09-09T15:39:04Z"
}
Three ISO 8601 fields instead of an unordered event array. Any field can be null when the registry doesn't provide it — for example, .ch and .li domains omit the expiration date.
Date helpers in the SDKs
All five SDKs include convenience methods that return a native date object and days-until-expiration as an integer. No ISO parsing needed:
import os
from rdapapi import RdapApi # pip install rdapapi
api = RdapApi(os.environ["RDAP_API_KEY"])
domain = api.domain("google.com")
domain.dates.expires # "2028-09-14T04:00:00Z" (raw string)
domain.dates.expires_at # datetime(2028, 9, 14, ...) (parsed)
domain.dates.expires_in_days # days until expiration (int)
domain.dates.registered_at # datetime(1997, 9, 15, ...)
import { RdapClient, expiresInDays, expiresAt } from "rdapapi"; // npm install rdapapi
const api = new RdapClient(process.env.RDAP_API_KEY);
const domain = await api.domain("google.com");
domain.dates.expires; // "2028-09-14T04:00:00Z" (raw string)
expiresAt(domain.dates); // Date(2028-09-14T04:00:00Z) (parsed)
expiresInDays(domain.dates); // days until expiration (number)
// composer require rdapapi/rdapapi-php
$api = new \RdapApi\RdapApi(getenv('RDAP_API_KEY'));
$domain = $api->domain('google.com');
$domain->dates->expires; // "2028-09-14T04:00:00Z" (raw string)
$domain->dates->expiresAt(); // DateTimeImmutable (parsed)
$domain->dates->expiresInDays(); // days until expiration (int)
// go get github.com/rdapapi/rdapapi-go
client := rdapapi.NewClient(os.Getenv("RDAP_API_KEY"))
domain, err := client.Domain("google.com", nil)
// handle err
*domain.Dates.Expires // "2028-09-14T04:00:00Z" (raw string)
domain.Dates.ExpiresAt() // time.Time, true (parsed)
domain.Dates.ExpiresInDays() // days until expiration, true
// io.rdapapi:rdapapi-java (Maven Central)
RdapClient client = new RdapClient(System.getenv("RDAP_API_KEY"));
DomainResponse domain = client.domain("google.com");
domain.getDates().getExpires(); // "2028-09-14T04:00:00Z" (raw String)
domain.getDates().getExpiresAt(); // Instant (parsed)
domain.getDates().getExpiresInDays(); // days until expiration (Long)
The raw string fields (expires, registered, updated) map directly to the API response. The convenience methods return null (or None/false in Go) when the field is absent, so they're safe to call without checking first. Already-expired domains return a negative number, useful for monitoring lapsed phishing domains.
Get an API key at rdapapi.io/register (free trial, no credit card).
Building a domain expiration monitor
Check a list of domains and flag anything expiring within 60 days:
import os
from rdapapi import RdapApi, NotFoundError
api = RdapApi(os.environ["RDAP_API_KEY"])
# read from a file, database, or config
domains = ["mycompany.com", "mycompany.io", "myproduct.dev"]
for name in domains:
try:
domain = api.domain(name)
days = domain.dates.expires_in_days
if days is None:
print(f" {name}: no expiration date available")
elif days < 30:
print(f"🔴 {name}: expires in {days} days — renew now!")
elif days < 60:
print(f"🟡 {name}: expires in {days} days")
else:
print(f"🟢 {name}: {days} days remaining")
except NotFoundError:
print(f" {name}: not found in RDAP")
import { RdapClient, NotFoundError, expiresInDays } from "rdapapi";
const api = new RdapClient(process.env.RDAP_API_KEY);
const domains = ["mycompany.com", "mycompany.io", "myproduct.dev"];
for (const name of domains) {
try {
const domain = await api.domain(name);
const days = expiresInDays(domain.dates);
if (days === null) {
console.log(` ${name}: no expiration date available`);
} else if (days < 30) {
console.log(`🔴 ${name}: expires in ${days} days — renew now!`);
} else if (days < 60) {
console.log(`🟡 ${name}: expires in ${days} days`);
} else {
console.log(`🟢 ${name}: ${days} days remaining`);
}
} catch (e) {
if (e instanceof NotFoundError) {
console.log(` ${name}: not found in RDAP`);
} else {
throw e;
}
}
}
$api = new \RdapApi\RdapApi(getenv('RDAP_API_KEY'));
$domains = ['mycompany.com', 'mycompany.io', 'myproduct.dev'];
foreach ($domains as $name) {
try {
$domain = $api->domain($name);
$days = $domain->dates->expiresInDays();
if ($days === null) {
echo " {$name}: no expiration date available\n";
} elseif ($days < 30) {
echo "🔴 {$name}: expires in {$days} days — renew now!\n";
} elseif ($days < 60) {
echo "🟡 {$name}: expires in {$days} days\n";
} else {
echo "🟢 {$name}: {$days} days remaining\n";
}
} catch (\RdapApi\Exceptions\NotFoundException $e) {
echo " {$name}: not found in RDAP\n";
}
}
client := rdapapi.NewClient(os.Getenv("RDAP_API_KEY"))
domains := []string{"mycompany.com", "mycompany.io", "myproduct.dev"}
for _, name := range domains {
domain, err := client.Domain(name, nil)
if err != nil {
var notFound *rdapapi.NotFoundError
if errors.As(err, ¬Found) {
fmt.Printf(" %s: not found in RDAP\n", name)
}
continue
}
days, ok := domain.Dates.ExpiresInDays()
if !ok {
fmt.Printf(" %s: no expiration date available\n", name)
} else if days < 30 {
fmt.Printf("🔴 %s: expires in %d days — renew now!\n", name, days)
} else if days < 60 {
fmt.Printf("🟡 %s: expires in %d days\n", name, days)
} else {
fmt.Printf("🟢 %s: %d days remaining\n", name, days)
}
}
RdapClient client = new RdapClient(System.getenv("RDAP_API_KEY"));
List<String> domains = List.of("mycompany.com", "mycompany.io", "myproduct.dev");
for (String name : domains) {
try {
DomainResponse domain = client.domain(name);
Long days = domain.getDates().getExpiresInDays();
if (days == null) {
System.out.println(" " + name + ": no expiration date available");
} else if (days < 30) {
System.out.printf("🔴 %s: expires in %d days — renew now!%n", name, days);
} else if (days < 60) {
System.out.printf("🟡 %s: expires in %d days%n", name, days);
} else {
System.out.printf("🟢 %s: %d days remaining%n", name, days);
}
} catch (NotFoundException e) {
System.out.println(" " + name + ": not found in RDAP");
}
}
Output:
🟢 mycompany.com: 847 days remaining
🔴 mycompany.io: expires in 12 days — renew now!
🟡 myproduct.dev: expires in 45 days
Run this daily with cron, a GitHub Actions schedule, or any task runner. Three domains = three API calls = negligible quota usage even on the Starter plan (30,000 lookups/month). Pipe the output to a Slack webhook or PagerDuty for alerts.
Checking multiple domains at once
The bulk endpoint reduces HTTP overhead by batching up to 10 domains per request. Failed domains return inline errors — one bad domain doesn't break the batch. Requires a Pro or Business plan:
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domains": ["google.com", "github.com", "example.net"]}' \
"https://rdapapi.io/api/v1/domains/bulk"
result = api.bulk_domains(["google.com", "github.com", "example.net"])
for r in result.results:
if r.status == "success":
days = r.data.dates.expires_in_days
print(f"{r.data.domain}: {days} days remaining")
else:
print(f"{r.domain}: {r.error}")
const result = await api.bulkDomains(["google.com", "github.com", "example.net"]);
for (const r of result.results) {
if (r.status === "success") {
const days = expiresInDays(r.data.dates);
console.log(`${r.data.domain}: ${days} days remaining`);
} else {
console.log(`${r.domain}: ${r.error}`);
}
}
$result = $api->bulkDomains(['google.com', 'github.com', 'example.net']);
foreach ($result->results as $r) {
if ($r->status === 'success') {
$days = $r->data->dates->expiresInDays();
echo "{$r->data->domain}: {$days} days remaining\n";
} else {
echo "{$r->domain}: {$r->error}\n";
}
}
result, err := client.BulkDomains(
[]string{"google.com", "github.com", "example.net"}, nil,
)
// handle err
for _, r := range result.Results {
if r.Status == "success" {
days, _ := r.Data.Dates.ExpiresInDays()
fmt.Printf("%s: %d days remaining\n", r.Data.Domain, days)
} else {
fmt.Printf("%s: %s\n", r.Domain, *r.Error)
}
}
BulkDomainsResponse result = client.bulkDomains(
List.of("google.com", "github.com", "example.net")
);
for (BulkDomainResult r : result.getResults()) {
if ("success".equals(r.getStatus())) {
Long days = r.getData().getDates().getExpiresInDays();
System.out.printf("%s: %d days remaining%n", r.getData().getDomain(), days);
} else {
System.out.printf("%s: %s%n", r.getDomain(), r.getError());
}
}
Each domain in the batch counts as one lookup against your quota.
Further reading
- API documentation — full endpoint reference with interactive examples
- RDAP vs WHOIS — why WHOIS is being replaced
- WHOIS API Alternatives — provider comparison with pricing
- WHOIS to RDAP migration guide — moving from WHOIS to RDAP