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>",..] }
}
-
erroris a stable, snake_case identifier; clients may switch on it. -
detailis an optional structured payload (e.g.,{ "purge_due_at": ".." }) documented per endpoint. -
detailscarries field-level validation messages and only appears forunprocessable_entityfrom changeset validation.
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:
- 500 Internal Server Error — the registrar itself failed.
- 502 / 503 / 504 — the upstream registry failed (bad response, unreachable, or timeout). The registrar is healthy.
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_\-:.+]>
- A key is opaque to the server; UUIDv4 or ULID are recommended.
- Keys are scoped per tenant (or per credential / IP for unauthenticated endpoints), so collisions across tenants are impossible.
- The first request executes normally. The response is stored and replayed for any subsequent request that arrives within 24 hours with the same key.
| 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:
-
Extracts the token from the
Authorization: Bearer <token>header. - Verifies the JWT signature and expiration using the configured secret.
-
Looks up the token’s
jti(JWT ID) in theoauth_tokenstable to check for revocation. -
Assigns the token’s claims (including
subas the current user ID andtenant_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:
-
The
tenant_idin the URL must match thetenant_idclaim in the bearer token. - The tenant must exist and not be suspended.
- The authenticated user must have an active membership in the tenant.
If any check fails, the service returns 403 Forbidden with one of:
-
tenant_mismatch– token was issued for a different tenant -
tenant_not_found– tenant does not exist -
tenant_inactive– tenant is suspended -
not_a_member– user has no membership in this tenant -
membership_deactivated– user’s membership is deactivated
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
-
400 Bad Requestwith{"error": "unsupported_grant_type"}for unrecognized grant types. - Various error tuples for invalid clients, expired codes, PKCE mismatches, etc.
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:
-
404 Not Found— DCR is disabled. -
422 Unprocessable Entity— missingredirect_urisor invalid input.
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:
-
404 Not Found— token does not match a non-expired challenge (missing, invalid, expired, or already consumed). -
422 Unprocessable Entity—tokenmissing from the request body.
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:
-
400 invalid_token— token is malformed, unknown, already consumed, or expired. The single error code intentionally does not distinguish among these. -
422 unprocessable_entitywithdetails.password— new password fails the policy.
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.
-
422 invalid_pem— PEM decode failed. -
422 key_mismatch— private key does not match the certificate. -
422 cert_pem_required/key_pem_required— field missing or blank.
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:
-
state— filter by lifecycle state. -
tld— filter by TLD (case-insensitive). -
name_like— substring match on the domain name (case-insensitive). -
updated_after/updated_before— RFC 3339 timestamps; bound the search to domains whoseupdated_atfalls within the window. -
expires_within_days— positive integer; matches domains whoseexpires_atfalls between now and now + N days. Past-due rows do not match. -
renewed_within_days— positive integer; matches domains whoselast_renewed_atfalls within the last N days. Domains that have never been renewed do not match. -
in_grace—truematches domains in the auto-renewal grace window (state=expiredandexpires_at >= now − tld_config.auto_renewal_grace_period_days). -
in_redemption—truematchesstate=redemption. -
nameserver— fully qualified host name; matches domains whose nameserver set contains the supplied host (case-insensitive). Malformed FQDNs return 422. -
unicode_name_like— substring match on the domain’s U-label (unicode_name); complementary toname_like, which matches the A-label. -
out_of_sync— boolean;truematches domains whose row diverges from the registry’s authoritative view;falseexcludes them. -
registrant_verification_state— one ofpending_verification,active,suspended,deleted. Joins through the registrant contact. -
order_by— one ofname,registered_at,expires_at,updated_at,last_renewed_at. Offset mode only; combining withcursorreturns 422. -
order—asc(default) ordesc. Ignored whenorder_byis absent. -
limit— page size (default 100, max 500). -
cursor— opaque token returned in a prior response’spagination.next_cursor. Omit for the first page. -
offset— non-negative integer offset (0-based). Use instead ofcursorfor numeric pagination; the response then includes atotalcount.
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):
-
wrong_state— domain isn’tregisteredoron_hold. -
not_at_registry— the registry returnedobject_does_not_existfor this domain. -
subordinate_host_missing— the registry’s nameserver list references a subordinate host the tenant hasn’t created (its IPs are operator-supplied and we won’t fabricate them).
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:
-
External transfer (no existing domain row for this name): the response carries a slim object —
name,tld,registrant_handle,admin_handle,tech_handle,billing_handle, and apending_transferblock of{ id, direction: "in", state: "pending", initiated_at, auto_ack_at }. No domainidorstatefield is present, because no domain row exists in this service until the registry confirms. While the transfer is in flight,GET /domains/:namereturns404; the transfer can be tracked throughGET /transfers/:pending_id. -
Intra-tenant transfer (a domain row already exists in this tenant for this name): the full domain object is returned.
statestaysregisteredandregistry_account_idis unchanged during the pending window — the new account selected for this transfer travels on thepending_transferrow and is applied to the domain on completion.
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:
-
contact_inaccuracy→clientHold+clientTransferProhibited(registrar-set, the registrar can remove them at any time). -
dispute→serverHold+serverTransferProhibited(REGISTRAR HOLD / REGISTRAR LOCK for UDRP/URS proceedings; set at the registry’s server side).
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:
-
201 Createdwith{ "data": <request>, "tokens": { "prior": "<plaintext>", "new": "<plaintext>" } }for a material change. The plaintext tokens are returned once; pass them back to confirm/decline endpoints (or include them in consent emails). -
200 OKwith{ "data": { "material": false } }when the proposed change is non-material; no request row is created and the caller may proceed via Update Contacts.
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:
-
price_not_configured— no rate-card row matches the requested key. -
premium_class_unknown—tier = premiumbut no row exists for the suppliedpremium_class. -
period_not_allowed—periodmissing or outside 1–10 for the operation. -
tld_not_associated— the derived TLD is not associated with the tenant. -
invalid_operation—operationis missing or not one of the allowed values. -
premium_class_required—tier = premiumwithout apremium_class. -
fqdn_required—fqdnmissing or empty.
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:
-
missing_domain_name— field absent or empty. -
missing_description— field absent or empty.
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, country → true; 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).