The RDAP JSON Response Decoded

March 4, 2026

You query an RDAP server and get back a wall of JSON. Some fields are obvious (ldhName, status). Others are cryptic (vcardArray, objectClassName). Some are deeply nested for no apparent reason.

This guide walks through every field in an RDAP domain response, explains what it means, and shows you how to extract the data you need. (RDAP also covers IPs, ASNs, nameservers, and entities — their responses share the same conventions but have different top-level fields.)

The full response structure

Here is a real Verisign .com response (for stripe.com), condensed to show the top-level structure:

{
  "objectClassName": "domain",
  "handle": "891022_DOMAIN_COM-VRSN",
  "ldhName": "STRIPE.COM",
  "links": [...],
  "status": [...],
  "entities": [...],
  "events": [...],
  "nameservers": [...],
  "secureDNS": { "delegationSigned": false },
  "rdapConformance": [...],
  "notices": [...]
}

A real .com response is about 190 lines when pretty-printed. Thick registries (.org, .dev) can be longer because they include full registrant data inline.

objectClassName and handle

{
  "objectClassName": "domain",
  "handle": "891022_DOMAIN_COM-VRSN"
}

objectClassName is always "domain" for domain lookups. This field tells you which RDAP object type you're looking at — the same field appears on IP, ASN, nameserver, and entity responses with different values.

handle is the registry's internal identifier. The format varies by registry (Verisign uses {id}_DOMAIN_COM-VRSN). You rarely need this field.

ldhName and unicodeName

{
  "ldhName": "STRIPE.COM"
}

ldhName is the ASCII form of the domain name (letters, digits, hyphens — hence "LDH"). Most registries return it in uppercase.

Some registries also include unicodeName — the Unicode form for internationalized domain names. For example, "münchen.de" in Unicode vs "xn--mnchen-3ya.de" in LDH form. Verisign (.com) does not include unicodeName; PIR (.org) does.

Don't rely on case — always normalize to lowercase.

links

{
  "links": [
    {
      "value": "https://rdap.verisign.com/com/v1/domain/STRIPE.COM",
      "rel": "self",
      "href": "https://rdap.verisign.com/com/v1/domain/STRIPE.COM",
      "type": "application/rdap+json"
    },
    {
      "value": "https://rdap.verisign.com/com/v1/domain/STRIPE.COM",
      "rel": "related",
      "href": "https://rdap.safenames.legal/rdap/domain/STRIPE.COM",
      "type": "application/rdap+json"
    }
  ]
}

rel=self points to the current response. The important one is rel=related — the registrar referral link for thin registries.

Thin vs. thick registries

.com and .net use thin registries. Verisign stores basic data (domain name, status, nameservers, registrar reference) but the registrar holds the rest (contacts, abuse email). The related link points to the registrar's RDAP server. To get complete data, you need to follow it.

Thick registries (.org, most ccTLDs) return everything in one response. No referral link needed.

Here is how to follow the referral:

import requests

def lookup_with_follow(domain, base_url):
    headers = {"User-Agent": "MyApp/1.0"}
    resp = requests.get(f"{base_url}domain/{domain}", headers=headers)
    data = resp.json()

    # Check for registrar referral link
    for link in data.get("links", []):
        if link.get("rel") == "related" and "rdap+json" in link.get("type", ""):
            referral_url = link["href"]
            ref_resp = requests.get(referral_url, headers=headers)
            if ref_resp.status_code == 200:
                ref_data = ref_resp.json()
                # Merge: referral data fills in what the registry omits
                # (entities, contacts, abuse info)
                data["entities"] = ref_data.get("entities", data.get("entities", []))
            break

    return data

This is the minimum — production code should handle timeouts, rate limits, and partial merge logic.

status

{
  "status": [
    "client delete prohibited",
    "client transfer prohibited",
    "client update prohibited",
    "server delete prohibited",
    "server transfer prohibited",
    "server update prohibited"
  ]
}

Status codes follow the EPP status codes defined by ICANN. The most common ones:

Status Meaning (from ICANN)
ok Standard status — no pending operations or prohibitions
client delete prohibited Registrar will reject requests to delete the domain
client transfer prohibited Registrar will reject requests to transfer the domain
client update prohibited Registrar will reject requests to update the domain
server delete prohibited Registry will reject requests to delete the domain
server transfer prohibited Registry will reject requests to transfer the domain
server hold Domain is not activated in the DNS by the registry
client hold Domain will not resolve — usually in connection with non-payment
pending delete Domain was in redemption period for 30 days and was not restored
redemption period Registrar asked to delete the domain — held for 30 days before permanent deletion
inactive No delegation information (nameservers) associated with the domain

A domain with both client and server prohibitions (like stripe.com above) is locked — it cannot be transferred, deleted, or modified without the registrar and registry both lifting their locks.

events

{
  "events": [
    { "eventAction": "registration", "eventDate": "1995-09-12T04:00:00Z" },
    { "eventAction": "expiration", "eventDate": "2027-09-11T04:00:00Z" },
    { "eventAction": "last changed", "eventDate": "2025-10-01T01:39:51Z" },
    { "eventAction": "last update of RDAP database", "eventDate": "2026-03-05T08:16:50Z" }
  ]
}

Dates are always ISO 8601. The eventAction values you care about:

  • "registration" — when the domain was first registered
  • "expiration" — when the domain expires
  • "last changed" — last modification to the registration record
  • "last update of RDAP database" — when the RDAP server's data was refreshed (not a domain event — ignore this for domain analysis)

To extract the expiration date:

expiry = None
for event in data.get("events", []):
    if event["eventAction"] == "expiration":
        expiry = event["eventDate"]  # "2027-09-11T04:00:00Z"

Watch out: not all registries include all event types. DENIC (.de) does not include expiration in its RDAP responses. Some registries include additional events like "transfer". Always check for the field before accessing it.

entities — the hard part

Entities represent the people and organizations associated with a domain — registrar, registrant, admin contact, tech contact.

{
  "entities": [
    {
      "objectClassName": "entity",
      "handle": "447",
      "roles": ["registrar"],
      "publicIds": [
        { "type": "IANA Registrar ID", "identifier": "447" }
      ],
      "vcardArray": [
        "vcard",
        [
          ["version", {}, "text", "4.0"],
          ["fn", {}, "text", "SafeNames Ltd."]
        ]
      ],
      "entities": [...]
    }
  ]
}

roles

Each entity has a roles array that tells you what it represents:

  • "registrar" — the registrar (GoDaddy, Cloudflare, SafeNames, etc.)
  • "registrant" — the domain owner
  • "administrative" — admin contact
  • "technical" — tech contact
  • "abuse" — abuse contact (usually nested inside the registrar entity)

vcardArray — decoding contacts

Contact information uses jCard (RFC 7095) — the JSON encoding of vCard 4.0. The format is:

["vcard", [
  ["version", {}, "text", "4.0"],
  ["fn", {}, "text", "SafeNames Ltd."],
  ["adr", {}, "text", ["", "", "PO Box 5765", "Milton Keynes", "", "MK10 1BY", "GB"]],
  ["tel", { "type": "voice" }, "uri", "tel:+44.1908200022"],
  ["email", {}, "text", "[email protected]"]
]]

Each entry is an array of 4 elements: [property, parameters, type, value]. The first entry is always "version" — skip it when extracting data.

To extract useful fields:

def parse_vcard(vcard_array):
    """Extract fields from RDAP vcardArray."""
    result = {}
    for field in vcard_array[1]:  # vcard_array[0] is "vcard", [1] is the fields list
        prop = field[0]
        value = field[3]

        if prop == "version":
            continue  # Always "4.0", skip
        elif prop == "fn":
            result["name"] = value
        elif prop == "org":
            result["organization"] = value if isinstance(value, str) else value[0]
        elif prop == "email":
            result["email"] = value
        elif prop == "tel":
            result["phone"] = value
        elif prop == "adr":
            if isinstance(value, list) and len(value) >= 7:
                result["country"] = value[6]
                result["city"] = value[3]

    return result
function parseVcard(vcardArray) {
  const result = {};
  for (const field of vcardArray[1]) {
    const [prop, , , value] = field;

    if (prop === "version") continue;
    if (prop === "fn") result.name = value;
    else if (prop === "org")
      result.organization = Array.isArray(value) ? value[0] : value;
    else if (prop === "email") result.email = value;
    else if (prop === "tel") result.phone = value;
    else if (prop === "adr" && Array.isArray(value) && value.length >= 7) {
      result.country = value[6];
      result.city = value[3];
    }
  }
  return result;
}

Nested entities

Entities can contain other entities. The most common case: the registrar entity contains an abuse contact:

{
  "roles": ["registrar"],
  "vcardArray": ["vcard", [["version", {}, "text", "4.0"], ["fn", {}, "text", "SafeNames Ltd."]]],
  "entities": [
    {
      "roles": ["abuse"],
      "vcardArray": ["vcard", [
        ["version", {}, "text", "4.0"],
        ["fn", {}, "text", ""],
        ["tel", {"type": "voice"}, "uri", "tel:+44.1908200022"],
        ["email", {}, "text", "[email protected]"]
      ]]
    }
  ]
}

To get the abuse email, you need to look inside the registrar entity's entities array — two levels deep:

def get_abuse_email(data):
    for entity in data.get("entities", []):
        if "registrar" in entity.get("roles", []):
            for sub in entity.get("entities", []):
                if "abuse" in sub.get("roles", []):
                    vcard = sub.get("vcardArray", ["vcard", []])
                    for field in vcard[1]:
                        if field[0] == "email":
                            return field[3]
    return None

nameservers

{
  "nameservers": [
    { "objectClassName": "nameserver", "ldhName": "NS-1087.AWSDNS-07.ORG" },
    { "objectClassName": "nameserver", "ldhName": "NS-1882.AWSDNS-43.CO.UK" },
    { "objectClassName": "nameserver", "ldhName": "NS-423.AWSDNS-52.COM" },
    { "objectClassName": "nameserver", "ldhName": "NS-705.AWSDNS-24.NET" }
  ]
}

Each nameserver is an object with at least ldhName. Some registries (PIR for .org, verified) include IP addresses:

{
  "ldhName": "NS0.WIKIMEDIA.ORG",
  "ipAddresses": {
    "v4": ["208.80.154.238"],
    "v6": ["2620:0:862:ed1a::3:e"]
  }
}

Verisign (.com) does not include nameserver IPs — you need DNS lookups for those.

secureDNS

{
  "secureDNS": {
    "delegationSigned": false
  }
}

Tells you whether the domain has DNSSEC enabled. If delegationSigned is true, the domain has a DS record in the parent zone. Some registries include the actual key data:

{
  "secureDNS": {
    "delegationSigned": true,
    "maxSigLife": 1,
    "dsData": [
      {
        "keyTag": 2371,
        "algorithm": 13,
        "digest": "39FDC63793DB261F978F59086A5D1D17BDE3B5A32E2A4D55C8ECE6027D969C33",
        "digestType": 2
      }
    ]
  }
}

This is a real response from PIR (.org, internetsociety.org). PIR also includes "maxSigLife" in the secureDNS object — Verisign does not.

rdapConformance and notices

{
  "rdapConformance": [
    "rdap_level_0",
    "icann_rdap_technical_implementation_guide_1",
    "icann_rdap_response_profile_1"
  ],
  "notices": [
    {
      "title": "Terms of Service",
      "description": ["Service subject to Terms of Use."],
      "links": [{"href": "https://www.verisign.com/domain-names/registration-data-access-protocol/terms-service/index.xhtml", "rel": "terms-of-service"}]
    },
    {
      "title": "Status Codes",
      "description": ["For more information on domain status codes, please visit https://icann.org/epp"],
      "links": [{"href": "https://icann.org/epp"}]
    }
  ]
}

rdapConformance lists which RDAP extensions the server supports. notices contain terms of service and informational links. You can safely ignore both for data extraction.

What varies between registries

Based on testing real responses from Verisign (.com) and PIR (.org):

Field Verisign (.com) PIR (.org)
Registry type Thin (needs referral) Thick (complete)
unicodeName Not included Included
Nameserver IPs Not included Included
secureDNS.maxSigLife Not included Included
Registrant entity Minimal (in referral) Full (with GDPR redaction)

The practical difference: for .com domains, you need to follow the rel=related referral link to get registrar contacts and abuse info. For .org domains, everything is in the first response.

Skip the parsing

If you don't want to write vcardArray parsers, follow referral links, and handle per-registry differences, RDAP API does it for you:

curl -H "Authorization: Bearer YOUR_TOKEN" \
  "https://rdapapi.io/api/v1/domain/stripe.com?follow=true"

The ?follow=true parameter automatically follows thin-registry referrals and merges the responses. The result is flat JSON — no vcardArray, no nested entities, same structure for every TLD. See the API documentation for the full schema.

Further reading


Ready to try RDAP lookups?