Practical guide

RDAP Domain Expiration & Registrar API Guide: Monitor Domain Expiry & DNSSEC in Python/Node.js

Last reviewed: October 25, 2026

Discover the authoritative registry RDAP service, read structured domain events and DNSSEC metadata, and keep a dated portfolio watch without collecting personal WHOIS contacts.

Why RDAP replaced port-43 WHOIS for domain monitoring

RDAP provides structured JSON over HTTPS, with explicit events, status codes and registrar entities. It replaces brittle port-43 WHOIS text parsing in this workflow. Use RFC 9224 discovery to choose the authoritative service, and RFC 7480 HTTP rules to interpret redirects, negative responses and rate limits. A registry record is an observation of registration state, not a guarantee of your customer renewal deadline.

SourceEndpoint and purposeTerms and coverage limits
IANA DNS bootstraphttps://data.iana.org/rdap/dns.json: maps suffixes to RDAP base URLs.RFC 9224 discovery. Choose the longest suffix match and an HTTPS URL. A mapping is service discovery, not a data reuse licence.
Verisign .com / .nethttps://rdap.verisign.com/com/v1/ and https://rdap.verisign.com/net/v1/; append domain/example.com or the matching .net name.RFC 7480 HTTP responses. Registry terms restrict unsolicited solicitation and high-volume automation; no accuracy guarantee.
Public Interest Registry .orghttps://rdap.publicinterestregistry.org/rdap/; append domain/iana.org.Lawful informational use, throttling and restrictions on high-volume automation. Retain source attribution.
DENIC .dehttps://rdap.denic.de/: documented experimental service.No service-level assurances. The supplied review found no .de entry in the live IANA bootstrap. A fallback reachability probe does not establish bootstrap coverage or robots permission.

Public access does not grant unrestricted redistribution rights. Check each registry's terms and robots policy before automation. Omit natural-person contact vCards, nested registrant, administrative, technical, billing and abuse entities, addresses, email and phone fields. Only project a registrar's public business identity and numeric IANA ID. The examples never print a raw entity object.

Discover the authoritative server and query a domain with curl and jq

Review https://data.iana.org/robots.txt and the chosen registry's /robots.txt before these requests. The recorded review permitted IANA's bootstrap path; PIR returned robots 404, meaning no published policy. Check current results when you run this yourself. The shell example first inspects the .org discovery entry, then projects only selected fields from the verified registry endpoint.

# Run in Bash after reviewing IANA and registry robots.txt and terms.
set -euo pipefail
curl --fail --silent --show-error --max-time 20 \
  'https://data.iana.org/rdap/dns.json' |
  jq '.services[] | select(.[0] | index("org")) | .[1]'
# The recorded .org mapping points to this authoritative registry URL.
curl --fail --silent --show-error --max-time 20 \
  -H 'Accept: application/rdap+json' \
  'https://rdap.publicinterestregistry.org/rdap/domain/iana.org' |
jq '
  def business_registrar:
    [.entities[]?
     | select((.roles // []) | index("registrar"))
     | (.vcardArray[1] // []) as $v
     | ([.publicIds[]? | select(.type == "IANA Registrar ID")
         | .identifier | tostring | select(test("^[0-9]+$"))][0]) as $id
     | ([$v[] | select(.[0] == "kind") | .[3]][0] // "") as $kind
     | select($kind != "individual")
     | ([$v[] | select(.[0] == "org") | .[3]
          | if type == "array" then .[0] else . end][0]) as $org
     | ([$v[] | select(.[0] == "fn") | .[3]][0]) as $fn
     | ($org // (if $kind == "org" or
           (($fn // "") | test("(?i)(^|[^a-z])(inc|ltd|llc|corp|corporation|corporate|limited|gmbh|llp|plc|icann)([^a-z]|$)"))
         then $fn else null end)) as $name
     | select($id != null and ($name | type) == "string")
     | select(($name | test("@|[0-9]{5,}|https?://")) | not)
     | {name: $name, ianaId: $id}][0] // null;
  {ldhName,
   events: [.events[]? | select(.eventAction == "expiration" or
                               .eventAction == "registration" or
                               .eventAction == "last changed")
            | {eventAction, eventDate}],
   status: (.status // []),
   nameservers: [.nameservers[]? | .ldhName],
   delegationSigned: (if (.secureDNS.delegationSigned | type) == "boolean" then .secureDNS.delegationSigned else null end),
   registrar: business_registrar}'

The registrar projection requires a numeric IANA ID with organization context or recognizable business wording, excludes explicit individual vCards, and omits ambiguous names. A missing business identity is null; never fall back to a registrant or abuse contact. events can omit expiration, registration or last changed. secureDNS.delegationSigned can be true, false or absent; absence means unknown. The supplied October 25 snapshot for iana.org published expiry 2027-12-08T17:00:53.000Z and DNSSEC signed true. It is dated evidence, not a fresh lookup.

Python 3 and Node.js 20: discover RDAP and compute expiry signals

Both scripts take an exact registered ASCII domain, fetch IANA bootstrap once, select the longest matching suffix with an HTTPS service, then request domain/<name>. Supply example.co.uk rather than a URL or arbitrary subdomain: bootstrap lookup does not determine the registrable domain. Convert internationalized names to punycode first. Review IANA and the selected registry's robots rules and terms before running; these focused examples assume that policy review is complete.

# Save as rdap_expiry.py; run: python3 rdap_expiry.py iana.org
# First review the selected registry's robots.txt and terms, as described above.
import json
import math
import re
import sys
import time
from datetime import datetime, timezone
from urllib.parse import urlsplit, quote
from urllib.request import Request, build_opener, HTTPRedirectHandler

class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None

opener = build_opener(NoRedirect())
last_request = {}

def get_json(url):
    parts = urlsplit(url)
    if (parts.scheme != "https" or not parts.hostname or parts.username
            or parts.password or parts.port not in (None, 443)):
        raise ValueError("Expected HTTPS without credentials or custom port")
    origin = parts.netloc.lower()
    time.sleep(max(0, last_request.get(origin, 0) + 0.75 - time.monotonic()))
    last_request[origin] = time.monotonic()
    request = Request(url, headers={"User-Agent": "DomainGuide/1.0",
                                   "Accept": "application/rdap+json, application/json"})
    with opener.open(request, timeout=15) as response:
        body = response.read(2 * 1024 * 1024 + 1)
    if len(body) > 2 * 1024 * 1024:
        raise ValueError("Response too large")
    return json.loads(body)

def server_for(domain, bootstrap):
    candidates = []
    for suffixes, urls in bootstrap["services"]:
        for suffix in suffixes:
            if domain == suffix or domain.endswith("." + suffix):
                for url in urls:
                    if urlsplit(url).scheme == "https":
                        candidates.append((len(suffix), url))
    if not candidates:
        raise ValueError("No HTTPS bootstrap mapping; do not guess a server")
    return max(candidates, key=lambda item: item[0])[1]

def timestamp(value):
    try:
        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
        return parsed.astimezone(timezone.utc) if parsed.tzinfo else None
    except (AttributeError, TypeError, ValueError):
        return None

def project(raw, now):
    events = {x.get("eventAction"): x.get("eventDate") for x in raw.get("events", [])}
    expiry = timestamp(events.get("expiration"))
    statuses = sorted({str(x).lower() for x in raw.get("status", [])})
    # Canonical spaced RDAP values; recognize the EPP equivalents too.
    compact = {re.sub(r"[ _-]", "", x) for x in statuses}
    dns = raw.get("secureDNS") or {}
    signed = dns.get("delegationSigned")
    return {"domain": raw.get("ldhName"), "registeredAt": events.get("registration"),
            "lastChangedAt": events.get("last changed"), "expiresAt": events.get("expiration"),
            "daysUntilExpiry": math.floor((expiry - now).total_seconds() / 86400) if expiry else None,
            "statusCodes": statuses,
            "transferLocked": bool(compact & {"clienttransferprohibited", "servertransferprohibited"}),
            "nameservers": sorted({x["ldhName"].lower() for x in raw.get("nameservers", []) if x.get("ldhName")}),
            "dnssecSigned": signed if isinstance(signed, bool) else None,
            "dnssecDsRecords": [{k: x.get(k) for k in ("keyTag", "algorithm", "digestType")}
                                for x in dns.get("dsData", [])]}

def main():
    # Supply an exact registered ASCII domain, not a URL or subdomain.
    domain = sys.argv[1].lower().rstrip(".") if len(sys.argv) > 1 else "iana.org"
    if not re.fullmatch(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+", domain):
        raise ValueError("Supply an ASCII domain; convert IDNs to punycode first")
    bootstrap = get_json("https://data.iana.org/rdap/dns.json")
    server = server_for(domain, bootstrap)
    url = server.rstrip("/") + "/domain/" + quote(domain, safe="")
    raw = get_json(url)
    print(json.dumps({**project(raw, datetime.now(timezone.utc)), "sourceUrl": url}))

if __name__ == "__main__":
    main()
// Save as rdap-expiry.mjs; run: node rdap-expiry.mjs iana.org (Node.js 20).
// Review the selected registry's robots.txt and terms before running.
const lastRequest = new Map();
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function getJson(url) {
  const u = new URL(url);
  if (u.protocol !== 'https:' || u.username || u.password || u.port) {
    throw new Error('Expected HTTPS without credentials or custom port');
  }
  await sleep(Math.max(0, (lastRequest.get(u.origin) || 0) + 750 - Date.now()));
  lastRequest.set(u.origin, Date.now());
  const response = await fetch(url, {redirect: 'error', signal: AbortSignal.timeout(15000),
    headers: {'User-Agent': 'DomainGuide/1.0', Accept: 'application/rdap+json, application/json'}});
  if (!response.ok) throw new Error(`HTTP ${response.status}; keep the last good observation`);
  const chunks = []; let size = 0;
  for await (const chunk of response.body) {
    size += chunk.length;
    if (size > 2 * 1024 * 1024) throw new Error('Response too large');
    chunks.push(chunk);
  }
  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
function serverFor(domain, bootstrap) {
  const candidates = [];
  for (const [suffixes, urls] of bootstrap.services) {
    for (const suffix of suffixes) {
      if (domain === suffix || domain.endsWith('.' + suffix)) {
        for (const url of urls) if (new URL(url).protocol === 'https:') {
          candidates.push({length: suffix.length, url});
        }
      }
    }
  }
  candidates.sort((a, b) => b.length - a.length);
  if (!candidates.length) throw new Error('No HTTPS bootstrap mapping; do not guess');
  return candidates[0].url;
}
function project(raw, now = Date.now()) {
  const events = Object.fromEntries((raw.events || []).map(x => [x.eventAction, x.eventDate]));
  const expiry = Date.parse(events.expiration);
  const statusCodes = [...new Set((raw.status || []).map(x => x.toLowerCase()))].sort();
  const compact = statusCodes.map(x => x.replace(/[ _-]/g, ''));
  const dns = raw.secureDNS || {};
  return {domain: raw.ldhName, registeredAt: events.registration ?? null,
    lastChangedAt: events['last changed'] ?? null, expiresAt: events.expiration ?? null,
    daysUntilExpiry: Number.isFinite(expiry) ? Math.floor((expiry - now) / 86400000) : null,
    statusCodes, transferLocked: compact.some(x => ['clienttransferprohibited', 'servertransferprohibited'].includes(x)),
    nameservers: [...new Set((raw.nameservers || []).map(x => x.ldhName?.toLowerCase()).filter(Boolean))].sort(),
    dnssecSigned: typeof dns.delegationSigned === 'boolean' ? dns.delegationSigned : null,
    dnssecDsRecords: (dns.dsData || []).map(x => ({keyTag: x.keyTag ?? null,
      algorithm: x.algorithm ?? null, digestType: x.digestType ?? null}))};
}
const domain = (process.argv[2] || 'iana.org').toLowerCase().replace(/\.$/, '');
if (!/^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/.test(domain)) throw new Error('Supply an ASCII registered domain');
const bootstrap = await getJson('https://data.iana.org/rdap/dns.json');
const server = serverFor(domain, bootstrap);
const sourceUrl = server.replace(/\/$/, '') + '/domain/' + encodeURIComponent(domain);
console.log(JSON.stringify({...project(await getJson(sourceUrl)), sourceUrl}));

Whole days use floor, including negative values. An expiry one hour in the past gives daysUntilExpiry: -1; one hour in the future gives 0. Unknown expiration stays null. Missing transfer-prohibited statuses mean no published lock, not proof that a transfer will succeed. The projection keeps only DS keyTag, algorithm and digestType, omitting full digest values. Registry DNSSEC metadata does not validate the live DNS chain.

These scripts reject redirects and stop on failed lookups, including 404 and 429. They do not treat errors as domain availability or remove a saved baseline. Before running a portfolio, cache the bootstrap, add per-registry policy checks, bounded retries and Retry-After handling. Use public registry destinations only; add DNS destination validation before turning this local example into a service that accepts untrusted input.

Portfolio monitoring, alert signals and polite polling

Save a dated successful observation for each domain with its exact source URL. Compare expiry, registrar identity, sorted status and nameserver lists, DNSSEC signature state and DS metadata. Keep lookup failures separate and preserve the last good baseline. A daily countdown is useful for display; a changed threshold signal is the alert event.

SignalInterpretation
expiring_soon0 <= daysUntilExpiry <= expiringWithinDays; the documented Actor default threshold is 30 days.
expiredThe published registry expiry timestamp is in the past. Compare the timestamp directly.
transfer_unlockedNeither client transfer prohibited nor server transfer prohibited is published; also recognize equivalent EPP camelCase values.
dnssec_unsigneddelegationSigned is explicitly false. Unknown state does not trigger this flag.
client_hold / server_holdPublished client hold or server hold status; review registrar or registry action.
pending_delete / redemption_periodPublished pending delete or redemption period status. Review recovery options with the registrar.

Serialize requests per origin, start with at least 750 ms between requests, and honor any longer robots crawl delay. This is a conservative example setting, not a registry quota or permission for bulk extraction. Cache IANA discovery for repeated portfolio checks, respect Retry-After on 429, back off on 5xx, and avoid overlapping runs. A single registry can serve many portfolio domains, so parallelizing by domain can still overload one origin.

Registry expiration dates can differ from registrar billing renewal dates because of auto-renew and redemption handling. Confirm critical renewal deadlines, payment status and recovery procedures with your registrar. Nameservers in RDAP describe registry records rather than a live DNS test; a 404 does not prove that a reserved, premium or restricted domain can be purchased.

The easy option: RDAP Domain & Expiry Monitor

RDAP Domain & Expiry Monitor collects normalized domain rows through official public registry RDAP services discovered via IANA. It accepts domains or website URLs, normalizes known public suffixes to registrable domains, deduplicates inputs, and returns expiry, public registrar business identity, transfer statuses, nameservers, DNSSEC metadata, signals and checkedAt. Export JSON, CSV or spreadsheet data. Price: about $1.50 per 1,000 domains emitted, plus the Actor start event; confirm final Store pricing.

{
  "domains": [
    "openai.com",
    "cloudflare.com",
    "iana.org"
  ],
  "expiringWithinDays": 30,
  "onlyFlagged": false,
  "includeNotFound": false,
  "maxResults": 25,
  "monitorMode": true,
  "monitorKey": "portfolio-watch",
  "requestDelayMs": 750
}

The first successful monitor observation emits changes: ["initial"]. Later runs emit changes to monitored fields or signals; a daily countdown alone does not create another row. Reuse the same monitorKey and threshold, retain named state, and schedule one run at a time. Snapshot mode uses an empty changes array. Failed lookups and filtered rows preserve the last delivered baseline.

Some TLDs lack an HTTPS bootstrap mapping. The Actor uses a best effort common-TLD fallback only when IANA is unreachable, not when a fresh bootstrap omits a suffix. It enforces robots policies and per-origin spacing. Runs have a 45-second collection budget, so partition or rotate large portfolios. Check RUN_SUMMARY for unsupported, blocked, rate-limited or unavailable targets: all-upstream failure can still finish successfully with zero rows.

Unchanged, filtered, duplicate, unsupported and failed lookups are not billed. With includeNotFound enabled, emitted 404 observations are billed. Delivery reservations reduce duplicate charges but cannot guarantee exactly-once delivery across crashes and storage writes. Apify schedules and your integrations handle alert delivery.

Frequently asked questions

Is an RDAP expiration date my registrar renewal deadline?

Not necessarily. Registry expiration can differ from registrar billing renewal dates because of auto-renew and redemption handling. Confirm critical renewal deadlines and payment status with your registrar.

Does a missing transfer lock mean I can transfer the domain?

No. It means neither client nor server transfer-prohibited status was published. Other registration rules and registrar requirements can still prevent a transfer.

Does DNSSEC signed mean the live DNS chain validates?

No. RDAP reports registry delegation metadata. It does not test live DNS signatures or validate the complete DNSSEC chain. Missing signature state is unknown, not unsigned.

Can I use these queries to collect WHOIS contact data?

These examples and the linked monitor omit natural-person contacts and project domain metadata plus public registrar business identity. Registry terms still apply to automated access and redistribution.

Why can a registered domain be missing from monitor output?

It may be unchanged, filtered, unsupported by the HTTPS bootstrap, robots-blocked, rate-limited or unavailable, or the run may have reached a result or time limit. Inspect RUN_SUMMARY before treating zero rows as a complete portfolio check.

Sources

Official sources below were checked in the supplied October 25, 2026 actor documentation. This guide uses that recorded review; no new network checks were made while preparing it.

Tool behavior, pricing qualifications and limitations follow the supplied Actor README: RDAP Domain & Expiry Monitor documentation.