Preview โ€” not for production reliance
Documentation

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

Authentication
Public, no credentials
Operation ID
listDomains

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

NameInRequiredTypeDescription
queryqueryNostringPrefix of the canonical domain name.
at most 1012 characters
example payments
limitqueryNointegerPage size.
1 to 100; default 25
cursorqueryNostringThe next_cursor of the previous page.
at most 512 characters

Responses

StatusDescriptionBody
200One page of domains.
Headers: Cache-Control: public, max-age=60
application/json DomainPage
400invalid_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
503unavailable (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

json
{
  "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

json
{
  "code": "invalid_request",
  "message": "Invalid organization.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w",
  "field_errors": [
    {
      "field": "legal_name",
      "message": "must be 1-200 characters"
    }
  ]
}

503 example

json
{
  "code": "unavailable",
  "message": "Temporarily unavailable.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}

Add an unlisted domain (live DNS and TLS validation)

POST/v1/domains

Authentication
Session (passkey-verified)
Operation ID
addDomain

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:

  1. 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. Otherwise 422 with the reason code in field_errors[0].message (ip_address, public_suffix, private_suffix, special_use, unknown_tld, ...).
  2. Already listed: 200 with status: present (idempotent).
  3. Then the check runs as a job: 202 with job_id; poll GET /v1/jobs/{id} (kind domain_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

NameInRequiredTypeDescription
Idempotency-KeyheaderYesstringUnique 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-csrfheaderYesstringThe csrf_token from GET /auth/session. Required on every session-authenticated mutation.
example Kk3jVq0bYw2p0mT1aZc9sD8eF4gH6iJ7nL5oP2rQ1uA

Request body

application/json, required: object

FieldTypeRequiredDescription
domainstringYesThe exact hostname; normalized like the directory's own names (case, trailing dot, IDNA).
at most 1012 characters
organization_idOrganizationIdYes

Example

json
{
  "domain": "payments.example.com",
  "organization_id": "org_7k3m9q2x8d5f1h4j6n0p2r4t8v"
}

Responses

StatusDescriptionBody
200The domain is already listed.application/json object
202The check is running; poll the job.application/json object
400invalid_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
401unauthenticated (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
403forbidden. 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
404not_found. No such resource, or one the caller may not see (the two are indistinguishable by design).application/json Error
409conflict. 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
422semantic_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
429rate_limited. A per-client or per-record limit was reached; retry later.application/json Error
503unavailable (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

json
{
  "status": "present",
  "domain": "payments.example.com",
  "record": "/v1/domains/payments.example.com"
}

202 example

json
{
  "status": "pending",
  "domain": "payments.example.com",
  "enrollment_id": "enr_5b8e1h4k7n0q3t6w9z2c5f8j1m",
  "job_id": "job_2d5g8k1n4q7t0w3z6c9f2h5j8m",
  "poll": "/v1/jobs/job_2d5g8k1n4q7t0w3z6c9f2h5j8m"
}

400 example

json
{
  "code": "invalid_request",
  "message": "Invalid organization.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w",
  "field_errors": [
    {
      "field": "legal_name",
      "message": "must be 1-200 characters"
    }
  ]
}

401 unauthenticated

json
{
  "code": "unauthenticated",
  "message": "Sign in required.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}

401 step_up_required

json
{
  "code": "step_up_required",
  "message": "Recent passkey verification required for this action.",
  "request_id": "req_5a8c1f4h7k0m3p6s9v2x5z8b1d"
}

403 example

json
{
  "code": "forbidden",
  "message": "Your role does not permit this action.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}

404 example

json
{
  "code": "not_found",
  "message": "No such resource.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}

409 example

json
{
  "code": "conflict",
  "message": "The record changed since you read it; reload and retry with the current version.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}

422 example

json
{
  "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

json
{
  "code": "rate_limited",
  "message": "Too many requests.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}

503 example

json
{
  "code": "unavailable",
  "message": "Temporarily unavailable.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}

Get one domain

GET/v1/domains/{domain}

Authentication
Public, no credentials
Operation ID
getDomain

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

NameInRequiredTypeDescription
domainpathYesstringA hostname. It is canonicalized (case, trailing dot, IDNA) before lookup.
at most 253 characters
example payments.example.com

Responses

StatusDescriptionBody
200The domain record.
Headers: Cache-Control: public, max-age=60
application/json DomainDetail
400invalid_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
404not_found. No such resource, or one the caller may not see (the two are indistinguishable by design).application/json Error
503unavailable (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

json
{
  "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

json
{
  "code": "invalid_request",
  "message": "Invalid organization.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w",
  "field_errors": [
    {
      "field": "legal_name",
      "message": "must be 1-200 characters"
    }
  ]
}

404 example

json
{
  "code": "not_found",
  "message": "No such resource.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}

503 example

json
{
  "code": "unavailable",
  "message": "Temporarily unavailable.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}

Seed sources, import counts and observation coverage

GET/v1/sources

Authentication
Public, no credentials
Operation ID
listSources

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

StatusDescriptionBody
200Snapshots, newest first.
Headers: Cache-Control
application/json SourceList
503unavailable (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

json
{
  "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

json
{
  "code": "unavailable",
  "message": "Temporarily unavailable.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}

Public organization detail

GET/v1/organizations/{id}/public

Authentication
Public, no credentials
Operation ID
getPublicOrganization

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

NameInRequiredTypeDescription
idpathYesstringpattern ^org_[a-z0-9]+$
example org_2k5n8r1v4y7b0e3h6m9q2t5x8a

Responses

StatusDescriptionBody
200The organization's public record.
Headers: Cache-Control
application/json PublicOrganization
404not_found. No such resource, or one the caller may not see (the two are indistinguishable by design).application/json Error
503unavailable (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

json
{
  "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

json
{
  "code": "not_found",
  "message": "No such resource.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}

503 example

json
{
  "code": "unavailable",
  "message": "Temporarily unavailable.",
  "request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w"
}