Public API Documentation

Detailed request and response examples for every tenant-scoped public endpoint. Back to home.

Public API

The public API provides tenant-scoped endpoints for managing domains, contacts, hosts, OAuth clients, memberships, and related resources. It also includes unauthenticated endpoints for OAuth token management, invitation acceptance, and contact verification.

Errors

Every non-2xx response uses the canonical envelope:

{
 "error": "<machine-readable code>",
 "detail": { "..optional structured context.." },
 "details": { "<field>": ["<message>",..] }
}

Status code conventions

Status When Retryable?
400 Malformed request body, missing required header, malformed Idempotency-Key No
401 / 403 Auth failure, insufficient scope No (until creds change)
404 Resource not found within the caller’s scope No
409 Precondition violated by current state (e.g., wrong_state, idempotency_key_in_use) Caller must change state
422 Validation or business-rule failure No
429 Rate-limited Yes, after Retry-After
500 Server bug Yes with backoff
502 / 503 / 504 Upstream registry failure (gateway error) Yes (Idempotency-Key recommended)

5xx errors split into two operationally distinct classes:

Both are retryable for clients with a generic retry-on-5xx policy.

Idempotency

Mutating endpoints (POST, PATCH, DELETE) accept an optional Idempotency-Key header so retries are safe to send.

Idempotency-Key: <1–200 chars of [A-Za-z0-9_\-:.+]>
Replay scenario Result
Same key, same body The cached response is returned; Idempotent-Replayed: true
Same key, in-flight original 409 idempotency_key_in_use with detail.in_flight: true; retry after a brief delay
Same key, different body 409 idempotency_key_in_use (the server detected conflicting reuse)
Malformed key 400 malformed_idempotency_key
5xx on the original attempt Not cached — the next attempt re-executes

The response includes Idempotent-Replayed: false on the original execution and true on a replay.

Health Endpoints

Unauthenticated, unbilled, no rate limit. Suitable for load-balancer probes.

Endpoint Purpose
GET /healthz Liveness. Returns 200 OK whenever the web process is up.
GET /health Readiness. Reports DB connectivity and a summary of registry circuit-breaker states. Returns 503 when the DB ping fails. The status field is "ok", "degraded", or "fail".

Authentication

OAuth 2.0 Bearer Tokens

Most endpoints require an OAuth 2.0 bearer token in the Authorization header:

Authorization: Bearer <access_token>

The token is a JWT signed with HS256. On each request, the service:

  1. Extracts the token from the Authorization: Bearer <token> header.
  2. Verifies the JWT signature and expiration using the configured secret.
  3. Looks up the token’s jti (JWT ID) in the oauth_tokens table to check for revocation.
  4. Assigns the token’s claims (including sub as the current user ID and tenant_id) to the request context.

If the token is missing, invalid, expired, or revoked, the service returns 401 Unauthorized:

{
  "error": "unauthorized"
}

Tenant Isolation

All endpoints under /api/v1/tenants/:tenant_id enforce tenant isolation after bearer token validation:

  1. The tenant_id in the URL must match the tenant_id claim in the bearer token.
  2. The tenant must exist and not be suspended.
  3. The authenticated user must have an active membership in the tenant.

If any check fails, the service returns 403 Forbidden with one of:

Obtaining Tokens

Tokens are obtained through the OAuth endpoints (documented below), which do not require a bearer token themselves.

OAuth Endpoints

These endpoints are unauthenticated and handle token lifecycle.

Authorize (Authorization Code flow)

GET /oauth/authorize

Initiates the Authorization Code flow with PKCE.

Query parameters:

Parameter Required Description
response_type yes Must be "code".
client_id yes The registered OAuth client ID.
redirect_uri yes Must exactly match a registered redirect URI.
code_challenge yes PKCE code challenge (S256).
code_challenge_method yes Must be "S256".
scope no Space-separated list of requested scopes.
tenant_id no Tenant context for the authorization.
state no Opaque value passed through to the response.

Response: 200 OK

{
  "data": {
    "code": "authorization-code",
    "state": "passed-through-value"
  }
}

The authorization code is single-use and expires after 10 minutes.


Exchange Token

POST /oauth/token

Exchanges credentials for access (and optionally refresh) tokens. The behavior depends on the grant_type parameter.

grant_type: authorization_code

Exchanges an authorization code for tokens.

Request body:

Field Required Description
grant_type yes "authorization_code"
code yes The authorization code from the authorize step.
code_verifier yes PKCE code verifier (plaintext that hashes to the original challenge).
redirect_uri yes Must match the redirect URI used during authorization.

grant_type: client_credentials

Authenticates directly with client credentials.

Request body:

Field Required Description
grant_type yes "client_credentials"
client_id yes The registered OAuth client ID.
client_secret yes The client secret (for confidential clients).
scope no Space-separated list of requested scopes.

grant_type: refresh_token

Exchanges a refresh token for a new token pair.

Request body:

Field Required Description
grant_type yes "refresh_token"
refresh_token yes A valid, non-revoked refresh token.

Token response

All successful grant types return:

{
  "access_token": "jwt-access-token",
  "token_type": "Bearer"
}

If the client is configured with a refresh_token_ttl_seconds, the response also includes:

{
  "access_token": "jwt-access-token",
  "refresh_token": "jwt-refresh-token",
  "token_type": "Bearer"
}

Errors


Revoke Token

POST /oauth/revoke

Revokes an access or refresh token.

Request body:

Field Required Description
token yes The JWT token to revoke.

Response: 200 OK (empty body). Always succeeds, even if the token is already revoked or unknown (per RFC 7009).


Introspect Token

POST /oauth/introspect

RFC 7662 token introspection. Used by resource servers (e.g. the MCP gateway) to validate bearer tokens without having to verify JWT signatures locally.

Request body (application/json or application/x-www-form-urlencoded):

Field Required Description
token yes The token value to introspect.

Response (active token): 200 OK

{
  "active": true,
  "client_id": "<oauth_client uuid>",
  "exp": 1712000000,
  "iat": 1711996400,
  "scope": "space separated scopes",
  "sub": "<user uuid or null>",
  "tenant_id": "<tenant uuid>",
  "token_type": "Bearer"
}

Response (inactive/invalid/unknown): 200 OK

{
  "active": false
}

Register Client (Dynamic Client Registration)

POST /oauth/register

RFC 7591 Dynamic Client Registration. Disabled by default; enable via config :registrar, Registrar.Auth.DCR, enabled: true. All dynamically registered clients are public (no client_secret), restricted to authorization_code + PKCE, and tenant-less — the tenant for each issued token is selected at authorize time via the tenant_id query param on GET /oauth/authorize.

Request body:

Field Required Description
redirect_uris yes Array of redirect URIs.
client_name no Human-readable name (default: “Dynamically registered client”).
scope no Space-separated requested scopes.

Response: 201 Created

{
  "client_id": "<new client_id>",
  "client_name": "..",
  "client_type": "public",
  "grant_types": [
    "authorization_code",
    "refresh_token"
  ],
  "redirect_uris": [
    "http://127.0.0.1:51351/callback"
  ],
  "response_types": [
    "code"
  ],
  "scope": "",
  "token_endpoint_auth_method": "none"
}

Errors:


Discovery Metadata

Authorization Server Metadata

GET /.well-known/oauth-authorization-server

RFC 8414 metadata describing the OAuth endpoints. registration_endpoint only appears when DCR is enabled. No authentication required.


Unauthenticated Endpoints

These endpoints do not require a bearer token.

Accept Invitation

POST /api/v1/invitations/:token/accept

Accepts a pending invitation using the invitation token (received via email or, in test environments, via the operator API).

Response: 200 OK

{
  "data": {
    "message": "invitation_accepted",
    "user_id": "uuid"
  }
}

Verify Contact

POST /api/v1/tenants/:tenant_id/contacts/:handle/verify

Submits a verification challenge response for a contact. The registrant receives the challenge token via email or SMS and submits it here without needing a bearer token.

Request body:

Field Required Description
channel yes The verification channel (e.g., "email", "sms").
token yes The verification token received via that channel.

Response: 200 OK with the updated contact object.


User Email Verification

Complete verification

POST /api/v1/users/verify

Unauthenticated. Consumes a challenge token issued by the operator resend endpoint and marks the user’s email as verified.

For human users clicking an email link, the registrar also hosts an HTML form at GET /verify/user?token=<token> which renders a confirmation page and POSTs here on submit.

Request body:

{
  "token": "<opaque-challenge-token>"
}

Response: 200 OK

{
  "data": {
    "user": {
      "email": "user@example.com",
      "email_verified_at": "2026-04-24T12:00:00Z",
      "id": "uuid"
    }
  }
}

Errors:


Password Reset

Self-service flow for users who have forgotten their password. Tokens are 256-bit random, single-use, expire after 24 hours, and only SHA-256(token) is persisted server-side. Successful completion revokes every active OAuth session for the user across all tenants. TOTP enrollment is preserved.

Request reset

POST /api/v1/users/reset_password

Unauthenticated. Body: { "email": "user@example.com" }. The response is identical whether or not the email matches a user, both in shape and approximate timing — clients cannot use this endpoint to enumerate registered emails.

Response: 200 OK with { "status": "ok" }.

Errors: 400 invalid_email_format for syntactically bad input.

For human users clicking an emailed link, the registrar also hosts an HTML form at GET /reset-password?token=<token> which renders the new-password form and POSTs through to the completion endpoint below.

Complete reset

POST /api/v1/users/reset_password/complete

Unauthenticated. Body: { "token": "<opaque>", "password": "<new>" }. Validates the token (exists, not consumed, not expired) and the password (12-char minimum), updates the user, consumes the token, and revokes every active session.

Response: 200 OK with { "data": { "user": { "id": "uuid" } } }.

Errors:


Current User Endpoints

Authenticated endpoints under /api/v1/user that are scoped to the bearer token’s user (the sub claim) rather than to a single tenant. The URL carries no :tenant_id and the token’s tenant_id claim is not checked.

List my tenants

GET /api/v1/user/tenants

Returns every tenant the authenticated user is currently an active member of. Suspended tenants and deactivated memberships are excluded.

Response: 200 OK

{
  "data": [
    {
      "id": "uuid",
      "inserted_at": "2025-01-01T00:00:00Z",
      "membership_state": "active",
      "name": "Acme Co",
      "role": "owner",
      "slug": "acme",
      "status": "active",
      "updated_at": "2025-01-01T00:00:00Z"
    }
  ]
}

TOTP enrollment and management

All four endpoints are bearer-authenticated; mutating verbs require the write scope and the GET requires read.

GET /api/v1/user/totp
POST /api/v1/user/totp/enrollments
POST /api/v1/user/totp/enrollments/confirm
DELETE /api/v1/user/totp

GET /api/v1/user/totp returns:

{
 "data": {
 "status": "not_enrolled" | "pending" | "enrolled",
 "enrolled_at": "2026-04-24T19:23:33Z" | null,
 "recovery_codes_remaining": 10
 }
}

POST /api/v1/user/totp/enrollments is idempotent on a pending user; 409 already_enrolled once enrolled. Returns { "data": { "secret": "BASE32", "otpauth_uri": "otpauth://totp/.." } } (status 201).

POST /api/v1/user/totp/enrollments/confirm body { "code": "123456" } flips the user to enrolled on success and returns { "data": { "recovery_codes": [10 strings] } }. The recovery codes are shown once.

DELETE /api/v1/user/totp body { "code": "123456" } (or { "recovery_code": ".." }) clears the enrollment and returns 204. The caller must prove possession of a current TOTP or recovery code.


Tenant-Scoped Endpoints

All endpoints below require a valid bearer token and are scoped to a tenant at /api/v1/tenants/:tenant_id. See the Authentication section above for details on how tenant isolation is enforced.

Tenant self-update

PATCH /api/v1/tenants/:tenant_id
PUT /api/v1/tenants/:tenant_id

Updates mutable tenant attributes. Requires the caller to be an owner or admin of the tenant.

Request body (all fields optional):

Field Description
name Human-readable tenant name.
verification_from_email / suspension_from_email / renewal_from_email Per-notification From addresses.
verification_from_phone / suspension_from_phone E.164 From numbers for SMS notifications.
verification_url_template URL template with {{token}} placeholder for clickable verification links.
transfer_out_mode "auto" (default) or "explicit". Controls handling of pending outbound transfers per § Transfer-Out Mode.

Response: 200 OK with the updated tenant object, including transfer_out_mode.

Errors:

Status Code Meaning
403 forbidden Caller is not an owner/admin.
422 unprocessable_entity Validation failure (e.g. transfer_out_mode not in ["auto","explicit"], malformed from-email, etc.).

Tenant TLD-association reads

GET /api/v1/tenants/:tenant_id/tld_associations
GET /api/v1/tenants/:tenant_id/tld_associations/:id_or_tld

Read-only listing and lookup of the TLD associations bound to the calling tenant. Available to any tenant member; no elevated role is required. Write surfaces (create / delete / config / rebind / credentials / certificate) remain operator-only.

List filters (query string, all optional):

Field Description
state Match associations in this state (e.g. active).
accreditation_type icann_accredited or non_icann.
registry_account_id Match associations bound to the given account.

Show identifier: the URL segment may be either the association id (UUID) or the TLD label (e.g. com, co.uk). The TLD lookup is exact and tenant-scoped — associations belonging to a different tenant return 404.

Response: 200 OK with the same projection emitted by the operator endpoint:

{
 "data": {
 "id": "uuid",
 "tenant_id": "uuid",
 "tld": "com",
 "accreditation_type": "icann_accredited",
 "state": "active",
 "registry_account_id": "uuid",
 "inserted_at": "2025-01-01T00:00:00Z",
 "updated_at": "2025-01-01T00:00:00Z",
 "config": { /* policy + notification settings */ }
 }
}

The list endpoint returns data as an array of these objects.

EPP credentials and certificate material are never included in either response.

Errors:

Status Code Meaning
403 forbidden Token does not match the path tenant.
404 not_found Identifier does not resolve to an association on this tenant.

Tenant TLD-association notification settings

PATCH /api/v1/tenants/:tenant_id/tld_associations/:id/notifications

Updates per-association notification settings. Requires the caller to be an owner or admin of the tenant.

Request body (all fields optional):

Field Description
low_balance_notification_email Email address that receives registry low-balance warning + critical alerts for this association. Set to "" (empty string) to clear.

Response: 200 OK with the updated association object including its config map. The config map echoes the new field plus the per-association credit-snapshot fields the dispatcher writes from the registry’s <lowbalance-poll> messages: last_low_balance_at, last_low_balance_warning_at, last_low_balance_critical_at, current_credit, current_credit_limit, current_credit_threshold, current_credit_at.

Errors:

Status Code Meaning
403 forbidden Caller is not an owner/admin.
404 not_found Association id does not belong to the tenant.
422 unprocessable_entity low_balance_notification_email is malformed.

Members

List members

GET /api/v1/tenants/:tenant_id/members

Returns all memberships for the tenant.

Response: 200 OK

{
  "data": [
    {
      "id": "uuid",
      "inserted_at": "2025-01-01T00:00:00Z",
      "role": "owner",
      "state": "active",
      "tenant_id": "uuid",
      "updated_at": "2025-01-01T00:00:00Z",
      "user_id": "uuid"
    }
  ]
}

Get member

GET /api/v1/tenants/:tenant_id/members/:user_id

Returns a single membership.

Response: 200 OK with membership object, or 404 Not Found.


Remove member

DELETE /api/v1/tenants/:tenant_id/members/:user_id

Removes a user’s membership from the tenant.

Response: 204 No Content


Update member role

PUT /api/v1/tenants/:tenant_id/members/:user_id/role

Changes a member’s role.

Request body:

Field Required Description
role yes The new role (e.g., "owner", "admin", "member").

Response: 200 OK with updated membership object.


Activate member

PUT /api/v1/tenants/:tenant_id/members/:user_id/activate

Reactivates a deactivated membership.

Response: 200 OK with updated membership object.


Deactivate member

PUT /api/v1/tenants/:tenant_id/members/:user_id/deactivate

Deactivates a membership. The member retains their record but cannot access tenant resources.

Response: 200 OK with updated membership object.


Invitations

List invitations

GET /api/v1/tenants/:tenant_id/invitations

Returns all invitations for the tenant.

Response: 200 OK

{
  "data": [
    {
      "accepted_at": null,
      "email": "user@example.com",
      "expires_at": "2025-01-08T00:00:00Z",
      "id": "uuid",
      "inserted_at": "2025-01-01T00:00:00Z",
      "role": "member",
      "state": "pending",
      "tenant_id": "uuid"
    }
  ]
}

Create invitation

POST /api/v1/tenants/:tenant_id/invitations

Invites a user to the tenant. The invitation token is sent via email (not returned in the response).

Request body:

Field Required Description
email yes Email address of the invitee.
role yes Role to assign on acceptance.

Response: 201 Created with the invitation object (without the token).


Revoke invitation

DELETE /api/v1/tenants/:tenant_id/invitations/:id

Revokes a pending invitation.

Response: 204 No Content


OAuth Clients

List OAuth clients

GET /api/v1/tenants/:tenant_id/oauth_clients

Returns all registered OAuth clients for the tenant.

Response: 200 OK

{
  "data": [
    {
      "access_token_ttl_seconds": 3600,
      "allowed_flows": [
        "client_credentials"
      ],
      "allowed_scopes": [
        "read",
        "write"
      ],
      "client_id": "hex-encoded-id",
      "client_type": "confidential",
      "id": "uuid",
      "inserted_at": "2025-01-01T00:00:00Z",
      "name": "my-integration",
      "redirect_uris": [],
      "tenant_id": "uuid"
    }
  ]
}

Get OAuth client

GET /api/v1/tenants/:tenant_id/oauth_clients/:id

Returns a single OAuth client.

Response: 200 OK with client object, or 404 Not Found.


Register OAuth client

POST /api/v1/tenants/:tenant_id/oauth_clients

Registers a new OAuth client within the tenant.

Request body:

Field Type Required Description
name string yes Client name.
client_type string yes "confidential" or "public".
allowed_flows array yes Subset of ["authorization_code", "client_credentials"].
allowed_scopes array yes Scopes the client may request.
redirect_uris array conditional Required if authorization_code is in allowed_flows.
access_token_ttl_seconds integer no Access token lifetime in seconds. Defaults to 3600.
refresh_token_ttl_seconds integer no If set, refresh tokens are issued with this lifetime.

Response: 201 Created

The response includes a client_secret field (for confidential clients) that is only shown once. It is stored as an Argon2 hash and cannot be retrieved later.


Revoke OAuth client

DELETE /api/v1/tenants/:tenant_id/oauth_clients/:id

Revokes an OAuth client and invalidates all of its active tokens.

Response: 204 No Content


Rotate client secret

POST /api/v1/tenants/:tenant_id/oauth_clients/:id/rotate_secret

Generates a new client secret for a confidential OAuth client. The old secret is immediately invalidated.

Response: 200 OK

{
  "data": {
    "client_id": "hex-encoded-id",
    "client_secret": "new-plaintext-secret-shown-once"
  }
}

Returns an error if the client is public (no secret to rotate).


Sessions

List sessions

GET /api/v1/tenants/:tenant_id/sessions

Returns active sessions for the current user in this tenant.

Response: 200 OK

{
  "data": [
    {
      "expires_at": "2025-01-02T00:00:00Z",
      "id": "uuid",
      "inserted_at": "2025-01-01T00:00:00Z",
      "ip_address": "127.0.0.1",
      "user_agent": "Mozilla/5.0.."
    }
  ]
}

Revoke session

DELETE /api/v1/tenants/:tenant_id/sessions/:id

Revokes a specific session.

Response: 204 No Content


Registry Client Certificate

Per-RegistryAccount client TLS certificate used for EPP mTLS against the registry (plans 127, 162). The certificate and its private key are both encrypted at rest; the private key is write-only and is never returned by the API. Tenants can retrieve parsed metadata (expiry, subject, algorithms, fingerprint) and the public certificate PEM.

The legacy tld_associations/:id/certificate paths continue to work one release as a back-compat shim that resolves the bound account.

Retrieve certificate metadata

GET /api/v1/tenants/:tenant_id/registry_accounts/:id/certificate

Response:

{
  "data": {
    "cert_pem": "-----BEGIN CERTIFICATE-----\n..",
    "fingerprint_sha256": "abcdef..",
    "id": "..",
    "issuer": "CN=Registrar CA",
    "key_size": 2048,
    "not_after": "2027-04-22T00:00:00.000000Z",
    "not_before": "2026-04-22T00:00:00.000000Z",
    "public_key_algorithm": "RSA",
    "registry_account_id": "..",
    "rotated_at": "2026-04-22T00:00:00.000000Z",
    "serial_number": "1a2b3c..",
    "signature_algorithm": "sha256WithRSAEncryption",
    "subject": "CN=registrar.example,O=Example Co,C=US"
  }
}

Returns 404 Not Found when no certificate is configured.

Upload or rotate certificate

PUT /api/v1/tenants/:tenant_id/registry_accounts/:id/certificate

Body:

{
  "cert_pem": "-----BEGIN CERTIFICATE-----\n..",
  "key_pem": "-----BEGIN PRIVATE KEY-----\n.."
}

The server parses both PEMs, verifies the private key matches the certificate, and stores both encrypted at rest. RSA and EC keys are supported.

On success returns 200 OK with the same shape as GET.

Delete certificate

DELETE /api/v1/tenants/:tenant_id/registry_accounts/:id/certificate

Removes the certificate. EPP connections that authenticate as this account will then fail until a new certificate is uploaded. Returns 204 No Content (idempotent).

Legacy TLD-association paths

GET /api/v1/tenants/:tenant_id/tld_associations/:id/certificate
PUT /api/v1/tenants/:tenant_id/tld_associations/:id/certificate
DELETE /api/v1/tenants/:tenant_id/tld_associations/:id/certificate

Continue to work and operate on the certificate bound to the TLD association’s registry account. Prefer the registry_accounts/:id/certificate paths for new integrations.


Registry Accounts

Tenant-scoped read access to registry accounts plus the ability to set or clear the default account for a registry binding. Credential mutation (create / rotate / disable / delete) remains operator-only.

List registry accounts

GET /api/v1/tenants/:tenant_id/registry_accounts

Optional query parameters: registry_binding, state. Response items expose id, tenant_id, registry_binding, name, state, is_default, rotated_at, inserted_at, updated_at. Credential material is never returned.

Set / clear default registry account

POST /api/v1/tenants/:tenant_id/registry_accounts/:id/set_default
POST /api/v1/tenants/:tenant_id/registry_accounts/:id/clear_default

Toggles the per-(tenant_id, registry_binding) default flag). When more than one active candidate exists for a binding and the caller omits registry_account_id, the default is used. set_default rejects with 422 registry_account_unavailable when the account is disabled.


Domains

Check domain availability

POST /api/v1/tenants/:tenant_id/domains/check

Checks whether one or more domain names are available for registration. The same endpoint handles single-name and multi-name (multi-TLD) input; multi-name input fans out per registry binding.

Request body:

Field Required Description
name one of Single fully qualified domain name (e.g., "example.com").
names one of Non-empty list of fully qualified domain names; may span multiple TLDs and registry bindings. Mutually exclusive with name.
idn_language no IDN language/script tag forwarded to the registry (required when any submitted name contains non-ASCII labels and the registry requires a tag, e.g. Verisign .com / .net). Applied uniformly to every submitted name.
fee_commands no List of fee-extension commands to disclose. Subset of "create", "renew", "transfer", "restore".
fee_period no Period (positive integer, years) applied to the requested fee commands. Defaults to 1. Ignored for restore.
client_transaction_id no Caller-supplied identifier echoed in the response envelope under client_transaction_id.

registry_account_id is not accepted on this endpoint. Check fans out across bindings and resolves each binding’s account from the ; when more than one active account exists for a binding, set a default with POST /registry_accounts/:id/set_default.

Response (single-name form, name): 200 OK

{
  "data": {
    "client_transaction_id": "caller-trid-001",
    "fees": [
      {
        "amount": "9.99",
        "command": "create",
        "currency": "USD",
        "period_years": 1,
        "refundable": null
      }
    ],
    "name": "example.com",
    "status": "available"
  }
}

Response (multi-name form, names): 200 OK — one result per submitted name, in submitted order:

{
  "data": {
    "results": [
      {
        "client_transaction_id": null,
        "fees": [],
        "name": "example.com",
        "status": "available"
      },
      {
        "client_transaction_id": null,
        "fees": [],
        "name": "example.io",
        "status": "unavailable"
      }
    ]
  }
}

For premium results the entry also includes "premium": { "marker": ".." }.

When a single binding’s registry call fails the names in that binding’s group return status: "error"; other bindings’ results are unaffected.


List domains

GET /api/v1/tenants/:tenant_id/domains

Returns all domains for the tenant. Supports filtering and pagination via query parameters.

Query parameters:

If both cursor and offset are supplied, cursor wins.

Response (cursor mode): 200 OK

{
 "data": [..],
 "pagination": { "next_cursor": "opaque-token-or-null", "has_more": true }
}

Response (offset mode): 200 OK

{
 "data": [..],
 "pagination": { "offset": 0, "limit": 100, "total": 237, "has_more": true }
}

Each item in data has the shape:

{
  "acceptance": {
    "acceptance_method": "api",
    "accepted_at": "2025-01-01T00:00:00Z",
    "agreement_version_id": "v_abc123",
    "reacceptance_required": false,
    "redemption_fee_disclosure": {
      "amount": "79.0000",
      "currency": "USD"
    },
    "state": "active",
    "version_effective_at": "2025-01-01T00:00:00Z"
  },
  "auto_renewal": {
    "enabled": true,
    "grace_period_days": 30,
    "mode": "AUTORENEW",
    "opt_out_url": null,
    "renewal_fee": {
      "amount": "9.99",
      "currency": "USD"
    }
  },
  "deletion_scheduled_at": null,
  "epp_statuses": [
    "ok"
  ],
  "expires_at": "2026-01-01T00:00:00Z",
  "hold_reason": null,
  "id": "uuid",
  "inserted_at": "2025-01-01T00:00:00Z",
  "last_reconciled_at": null,
  "last_renewed_at": null,
  "name": "example.com",
  "nameservers": [
    "ns1.example.com"
  ],
  "out_of_sync": false,
  "registered_at": "2025-01-01T00:00:00Z",
  "registry_account": {
    "id": "uuid",
    "name": "primary",
    "registry_binding": "verisign"
  },
  "registry_account_id": "uuid",
  "state": "registered",
  "tenant_id": "uuid",
  "tld": "com",
  "transfer_lock_until": null,
  "updated_at": "2025-01-01T00:00:00Z"
}

The acceptance object is a condensed view of the domain’s current Registrant Agreement acceptance. It is null when no active acceptance exists (for example, immediately after termination). The full evidence bundle (including source_ip, user_agent, content_hash, and termination history) remains available at the dedicated /agreements/acceptance/:domain_name endpoint. When reacceptance_required is true, a material-change agreement version has been published since the domain’s acceptance; the registrant must re-accept to satisfy [RAA §3.7.7].

acceptance.redemption_fee_disclosure carries the redemption grace-period recovery fee figure shown to the registrant at registration time per [RAA §3.7.5.6]. It is recorded immutably on the Acceptance Record. The field is null for non-ICANN TLDs (which are exempt from the disclosure obligation) and for acceptances that pre-date this disclosure being captured.

The auto_renewal object is the auto-renewal grace period disclosure required by [RAA §3.7.5.1]. mode is the per-domain override (one of DEFAULT, AUTORENEW, AUTOEXPIRE, RENEWONCE, AUTODELETE); enabled is the boolean projection of the resolved mode (per-domain override when set, otherwise the TLD-wide default) and is retained for backwards compatibility. When enabled is true, grace_period_days is the number of days after expiration during which the domain may still be renewed at the standard renewal price, and renewal_fee is the quoted fee. When enabled is false, the TLD does not auto-renew and renewal_fee is null. opt_out_url is null today and reserved for a future opt-out flow; the key is always present so clients can bind to the shape.

last_renewed_at is set on every successful caller-initiated renew and on every auto-renewal acknowledged from the registry. It is null for domains that have never been renewed since registration. The field is also a sortable key (order_by=last_renewed_at) and a filter base (renewed_within_days).


Get domain

GET /api/v1/tenants/:tenant_id/domains/:name

Returns a single domain by its fully qualified name.

Response: 200 OK with domain object, or 404 Not Found.


Register domain

POST /api/v1/tenants/:tenant_id/domains

Registers a new domain. The request may include an acceptance object for the registrant agreement.

Request body: Domain registration parameters (fully qualified name, contact handles, etc.) plus an optional acceptance block. The service automatically enriches the acceptance with the request’s source IP and user agent.

Payment assurance ([RAA §3.7.4]): for ICANN-accredited TLDs the request must carry a quote_id referencing a quote previously issued by POST /quotes for the same (fqdn, operation = "register", period = years, tier, premium_class). The quote is single-use; reuse returns 422 quote_consumed. For non-ICANN TLDs the legacy boolean payment_assured: true is still accepted.

Field Required Description
quote_id conditional Required for ICANN-accredited TLDs. UUID of the quote returned by POST /quotes.
payment_assured conditional Legacy boolean shortcut, accepted for non-ICANN TLDs only.
registry_account_id conditional Required when the tenant has more than one active RegistryAccount for the TLD’s registry binding; with exactly one matching account the registrar selects it implicitly. The chosen account is persisted on the domain row and binds every subsequent EPP command on it. § Selector Rule.
idn_language conditional IDN language/script tag forwarded to the registry’s IDN extension. Required when name carries non-ASCII labels and the target registry requires a tag (Verisign .com / .net).

Nameservers: the optional nameservers field takes a list of host FQDNs. External names are auto-provisioned as host rows on the fly (plan 129); subordinate names (under a domain this tenant already manages) must already exist as active hosts so the operator can supply their IPs explicitly.

Response: 201 Created with the domain object.

Payment-assurance errors (422): quote_required, quote_not_found, quote_consumed, quote_expired, quote_mismatch, payment_not_assured.

Registry-account errors (422): registry_account_required (response carries meta.candidates: [{id, name},..]), registry_binding_mismatch, registry_account_unavailable.

Nameserver errors (422): invalid_nameservers, duplicate_nameserver, subordinate_host_missing (with the FQDN in detail).


Reconcile domain with registry

POST /api/v1/tenants/:tenant_id/domains/:name/reconcile

Pulls the registry’s current view of the domain via EPP domain:info and writes the drift-prone fields onto the local row (plan 130). Per-field behaviour:

Field Reconciled? Notes
nameservers yes Same auto-provision path as update_nameservers — unknown external names become host rows, unknown subordinates error.
epp_statuses yes Registry view wins.
expires_at yes Registry view wins.
registrant_handle / admin / tech / billing no Tenant-authoritative; registry handles are opaque and not mapped to tenant contacts.
auth_code_hash no Reconcile observes; it doesn’t rotate.

A reconciled_with_registry domain event is emitted carrying the per-field diff when anything changed; a no-op reconcile emits no event.

Response: 200 OK with the updated domain object.

Error cases (422):

Returns 404 Not Found when no local row exists for :name.


Renew domain

POST /api/v1/tenants/:tenant_id/domains/:name/renew

Renews a registered domain.

Request body:

Field Required Description
years no Number of years to renew. Defaults to 1.
quote_id conditional Required for ICANN-accredited TLDs. UUID of a POST /quotes quote where operation = "renew" and period = years.

Response: 200 OK with the updated domain object.

Payment-assurance errors (422): quote_required, quote_not_found, quote_consumed, quote_expired, quote_mismatch.


Update domain nameservers

POST /api/v1/tenants/:tenant_id/domains/:name/update_nameservers

Replaces the nameserver set on a registered domain. The request is a full-set replacement, not a delta. An empty-diff call (target set equal to the current set) is an idempotent no-op that does not contact the registry.

Request body:

Field Required Description
nameservers yes Target set of host names (FQDNs). External names that don’t yet have a Host row are auto-provisioned on the fly (no IPs); subordinate names — those under a domain this tenant manages — must already exist as active hosts in the tenant (see /hosts) because they require operator-supplied IPs.

Response: 200 OK with the updated domain object.

Error cases: 422 for a missing subordinate host (subordinate_host_missing, with the FQDN in detail), duplicates in the target set (duplicate_nameserver), invalid syntax (invalid_nameservers), empty set (empty_nameservers), wrong state (wrong_state), or clientUpdateProhibited / serverUpdateProhibited set (update_prohibited).


Replace DS records

POST /api/v1/tenants/:tenant_id/domains/:name/update_ds_records

Replaces the DS-record set on a registered or on_hold domain. Full-set replacement; an empty-diff call is an idempotent no-op.

Request body:

Field Required Description
ds_records yes Array of DS-record objects. Each object: key_tag (0–65535), algorithm (0–255), digest_type (0–255), digest (hex), optional max_sig_life (seconds).

Response: 200 OK with the updated domain object, including the new ds_records array and dnssec_signed boolean.

Error cases: 422 when the tenant’s TLD has dnssec_supported=false (tld_not_dnssec), when any DS record is malformed (invalid_ds_records), when the submitted set contains duplicate tuples (duplicate_ds_record), when records carry differing non-nil max_sig_life (inconsistent_max_sig_life), wrong state (wrong_state), or clientUpdateProhibited / serverUpdateProhibited set (update_prohibited).


Remove DS records

DELETE /api/v1/tenants/:tenant_id/domains/:name/ds_records

Removes every DS record from a domain (RFC 5910 <secDNS:rem><secDNS:all>true</…>). Equivalent to submitting an empty ds_records array to update_ds_records.

Response: 200 OK with the updated domain object (ds_records: [], dnssec_signed: false).

Error cases: Same state-gate errors as Replace DS records.


Update domain contacts

POST /api/v1/tenants/:tenant_id/domains/:name/update_contacts

Replaces the admin, tech, and/or billing contact handles on a registered or on_hold domain. Registrant changes are not handled here — Change of Registrant is a separate operation (deferred per § Change of Registrant).

Only the supplied handle fields are updated. To clear a role, pass an explicit empty string (""); omitted fields are left unchanged.

Request body:

Field Required Description
admin_handle optional Contact handle to assign as admin contact, or "" to clear.
tech_handle optional Contact handle to assign as tech contact, or "" to clear.
billing_handle optional Contact handle to assign as billing contact, or "" to clear.

Response: 200 OK with the updated domain object.

Error cases: 404 when a supplied handle does not exist in the tenant, 422 for wrong domain state.


Initiate inbound transfer

POST /api/v1/tenants/:tenant_id/domains/:name/transfer_in

Initiates a domain transfer into this registrar.

Request body:

Field Required Description
auth_code yes The authorization/transfer code from the losing registrar.
registrant_handle conditional Contact handle to use as registrant. Required for ICANN-accredited TLDs so the registrar holds WHOIS data the moment sponsorship transfers (per [RAA §3.7.7.1] and the inbound 15-day RDDS verification clock). Optional for non-ICANN TLDs.
admin_handle optional Contact handle to attach as admin contact at transfer time. Unknown handle returns contact_not_found.
tech_handle optional Contact handle to attach as tech contact at transfer time. Unknown handle returns contact_not_found.
billing_handle optional Contact handle to attach as billing contact at transfer time. Unknown handle returns contact_not_found.
years no Transfer-in period (defaults to 1). Must match the quote’s period when supplied.
quote_id conditional Required for ICANN-accredited TLDs. UUID of a POST /quotes quote where operation = "transfer_in".
registry_account_id conditional Required when the tenant has more than one active RegistryAccount for the TLD’s registry binding; with exactly one matching account the registrar selects it implicitly. The chosen account is persisted on the domain row and binds every subsequent EPP command on it. § Selector Rule.

Response: 202 Accepted. The response shape depends on whether a domain row already exists in this tenant for the name:

When the registry confirms, the pending row transitions to completed and a domain row appears (external) or is updated (intra-tenant) in state registered with the 60-day transfer lock applied. On registry NACK or auto-cancel the pending row transitions to rejected / cancelled; the domain row (if any) is unchanged. § Transfer In.

Errors (selected): 422 quote_required, 422 registrant_handle_required (ICANN-accredited TLDs only), 422 auth_code_invalid, 422 domain_within_transfer_lock, 422 registry_account_required (with meta.candidates), 422 registry_binding_mismatch.


Get a single transfer

GET /api/v1/tenants/:tenant_id/transfers/:pending_id

Returns a single inter-registrar transfer row scoped to the tenant. Cross-tenant access returns 404.

Response: 200 OK with the same shape as elements returned by the list endpoint.

Errors:

Status Code Meaning
404 not_found No transfer with that id in this tenant.

Cancel a pending inbound transfer

POST /api/v1/tenants/:tenant_id/transfers/:pending_id/cancel

Cancels a pending inbound transfer before the losing registrar ACKs, per ICANN Transfer Policy §I.A.4. The pending row is marked cancelled; the domain row (if any) is unchanged because nothing was modified at initiation. A transfer_in_cancelled event is emitted when an existing intra-tenant domain row is associated with the transfer.

Response: 204 No Content.

Errors:

Status Code Meaning
404 not_found No pending transfer with that id for this tenant.
422 wrong_state Pending row is not in pending state (already completed/cancelled/rejected).
422 cancel_too_late Registry has already ACK’d the transfer; cancel is no longer possible.
422 wrong_direction Target is an outbound transfer (cancellation is inbound-only).

List inter-registrar transfers

GET /api/v1/tenants/:tenant_id/transfers

Lists the tenant’s inter-registrar transfer attempts, including historical rows. Newest first.

Query parameters (optional):

Param Description
direction Filter by in or out.
state Filter by pending, completed, rejected, cancelled, or expired.

Response: 200 OK with { data: [<transfer>] }. Each element carries id, tenant_id, domain_id, domain_name, direction, state, registry_transfer_id, initiated_at, auto_ack_at, completed_at, nack_reason, explicit_decision, inserted_at, updated_at.


Approve outbound transfer

POST /api/v1/tenants/:tenant_id/domains/:name/approve_transfer_out

Approves a pending outbound transfer.

Response: 200 OK with the updated domain object.


Reject outbound transfer

POST /api/v1/tenants/:tenant_id/domains/:name/reject_transfer_out

Rejects a pending outbound transfer.

Request body:

Field Required Description
reason yes One of the permissible ICANN Transfer Policy §I.C.1 NACK reasons: evidence_of_fraud, udrp_action, court_order, registrant_identity_in_dispute, domain_locked, express_written_objection, payment_not_received.

Response: 200 OK with the updated domain object.

Errors:

Status Code Meaning
422 transfer_nack_reason_required No reason supplied.
422 transfer_nack_reason_invalid reason is not one of the permissible values.

Rotate auth code

POST /api/v1/tenants/:tenant_id/domains/:name/auth_code/rotate

Issues a fresh EPP auth code (authInfo) on a registered domain. The new code is submitted to the registry, stored as a hash on this service, and returned in the response body exactly once. The prior code is invalidated. § Auth Code Lifecycle.

Request body: none.

Response: 200 OK

{
  "data": {
    "auth_code": "A3K7M9QXJ2VW5PRT"
  }
}

The plaintext auth code is not persisted and cannot be retrieved again. The caller is responsible for delivering it to the RNH within five calendar days per ICANN Transfer Policy §I.A.2.

Errors:

Status Code Meaning
404 not_found Domain not found in this tenant.
422 wrong_state Domain is not in the registered state.
422 registry_error Registry rejected the update.

Apply registrant transfer lock

POST /api/v1/tenants/:tenant_id/domains/:name/transfer_lock

Sets clientTransferProhibited on the domain, blocking outbound transfers until released. Distinct from Apply Hold: state remains registered and clientHold is not applied. § Registrant Transfer Lock.

Request body: none.

Response: 200 OK with the updated domain object. Idempotent — applying to an already-locked domain is a no-op.

Errors:

Status Code Meaning
404 not_found Domain not found in this tenant.
422 wrong_state Domain is not in the registered state.

Release registrant transfer lock

DELETE /api/v1/tenants/:tenant_id/domains/:name/transfer_lock

Removes clientTransferProhibited from the domain. Idempotent — releasing a lock that is not set is a no-op.

Response: 200 OK with the updated domain object.

Errors:

Status Code Meaning
404 not_found Domain not found in this tenant.
422 wrong_state Domain is not in the registered state.

Apply update lock

POST /api/v1/tenants/:tenant_id/domains/:name/update_lock

Sets clientUpdateProhibited on the domain. While the lock is set, update_nameservers, update_contacts, replace_ds_records, and remove_ds_records return update_prohibited. § Apply / Release Update Lock.

Request body: none.

Response: 200 OK with the updated domain object. Idempotent.

Errors: same shape as transfer-lock — 404 not_found, 422 wrong_state.


Release update lock

DELETE /api/v1/tenants/:tenant_id/domains/:name/update_lock

Removes clientUpdateProhibited from the domain. Idempotent.

Response: 200 OK with the updated domain object.


Apply delete lock

POST /api/v1/tenants/:tenant_id/domains/:name/delete_lock

Sets clientDeleteProhibited on the domain. While set, DELETE /domains/:name returns delete_prohibited. § Apply / Release Delete Lock.

Request body: none.

Response: 200 OK with the updated domain object. Idempotent.

Errors: 404 not_found, 422 wrong_state.


Release delete lock

DELETE /api/v1/tenants/:tenant_id/domains/:name/delete_lock

Removes clientDeleteProhibited from the domain. Idempotent.

Response: 200 OK with the updated domain object.


Delete domain

DELETE /api/v1/tenants/:tenant_id/domains/:name

Deletes a domain. Rejected with 422 subordinate_hosts_exist if the tenant has any active subordinate host under this domain; delete or rename those hosts first.

Response: 204 No Content


Restore domain

POST /api/v1/tenants/:tenant_id/domains/:name/restore

Restores a deleted domain (during the redemption grace period).

Request body:

Field Required Description
quote_id conditional Required for ICANN-accredited TLDs. UUID of a POST /quotes quote where operation = "restore" (no period).

Response: 200 OK with the updated domain object.

Payment-assurance errors (422): quote_required, quote_not_found, quote_consumed, quote_expired, quote_mismatch.


Apply hold

POST /api/v1/tenants/:tenant_id/domains/:name/apply_hold

Places a hold on a domain.

Request body:

Field Required Description
reason no Hold reason. Defaults to "contact_inaccuracy". Accepted values: "contact_inaccuracy", "dispute".

The reason determines which EPP status set is applied:

release_hold removes whichever status set matches the domain’s current hold_reason.

Response: 200 OK with the updated domain object.


Release hold

POST /api/v1/tenants/:tenant_id/domains/:name/release_hold

Releases a hold on a domain.

Response: 200 OK with the updated domain object.


List domain events

GET /api/v1/tenants/:tenant_id/domains/:name/events

Returns the event history for a domain.

Response: 200 OK

{
  "data": [
    {
      "actor_id": "uuid",
      "actor_type": "user",
      "domain_id": "uuid",
      "event_type": "registered",
      "id": "uuid",
      "inserted_at": "2025-01-01T00:00:00Z",
      "payload": {}
    }
  ]
}

Change of Registrant

Material changes to a domain’s registrant (Name, Organization, or Email under [Transfer Policy §II.A.1]) require two-party confirmation per Non-material changes (address, phone, fax) short-circuit and may be applied via Update Contacts.

Initiate

POST /api/v1/tenants/:tenant_id/domains/:name/change_of_registrant

Body: { "new_registrant_handle": "<handle>" }.

Response:

Errors: change_already_pending, new_registrant_unknown, domain_in_transfer, domain_on_hold_dispute, domain_state_invalid, prior_registrant_unknown.

List for a domain

GET /api/v1/tenants/:tenant_id/domains/:name/change_of_registrant

Get one

GET /api/v1/tenants/:tenant_id/change_of_registrant/:id

Confirm

POST /api/v1/tenants/:tenant_id/change_of_registrant/:id/confirm

Body: { "token": "<plaintext>", "party": "prior" | "new" }.

When both parties have confirmed, the system Apply step swaps the registrant, sets transfer_lock_until = now + 60 days (unless the new registrant or tenant has transfer_lock_opt_out = true), captures a fresh Acceptance Record with acceptance_method = change_of_registrant, and dispatches the §II.B prior-registrant notification.

Decline

POST /api/v1/tenants/:tenant_id/change_of_registrant/:id/decline

Same body shape as Confirm. Either party’s decline terminates the request without modifying the domain.

Errors common to confirm/decline: consent_token_invalid, not_found.

Disputes (read-only)

Tenants can read UDRP / URS disputes filed against their own domains. Filing and deciding disputes are operator-only (see operator-api.md); providers notify the registrar, not the tenant.

List disputes

GET /api/v1/tenants/:tenant_id/disputes

Response: 200 OK with { data: [<dispute>] }. Newest-first.

Get a single dispute

GET /api/v1/tenants/:tenant_id/disputes/:id

Response: 200 OK with the dispute object, or 404 not_found.

When a dispute is active (state = "filed"), every domain response includes an inline dispute summary { id, type, provider, case_number, state, filed_at }.


Contacts

Check contact handle availability

POST /api/v1/tenants/:tenant_id/contacts/check

Checks whether a contact handle is available.

Request body:

Field Required Description
handle yes Handle to check. Must match [A-Za-z0-9][A-Za-z0-9_.-]{0,62}.

Response: 200 OK

{
  "data": {
    "handle": "jdoe",
    "status": "available"
  }
}

List contacts

GET /api/v1/tenants/:tenant_id/contacts

Returns all contacts for the tenant.

Response: 200 OK with array of contact objects.


Get contact

GET /api/v1/tenants/:tenant_id/contacts/:handle

Returns a single contact by handle.

Response: 200 OK with contact object, or 404 Not Found.


Create contact

POST /api/v1/tenants/:tenant_id/contacts

Creates a new contact.

The handle field is optional. When omitted (or supplied as an empty string), the service mints an opaque random handle (16 lowercase hex characters) that is returned in the response. Supply handle only when you need a caller-chosen identifier; opaque handles are preferred for most use cases because they avoid leaking user-meaningful strings into registry EPP objects.

Phone validation. The phone field must be in international E.164 form (+CCXXXXXXXXX) and be valid for the country implied by the dialling code per libphonenumber metadata. Numbers that pass E.164 syntax but are too short for their country (e.g. +15554545 against the +1 NANP plan) are rejected with unprocessable_entity and a phone error like "is not a valid number for its country code".

Response: 201 Created with the contact object.


Update contact

PATCH /api/v1/tenants/:tenant_id/contacts/:handle

Updates a contact’s attributes. The handle and tenant_id fields cannot be changed through this endpoint.

Response: 200 OK with the updated contact object.


Delete contact

DELETE /api/v1/tenants/:tenant_id/contacts/:handle

Deletes a contact.

Response: 204 No Content


Transfer contact

POST /api/v1/tenants/:tenant_id/contacts/:handle/transfer

Transfers a contact. Currently returns an error indicating transfer is not supported.


Agreement Versions

Create agreement version

POST /api/v1/tenants/:tenant_id/agreement_versions

Creates a new agreement version in draft state.

Response: 201 Created with the agreement version object.


List agreement versions

GET /api/v1/tenants/:tenant_id/agreement_versions

Returns agreement versions for the tenant. Supports filtering via query parameters.

Response: 200 OK with array of agreement version objects.


Get agreement version

GET /api/v1/tenants/:tenant_id/agreement_versions/:id

Returns a single agreement version.

Response: 200 OK with agreement version object, or 404 Not Found.


Publish agreement version

POST /api/v1/tenants/:tenant_id/agreement_versions/:id/publish

Publishes a draft agreement version, making it the active version.

Response: 200 OK with the updated agreement version object.


Retire agreement version

POST /api/v1/tenants/:tenant_id/agreement_versions/:id/retire

Retires a published agreement version.

Response: 200 OK with the updated agreement version object.


Update agreement version (not allowed)

PUT /api/v1/tenants/:tenant_id/agreement_versions/:id
PATCH /api/v1/tenants/:tenant_id/agreement_versions/:id

Returns 405 Method Not Allowed. Agreement versions are immutable once created; create a new version instead.


Acceptance Records

Get current acceptance

GET /api/v1/tenants/:tenant_id/domains/:name/acceptance

Returns the current acceptance record for a domain.

Response: 200 OK with acceptance object, or 404 Not Found if no acceptance exists.


Create acceptance

POST /api/v1/tenants/:tenant_id/domains/:name/acceptance

Records that the registrant has accepted the current agreement for a domain. The service automatically captures the request’s source IP and user agent.

Response: 201 Created with the acceptance object.


Terminate acceptance

POST /api/v1/tenants/:tenant_id/domains/:name/acceptance/terminate

Terminates the current acceptance record for a domain.

Response: 200 OK with the updated acceptance object.


Acceptance history

GET /api/v1/tenants/:tenant_id/domains/:name/acceptance/history

Returns all acceptance records for a domain, including terminated ones.

Response: 200 OK with array of acceptance objects.


Hosts

Check host name availability

POST /api/v1/tenants/:tenant_id/hosts/check

Checks whether a host name is available for creation.

Request body:

Field Required Description
name yes The fully qualified host name to check.

Response: 200 OK with availability result.


List hosts

GET /api/v1/tenants/:tenant_id/hosts

Returns all hosts for the tenant.

Response: 200 OK with array of host objects.


Get host

GET /api/v1/tenants/:tenant_id/hosts/:name

Returns a single host by name.

Response: 200 OK with host object, or 404 Not Found.


Create host

POST /api/v1/tenants/:tenant_id/hosts

Creates a new host (glue record).

Response: 201 Created with the host object.


Update host IPs

PATCH /api/v1/tenants/:tenant_id/hosts/:name

Updates the IP addresses for a host.

Response: 200 OK with the updated host object.


Rename host

POST /api/v1/tenants/:tenant_id/hosts/:name/rename

Renames a host.

Response: 200 OK with the updated host object.


Delete host

DELETE /api/v1/tenants/:tenant_id/hosts/:name

Deletes a host.

Response: 204 No Content


Reconcile host

POST /api/v1/tenants/:tenant_id/hosts/:name/reconcile

Pulls the registry’s current view of a subordinate host (RFC 5732 <host:info>) and writes the returned statuses and IP set onto the stored record. Useful for clearing pendingCreate / pendingUpdate / pendingDelete statuses after the registry has acknowledged an operation.

Response: 200 OK with the updated host object.

Errors: 404 not_found when the host is not in the tenant; 422 not_subordinate for external hosts (they have no registry counterpart); adapter errors (registry_error, etc.) are surfaced as 422.


Pricing

Rate-card rows are seeded by the operator (see operator-api.md); tenants read their rate card and request quotes through the endpoints below.

List rate cards

GET /api/v1/tenants/:tenant_id/rate_card

Returns every priced row across every TLD for the tenant, grouped by TLD. TLDs with no priced rows are omitted.

Response: 200 OK

{
  "data": [
    {
      "entries": [
        {
          "amount": "9.9900",
          "currency": "USD",
          "id": "…",
          "inserted_at": "…",
          "operation": "register",
          "period": 1,
          "premium_class": null,
          "tier": "standard",
          "tld": "com",
          "updated_at": "…"
        }
      ],
      "tld": "com"
    }
  ]
}

Get rate card

GET /api/v1/tenants/:tenant_id/tlds/:tld/rate_card

Returns every priced row for the tenant + TLD.

Path parameters:

Field Description
tld The TLD (without leading dot). Must be associated with the tenant.

Response: 200 OK

{
  "data": {
    "entries": [
      {
        "amount": "9.9900",
        "currency": "USD",
        "id": "…",
        "inserted_at": "…",
        "operation": "register",
        "period": 1,
        "premium_class": null,
        "tier": "standard",
        "tld": "com",
        "updated_at": "…"
      }
    ],
    "tld": "com"
  }
}

Errors: 422 tld_not_associated when the tenant has no active association for the requested TLD.


Create quote

POST /api/v1/tenants/:tenant_id/quotes

Resolves a concrete price for a specific (fqdn, operation, period) at the tier applicable to the name. The tier (and, for premium names, the premium class) is supplied by the caller; a later plan will source it automatically from availability checks and the domain record.

Request body:

Field Required Description
fqdn yes Fully qualified domain name. The TLD is derived and must be associated with the tenant.
operation yes One of register, renew, transfer_in, restore.
period conditional Integer years (1–10). Required for register, renew, transfer_in. Omitted for restore.
tier no standard (default) or premium.
premium_class conditional Required when tier = premium. Registry-supplied opaque label (e.g., gold).

Response: 200 OK

{
  "data": {
    "amount": "9.9900",
    "currency": "USD",
    "fqdn": "example.com",
    "operation": "register",
    "period": 1,
    "premium_class": null,
    "quote_id": "f1a2b3c4-…",
    "quoted_at": "2026-04-18T12:00:00.000000Z",
    "tier": "standard",
    "tld": "com",
    "valid_until": "2026-04-18T13:00:00.000000Z"
  }
}

The quote_id is the single-use payment-assurance token referenced by the priced lifecycle endpoints (register / renew / transfer_in / restore) per [RAA §3.7.4]. See the per-endpoint sections above for the request shape and error codes.

Errors: 422 with one of the following error codes:

Abuse Reports (public)

The abuse report endpoint is unauthenticated so that anyone can file a report of alleged abuse against a domain managed by this service, per [RAA §3.18.1].

File abuse report

POST /api/v1/abuse_reports

Request body:

Field Type Required Description
domain_name string yes The reported domain. Lowercased and trailing-dot-stripped server-side.
description string yes Free-text description of the alleged abuse.
reporter_name string no Display name of the reporter.
reporter_contact object no Structured contact (e.g. { "email": "..", "phone": ".." }).
reporter_reference string no External case number from the reporter.
law_enforcement boolean no Reporter-claimed LE origin; triggers the 24-hour acknowledgement clock until the operator confirms or overrides at acknowledgement.
source string no Intake channel. Defaults to "public_form".

Response: 201 Created

{
  "data": {
    "domain_name": "example.com",
    "id": "uuid",
    "received_at": "2026-04-20T12:00:00.000000Z",
    "state": "received"
  }
}

When the reported domain matches a registered domain on this service, the case is scoped to the owning tenant and becomes visible on the tenant surface below. Unmatched reports land in the operator-only triage queue.

Errors: 422 with one of:

Abuse Cases (tenant read-only)

GET /api/v1/tenants/:tenant_id/abuse_cases
GET /api/v1/tenants/:tenant_id/abuse_cases/:id

Returns cases scoped to the caller\x27s tenant. Optional query params state and category filter the list. Writes (acknowledge / classify / resolve / dismiss) are operator-only in Phase A.

Directory API

A cross-tenant, read-only lookup surface consumed by the standalone RDAP service. Not tenant-scoped. Authenticated via OAuth 2.0 Client Credentials; the token must carry the directory:read scope. Tenant-scoped tokens (read / write) are rejected with 403 insufficient_scope.

Routes

GET /directory/v1/domains/by_name/:name
GET /directory/v1/contacts/:handle
GET /directory/v1/hosts/by_name/:name
GET /directory/v1/registrars/by_iana_id/:iana_id

Every response is wrapped in {"data":..} following the house envelope convention. Each endpoint returns 404 not_found when the requested object does not exist.

Domain response fields: handle, ldh_name, unicode_name, tld, state, epp_statuses, registered_at, expires_at, last_changed_at, nameservers (array of host FQDN strings), ds_records, registrant_handle, admin_handle, tech_handle, billing_handle, tenant_id, iana_registrar_id (from the owning tenant), hold_reason.

Contact response fields: standard contact payload plus a disclosure object carrying per-field publication flags. Defaults match the ICANN 2024 Registration Data Policy (organization, state_province, countrytrue; other PII fields → false). Real per-field flags land in a follow-up plan.

Host response fields: handle, ldh_name, ip_addresses (split into v4 / v6 arrays), statuses, created_at, last_changed_at.

Registrar response fields: iana_registrar_id, name, abuse_email, abuse_form_url, rdap_base_url (today always null until the tenant column lands).