How to Find a Domain's Age in Java (RDAP, the Modern WHOIS)

You need to know how old a domain is. Maybe you're enriching a lead list, monitoring your own portfolio for expiry, or building a fraud-ops check where "registered four hours ago" is a useful hint. So you reach for WHOIS — and immediately hit a wall of free-text output that's different for every registry, changes without notice, and is rate-limited into oblivion.

There's a better way, and almost nobody talks about it: RDAP.

TL;DR

  • RDAP (Registration Data Access Protocol, RFC 7483) is the modern, structured, JSON-over-HTTPS replacement for WHOIS. Standardized fields, no regex-the-planet parsing.
  • The registration date lives in an events array: find eventAction == "registration", read its eventDate.
  • Gotcha 1: you don't know which server answers for a given TLD — a bootstrap step redirects you to the authoritative registry. Follow the redirect.
  • Gotcha 2: query the registrable domain (example.co.uk), not the full hostname (www.shop.example.co.uk) and not a naive "last two labels" (which breaks on .co.uk).

Here's the whole thing in plain Java — java.net.http.HttpClient + Jackson, no heavyweight deps.

Why not WHOIS?

WHOIS is a 1980s protocol that returns a blob of text a human is meant to read. Two registries, two completely different layouts:

# One registry
Creation Date: 2011-03-14T00:00:00Z

# Another
registered:   2004-08-01

Field names, date formats, key casing, and even which fields exist all vary. You end up maintaining a pile of registry-specific regexes that rot the moment someone tweaks their output. RDAP fixes this by defining the shape of the response, so you parse one structure everywhere.

Step 1: Get the registrable domain right

This is the step people skip, and it silently returns wrong answers. You must query the registrable domain — the part someone actually registered — which is one label above the public suffix:

  • www.example.com → example.com
  • blog.shop.example.co.uk → example.co.uk (not co.uk)
  • foo.github.io → foo.github.io (github.io is itself a public suffix)

A naive "take the last two labels" gives you co.uk for the second one — which isn't a registrable domain at all, and your lookup 404s or returns nonsense. The correct source of truth is the Public Suffix List. Guava ships a parser:

// com.google.guava:guava
import com.google.common.net.InternetDomainName;

static String registrableDomain(String host) {
    InternetDomainName idn = InternetDomainName.from(host.toLowerCase());
    return idn.hasPublicSuffix() && idn.isUnderPublicSuffix()
            ? idn.topPrivateDomain().toString()
            : host.toLowerCase();
}

topPrivateDomain() is the eTLD+1. If you don't want the dependency, you can vendor the Public Suffix List and match longest-suffix-first — but do not hand-roll "last two labels." That bug is a rite of passage; skip it.

Step 2: The bootstrap redirect

Here's the part the docs bury. There is no single RDAP endpoint. Each TLD's registry runs its own RDAP server, and IANA publishes a bootstrap registry that maps TLDs to servers. The easiest way to use it is https://rdap.org/domain/<name> — a thin bootstrap service that 302-redirects you to the authoritative server:

GET https://rdap.org/domain/example.com
→ 302 Location: https://rdap.verisign.com/com/v1/domain/example.com

So your HTTP client must follow redirects. java.net.http.HttpClient does not follow cross-scheme or (depending on config) all redirects by default, so set the policy explicitly:

import java.net.http.*;
import java.net.URI;
import java.time.Duration;

HttpClient http = HttpClient.newBuilder()
        .followRedirects(HttpClient.Redirect.NORMAL)   // follow 3xx
        .connectTimeout(Duration.ofSeconds(5))
        .build();

If your platform disables auto-redirects for security reasons (a common SSRF-hardening posture), follow the hops yourself: read the Location header, cap the number of hops, and re-issue the GET. Either way — plan for a redirect, because you'll always get one.

Step 3: Parse the registration event

The authoritative server returns JSON. The bit you want is the events array:

{
  "objectClassName": "domain",
  "ldhName": "example.com",
  "events": [
    { "eventAction": "registration",   "eventDate": "1995-08-14T04:00:00Z" },
    { "eventAction": "expiration",      "eventDate": "2026-08-13T04:00:00Z" },
    { "eventAction": "last changed",    "eventDate": "2024-08-14T07:01:44Z" }
  ]
}

Pull the registration event and parse its ISO-8601 date:

import com.fasterxml.jackson.databind.*;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;

static final ObjectMapper MAPPER = new ObjectMapper();

static Optional<Instant> registrationDate(String domain) throws Exception {
    String reg = registrableDomain(domain);
    HttpRequest req = HttpRequest.newBuilder(URI.create("https://rdap.org/domain/" + reg))
            .header("Accept", "application/rdap+json")
            .timeout(Duration.ofSeconds(8))
            .GET().build();

    HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
    if (resp.statusCode() == 404) return Optional.empty();      // not registered
    if (resp.statusCode() != 200) throw new IllegalStateException("RDAP " + resp.statusCode());

    JsonNode events = MAPPER.readTree(resp.body()).path("events");
    for (JsonNode e : events) {
        if ("registration".equalsIgnoreCase(e.path("eventAction").asText())) {
            return Optional.of(Instant.parse(e.path("eventDate").asText()));
        }
    }
    return Optional.empty();   // registry published no registration date (see below)
}

static long ageInDays(Instant registered) {
    return ChronoUnit.DAYS.between(registered, Instant.now());
}

That's it. That's the whole lookup. One request, one predictable structure, no per-registry parsers.

Common Pitfalls

Some registries publish no registration date. DENIC (.de) is the classic example — it returns a valid RDAP object with events, but no registration action at all. Treat "no data" as its own outcome, distinct from "lookup failed." Returning Optional.empty() for both and then treating empty as "brand new" will burn you: a 20-year-old .de domain is not a fresh registration.

Redirects, again. If you see empty bodies or 3xx status codes leaking through, your client isn't following the bootstrap hop. This is the number-one cause of "it works for .com but not .io."

Rate limits and politeness. Registry RDAP servers are a shared public good. Cache results — a domain's registration date never changes, so it's cache-forever — and don't hammer a registry in a loop. A small in-memory cache keyed by registrable domain removes almost all traffic.

Timeouts are not "new domains." If the lookup times out, that's an unknown, not a young domain. Keep the two states separate in your code, or a flaky network will make old domains look suspiciously new.

Unregistered vs. error. A 404 means the domain isn't registered. A 5xx or timeout means you don't know. Don't collapse them.

Result

A dependency-light, one-request way to answer "how old is this domain?" that works across TLDs without a maintenance treadmill of WHOIS parsers. Structured in, structured out.

The two things that will bite you aren't the JSON — it's forgetting the bootstrap redirect and getting the registrable domain wrong on multi-part TLDs. Handle those two and RDAP is genuinely pleasant.


What are you using domain-age lookups for? Drop it in the comments — always curious what people build with this.

Building jo4.io — a URL shortener with analytics for developers.