Docs API reference
Directory
Public, cacheable domain directory.
5 operations. See the API overview for authentication, errors, idempotency and caching rules that apply to all of them.
Browse or search domains
GET/v1/domains
Without query the directory is browsed in popularity-rank order (unranked domains last). With query it is a
prefix search over canonical names, in alphabetical order. Pagination is by opaque cursor; the cursor is only valid with
the same query mode that produced it.
query is trimmed and lower-cased, one trailing dot is dropped and internationalized names are converted to A-labels.
It may contain only letters, digits, dots and hyphens and be at most 253 characters.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
query | query | No | string | Prefix of the canonical domain name. at most 1012 characters example payments |
limit | query | No | integer | Page size. 1 to 100; default 25 |
cursor | query | No | string | The next_cursor of the previous page.at most 512 characters |
Responses
| Status | Description | Body |
|---|---|---|
| 200 | One page of domains. Headers: Cache-Control: public, max-age=60 | application/json DomainPage |
| 400 | invalid_request. Malformed JSON, an invalid or unknown field (see field_errors), a missing or malformed
Idempotency-Key, or a bad query. Also used with status 413 (body over 64 KiB) and 415 (wrong Content-Type). | application/json Error |
| 503 | unavailable (temporarily unavailable; also a feature that is not configured in this deployment) or
provider_unavailable (an identity provider is not configured). Both are 503. During maintenance (for example a database
recovery) every mutating request answers unavailable "The directory is under maintenance; try again later." Reads keep working. | application/json Error |
200 example
{
"data": [
{
"domain": "payments.example.com",
"display_name": "payments.example.com",
"registrable_domain": "example.com",
"status": "claimed",
"rank": 4821,
"observation": {
"status": "unchecked",
"observed_at": "2026-10-04T08:15:00.000Z"
}
}
],
"next_cursor": "eyJyIjo0ODIxLCJuIjoicGF5bWVudHMuZXhhbXBsZS5jb20ifQ",
"as_of": "2026-10-05T12:00:00.000Z"
}
400 example
{
"code": "invalid_request",
"message": "Invalid organization.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w",
"field_errors": [
{
"field": "legal_name",
"message": "must be 1-200 characters"
}
]
}
503 example
{
"code": "unavailable",
"message": "Temporarily unavailable.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}
Add an unlisted domain (live DNS and TLS validation)
POST/v1/domains
Asks the directory to list a domain it does not list yet, so that organization_id can then claim it with POST /v1/claims.
A signed-in person with passkey MFA (service tokens are refused with 403), owner or domain administrator of
organization_id. Spec ยง3: "Enrollment adds further domains". The exact hostname is added: adding www.example.com
never adds example.com, no other name is ever enumerated, and the popularity rank stays null.
Checked in order; nothing is inserted unless every check passes:
- The name canonicalizes to a public hostname: not an IP address, a public suffix (
co.uk), a shared hosting suffix (github.io), a special-use name (.local,.test,.example, ...) or an unknown TLD. Otherwise422with the reason code infield_errors[0].message(ip_address,public_suffix,private_suffix,special_use,unknown_tld, ...). - Already listed:
200withstatus: present(idempotent). - Then the check runs as a job:
202withjob_id; pollGET /v1/jobs/{id}(kinddomain_enroll). The job requires public A/AAAA records at both Cloudflare and Google DNS, then makes one live TLS observation through the same authenticated scanner protocol as the directory's own observations (signed request, MAC over the raw response, bound to this job), and requires the chain to validate for the exact name against the pinned trust store, now. The certificate class (DV, OV, EV) only describes the evidence and is never a reason to refuse.
The job result is {outcome: added, domain, domain_id, observation} (the domain is now listed, unclaimed, with the
observation as its first evidence, and a public domain.added event), {outcome: present}, or
{outcome: rejected, reason, message} with reason one of dns_no_address, dns_resolvers_disagree, dns_unavailable,
tls_unreachable, tls_not_observable, certificate_untrusted, certificate_name_mismatch, certificate_expired,
observation_unverifiable, scanning_paused. A scanner
answer that fails authentication or request binding is never trusted: the job ends failed and nothing is inserted.
The live scan counts against the directory's global observation rate cap (a capped check is retried with backoff). The job
also settles as rejected with scanning_paused if observations were switched off before it ran, and with
observation_unverifiable when the scanner could not be reached on the last attempt.
If the certificate names this host only through a wildcard SAN, the observation records wildcard_match: true.
Limits. 10 per person and 30 per organization per rolling day, 5 per registrable domain per rolling day, a global daily
cap for the whole directory (500 by default), and 5 per minute per client IP; each is 429. Re-asking for the same name, in
the same organization, while its check is still running returns that check. 503 while observations are switched off.
Who asked is recorded privately and never published. At most 100 domain.added events per hour are public; the rest of the
hour is published as one domain.added.batch count event.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
Idempotency-Key | header | Yes | string | Unique key for this logical mutation (1 to 255 characters of A-Z a-z 0-9 . _ : -). Replays within 24 hours return the original outcome.1 to 255 characters; pattern ^[A-Za-z0-9._:-]+$example 7f3c1b52-9d44-4a0e-8f21-0a6e5b9d3c77 |
x-art-csrf | header | Yes | string | The csrf_token from GET /auth/session. Required on every session-authenticated mutation.example Kk3jVq0bYw2p0mT1aZc9sD8eF4gH6iJ7nL5oP2rQ1uA |
Request body
application/json, required: object
| Field | Type | Required | Description |
|---|---|---|---|
domain | string | Yes | The exact hostname; normalized like the directory's own names (case, trailing dot, IDNA). at most 1012 characters |
organization_id | OrganizationId | Yes |
Example
{
"domain": "payments.example.com",
"organization_id": "org_7k3m9q2x8d5f1h4j6n0p2r4t8v"
}
Responses
| Status | Description | Body |
|---|---|---|
| 200 | The domain is already listed. | application/json object |
| 202 | The check is running; poll the job. | application/json object |
| 400 | invalid_request. Malformed JSON, an invalid or unknown field (see field_errors), a missing or malformed
Idempotency-Key, or a bad query. Also used with status 413 (body over 64 KiB) and 415 (wrong Content-Type). | application/json Error |
| 401 | unauthenticated (no or invalid credentials, or the session has not completed the passkey ceremony) or
step_up_required (the passkey assertion is too old; also returned to service tokens, which can never step up).
Both are 401; tell them apart by code. | application/json Error |
| 403 | forbidden. Authenticated but not allowed: a role that does not permit the action, a service token on a human-only operation,
a missing or wrong CSRF token, or a request Origin that is not allowed. | application/json Error |
| 404 | not_found. No such resource, or one the caller may not see (the two are indistinguishable by design). | application/json Error |
| 409 | conflict. The record changed since you read it (stale expected_version), the action is not valid in the record's
current state, the Idempotency-Key was reused with a different request, or the same request is still in flight. | application/json Error |
| 422 | semantic_check_failed. The request is well formed but a policy or proof check failed (for example an issuer-control proof
did not verify, a challenge expired, or the organization is not yet verified). field_errors[].message is often a stable
reason code. | application/json Error |
| 429 | rate_limited. A per-client or per-record limit was reached; retry later. | application/json Error |
| 503 | unavailable (temporarily unavailable; also a feature that is not configured in this deployment) or
provider_unavailable (an identity provider is not configured). Both are 503. During maintenance (for example a database
recovery) every mutating request answers unavailable "The directory is under maintenance; try again later." Reads keep working. | application/json Error |
200 example
{
"status": "present",
"domain": "payments.example.com",
"record": "/v1/domains/payments.example.com"
}
202 example
{
"status": "pending",
"domain": "payments.example.com",
"enrollment_id": "enr_5b8e1h4k7n0q3t6w9z2c5f8j1m",
"job_id": "job_2d5g8k1n4q7t0w3z6c9f2h5j8m",
"poll": "/v1/jobs/job_2d5g8k1n4q7t0w3z6c9f2h5j8m"
}
400 example
{
"code": "invalid_request",
"message": "Invalid organization.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w",
"field_errors": [
{
"field": "legal_name",
"message": "must be 1-200 characters"
}
]
}
401 unauthenticated
{
"code": "unauthenticated",
"message": "Sign in required.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}
401 step_up_required
{
"code": "step_up_required",
"message": "Recent passkey verification required for this action.",
"request_id": "req_5a8c1f4h7k0m3p6s9v2x5z8b1d"
}
403 example
{
"code": "forbidden",
"message": "Your role does not permit this action.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}
404 example
{
"code": "not_found",
"message": "No such resource.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}
409 example
{
"code": "conflict",
"message": "The record changed since you read it; reload and retry with the current version.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}
422 example
{
"code": "semantic_check_failed",
"message": "Issuer-control proof failed (endpoint_http_404). 4 attempt(s) left on this challenge.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w",
"field_errors": [
{
"field": "method",
"message": "endpoint_http_404"
}
]
}
429 example
{
"code": "rate_limited",
"message": "Too many requests.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}
503 example
{
"code": "unavailable",
"message": "Temporarily unavailable.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}
Get one domain
GET/v1/domains/{domain}
The public record of one exact domain: its directory status, the latest TLS observation (always a historical observation, never a trust verdict), verified claims, active delegations and live issuer bindings. Pending claims are counted, never named.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
domain | path | Yes | string | A hostname. It is canonicalized (case, trailing dot, IDNA) before lookup. at most 253 characters example payments.example.com |
Responses
| Status | Description | Body |
|---|---|---|
| 200 | The domain record. Headers: Cache-Control: public, max-age=60 | application/json DomainDetail |
| 400 | invalid_request. Malformed JSON, an invalid or unknown field (see field_errors), a missing or malformed
Idempotency-Key, or a bad query. Also used with status 413 (body over 64 KiB) and 415 (wrong Content-Type). | application/json Error |
| 404 | not_found. No such resource, or one the caller may not see (the two are indistinguishable by design). | application/json Error |
| 503 | unavailable (temporarily unavailable; also a feature that is not configured in this deployment) or
provider_unavailable (an identity provider is not configured). Both are 503. During maintenance (for example a database
recovery) every mutating request answers unavailable "The directory is under maintenance; try again later." Reads keep working. | application/json Error |
200 example
{
"domain": "payments.example.com",
"display_name": "payments.example.com",
"registrable_domain": "example.com",
"status": "claimed",
"rank": 4821,
"unclaimed": false,
"observation": {
"status": "unchecked",
"observed_at": "2026-10-04T08:15:00.000Z",
"class": "OV",
"label": "Organization validated certificate (status unchecked)",
"policy_oids": [
"2.23.140.1.2.2"
],
"policy_version": "obs-policy-1",
"trust_store_version": "2026-09",
"status_checked": false,
"path_valid": true,
"sans_count": 3,
"issuer_cn": "Example Issuing CA",
"not_before": "2026-08-01T00:00:00.000Z",
"not_after": "2026-11-01T00:00:00.000Z",
"error_code": null,
"wildcard_match": false
},
"sources": [
{
"source": "tranco",
"snapshot": "647LX",
"rank": 4821,
"original_name": "payments.example.com",
"snapshot_date": "2026-10-04T22:00:02Z",
"attribution": "Tranco list 647LX (Le Pochat et al., NDSS 2019) โ https://tranco-list.eu/list/647LX",
"license": "Tranco list: free to use with attribution; cite Le Pochat et al., NDSS 2019."
}
],
"enrollment": null,
"claims": [
{
"organization_name": "Example Payments, Inc.",
"organization_id": "org_2k5n8r1v4y7b0e3h6m9q2t5x8a",
"state": "verified",
"verified_checks": [
"domain",
"identity",
"authority"
]
}
],
"pending_claims": 0,
"delegations": [],
"issuer_bindings": [
{
"trust_domain": "agents.payments.example.com",
"status": "active"
}
],
"as_of": "2026-10-05T12:00:00.000Z"
}
400 example
{
"code": "invalid_request",
"message": "Invalid organization.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w",
"field_errors": [
{
"field": "legal_name",
"message": "must be 1-200 characters"
}
]
}
404 example
{
"code": "not_found",
"message": "No such resource.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}
503 example
{
"code": "unavailable",
"message": "Temporarily unavailable.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}
Seed sources, import counts and observation coverage
GET/v1/sources
Every completely imported snapshot of a seed list, newest first: where it came from (source, snapshot, dates, checksums), the license and attribution it is used under, the exact import counts, and how many of its domains have been observed.
The counts always add up: unique_domains + invalid_rows + merged_rows = source_rows. Rejected rows (IP addresses, public
suffixes, malformed lines) and merged rows (spellings of a name already listed at a better rank) are reported, never hidden,
so a "100,000" list is not presented as 100,000 distinct domains.
coverage counts the snapshot's domains by their latest observation: observed, unreachable, unknown (observed but not
observable, for example a non-TLS host) and pending (not observed yet). It is refreshed hourly; coverage.as_of says when.
Cacheable for 300 seconds.
Responses
| Status | Description | Body |
|---|---|---|
| 200 | Snapshots, newest first. Headers: Cache-Control | application/json SourceList |
| 503 | unavailable (temporarily unavailable; also a feature that is not configured in this deployment) or
provider_unavailable (an identity provider is not configured). Both are 503. During maintenance (for example a database
recovery) every mutating request answers unavailable "The directory is under maintenance; try again later." Reads keep working. | application/json Error |
200 example
{
"data": [
{
"snapshot_id": "imp_tranco_647LX_100000",
"source": "tranco",
"snapshot": "647LX",
"snapshot_date": "2026-10-04T22:00:02Z",
"retrieved_at": "2026-10-05T06:00:00.000Z",
"row_limit": 100000,
"checksum": "sha256:3b1f0c7a9d2e5f48a6b0c1d3e7f9a2b4c6d8e0f1a3b5c7d9e1f2a4b6c8d0e1f2",
"source_file_sha256": "23165e5e9e9ec2b6b186fb092c285f1943d51d25fb48d27d7fe1e0cad0bca959",
"psl_version": "tldts@7.4.16",
"attribution": "Tranco list 647LX (Le Pochat et al., NDSS 2019) โ https://tranco-list.eu/list/647LX",
"license": "Tranco list: free to use with attribution; cite Le Pochat et al., NDSS 2019.",
"counts": {
"source_rows": 100000,
"unique_domains": 99412,
"invalid_rows": 371,
"merged_rows": 217
},
"coverage": {
"observed": 61220,
"unreachable": 4108,
"unknown": 2190,
"pending": 31894,
"as_of": "2026-10-05T11:53:00.000Z"
}
}
],
"as_of": "2026-10-05T12:00:00.000Z"
}
503 example
{
"code": "unavailable",
"message": "Temporarily unavailable.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}
Public organization detail
GET/v1/organizations/{id}/public
What the directory publicly vouches for about one verified organization: its reviewed legal identity, the domains it holds a verified (or suspended) claim on with the time of each check, the SPIFFE trust domains it has authorized with the bundle version relying parties are currently given, and its active delegations, given and received.
evidence reports five independent indicators across the organization's domains. There is deliberately no combined score.
A draft, pending, rejected or lapsed organization answers 404, exactly like an unknown id, and so does a verified one that no
longer holds any claim in verified or suspended (its status lapses when the last such claim ends). Identity dates come only
from those live claims, and bindings are listed only under them. A delegation counterpart without its own public record has
organization.id: null (its identity is not verified). Never returned: members, accounts or
emails, reviewer identity or notes, private evidence, and pending, rejected or withdrawn items. No authentication;
cacheable for 60 seconds.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | Yes | string | pattern ^org_[a-z0-9]+$example org_2k5n8r1v4y7b0e3h6m9q2t5x8a |
Responses
| Status | Description | Body |
|---|---|---|
| 200 | The organization's public record. Headers: Cache-Control | application/json PublicOrganization |
| 404 | not_found. No such resource, or one the caller may not see (the two are indistinguishable by design). | application/json Error |
| 503 | unavailable (temporarily unavailable; also a feature that is not configured in this deployment) or
provider_unavailable (an identity provider is not configured). Both are 503. During maintenance (for example a database
recovery) every mutating request answers unavailable "The directory is under maintenance; try again later." Reads keep working. | application/json Error |
200 example
{
"id": "org_2k5n8r1v4y7b0e3h6m9q2t5x8a",
"legal_name": "Example Payments, Inc.",
"jurisdiction": "US-DE",
"registration_reference": "7712345",
"verification": {
"status": "verified",
"first_verified_at": "2026-09-01T10:00:00.000Z",
"last_reviewed_at": "2026-09-01T10:00:00.000Z",
"expires_at": "2027-09-01T10:00:00.000Z"
},
"evidence": {
"organization_identity": {
"status": "reviewed",
"reviewed_at": "2026-09-01T10:00:00.000Z",
"expires_at": "2027-09-01T10:00:00.000Z"
},
"domain_control": {
"verified_domains": 1,
"listed_domains": 1
},
"representative_authority": {
"current": 1,
"listed_domains": 1,
"earliest_expiry": "2027-03-01T00:00:00.000Z"
},
"issuer_authorization": {
"active": 1,
"suspended": 0
},
"certificate_observation": {
"observed_domains": 1,
"listed_domains": 1,
"latest_observed_at": "2026-10-05T04:36:00Z",
"status_checked": false
}
},
"domains": [
{
"domain": "payments.example.com",
"claim_state": "verified",
"verified_checks": [
"domain",
"identity",
"authority"
],
"domain_verified_at": "2026-09-01T09:00:00.000Z",
"domain_control_recheck_due": "2026-11-30T09:00:00.000Z",
"identity_verified_at": "2026-09-01T10:00:00.000Z",
"authority_verified_at": "2026-09-02T10:00:00.000Z",
"authority_expires_at": "2027-03-01T00:00:00.000Z",
"observation": {
"status": "observed",
"observed_at": "2026-10-05T04:36:00Z",
"class": "OV"
}
}
],
"issuer_bindings": [
{
"trust_domain": "agents.payments.example.com",
"profile": "spiffe-x509-svid",
"workload_scope": [
"/payments"
],
"status": "active",
"verified_domain": "payments.example.com",
"current_version": 2,
"bundle_sha256": "q3Jm0f4vX9n2bR7cT1yW8kP5sD6hL0aZ4eU2iO9gN1M",
"effective_at": "2026-09-10T00:00:00.000Z",
"expires_at": "2027-03-01T00:00:00.000Z",
"bundle_url": "/v1/issuer-bindings/bnd_9x8w7v6u5t4s3r2q1p0o9n8m7l/versions/2/bundle"
}
],
"delegations": {
"given": [],
"received": []
},
"as_of": "2026-10-05T12:00:00.000Z"
}
404 example
{
"code": "not_found",
"message": "No such resource.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}
503 example
{
"code": "unavailable",
"message": "Temporarily unavailable.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}