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
- RDAP vs WHOIS: What Developers Need to Know — protocol comparison
- WHOIS to RDAP Migration Guide — step-by-step code migration
- RFC 9083 — RDAP JSON response specification
- IANA RDAP Bootstrap — TLD-to-server mapping
- API documentation — endpoint reference