HTTP API
The dashboard, the MCP tools and third-party clients use one API under
/v1. Its OpenAPI 3.1 document is served, without authentication, at
/v1/openapi.json; internal/controlplane/openapi_routes.go declares
it as a table of operations and a test walks the router so every /v1
route is documented (and every documented operation exists). A new /v1
route fails the build until it is added there.
Authentication
| Method | How | Notes |
|---|---|---|
| Dashboard session | rdns_session cookie from POST /auth/code + POST /auth/verify | every scope; limited by the user's role |
| Personal access token | Authorization: Bearer rdns_<org>_<id>_<secret> | bound to one org, scopes, optional IP allowlist and expiry; created in the dashboard |
| OAuth 2.1 access token | Authorization: Bearer <JWT> | from the authorization code flow with PKCE; org and scopes chosen at consent; 1 hour, refreshable |
Tokens (PAT or OAuth) can never manage tokens, create organizations or use
/v1/admin: those are dashboard-only.
Sessions pick the organization with the X-RDNS-Org header (or ?org=),
defaulting to their first membership. Tokens are bound to their org.
Token clients
A personal access token declares what it is for at creation:
POST /v1/tokens {..., client} with api (default: scripts and
integrations), terraform, cli or mcp. The token record (and the
manifest) stores it and GET /v1/tokens shows it. The plan gates every
request of a terraform, cli or mcp token by that client
(402 plan_limit_reached, action access.terraform, access.cli or
access.mcp), whatever its User-Agent. An OAuth client declares itself
at registration with the RFC 7591 software_id: one starting with
rdnsctl is gated as the CLI, one starting with terraform as the
Terraform provider (rdnsctl login and the Terraform provider register
with them). An api token, an older token without a client and an OAuth
client with any other software_id fall back to the User-Agent
declaration (rdnsctl/, terraform-provider-redundantdns/), which the
go-client sets. Declarations are not proofs: they keep honest clients
inside their plan.
Scopes
zones:read, zones:write, connections:read, connections:write,
domains:read, domains:write. A
token request needs the route's scope and the user's minimum role
(viewer < editor < admin < owner); each operation's description in the
OpenAPI document states both.
IP allowlists
The org allowlist and the token's (PAT allowlist, or the OAuth token's
snapshot of the org allowlist at issuance) are enforced on every token
request. The client IP is the TCP peer, or, for requests from a trusted
proxy (RDNS_TRUSTED_PROXIES), the X-Forwarded-For entry
RDNS_FORWARDED_COUNT positions from the right.
OAuth 2.1 authorization server
Metadata: GET /.well-known/oauth-authorization-server (RFC 8414) and
GET /.well-known/oauth-protected-resource[/mcp|/v1] (RFC 9728); keys at
/.well-known/jwks.json. The issuer is RDNS_PUBLIC_URL, never a request
header.
| Endpoint | Purpose |
|---|---|
POST /oauth/register | dynamic client registration (RFC 7591); software_id and software_version are stored and echoed (a software_id of rdnsctl… or terraform… gates the client as the CLI or the Terraform provider, see Token clients). Public clients (token_endpoint_auth_method: none) with the same redirect set get the same client_id back; confidential clients get a new id and a secret shown once. Redirect URIs: https, http on loopback only, or a native-app scheme; exact match later. 20 successful registrations per IP per hour per instance |
GET /oauth/authorize | response_type=code, client_id, redirect_uri, code_challenge (S256 only), state, scope, optional resource. Redirects to the consent UI (RDNS_OAUTH_CONSENT_PATH?request=<id>), or straight back to the client when a trusted client was already approved |
GET /oauth/consent?request=<id> | the SPA consent page (RDNS_OAUTH_CONSENT_PATH): signs the user in and asks for the legal acceptance in place when needed, shows the client, its redirect host and the requested scopes, and posts the decision |
GET /oauth/authorize/request?request=<id> | consent details for the signed-in user (client, requested scopes, the user's orgs) |
POST /oauth/authorize/decision | JSON {request, approve, orgId, scopes} from the consent UI; returns {redirectTo} |
GET /oauth/consent-fallback?request=<id> | minimal server-rendered consent (sign in with an e-mail code, accept the current Terms and Privacy Policy when needed, choose org and scopes) |
POST /oauth/token | authorization_code (with code_verifier) and refresh_token (optional narrower scope) |
POST /oauth/revoke | RFC 7009; revoking a refresh or access token revokes its whole grant |
Rules mirrored from zodo:auth-mcp-kit:
- Codes are single use (10 minutes). A replayed code revokes the grant it issued.
- Refresh tokens rotate on every use (30 days). Reusing a rotated refresh token revokes the whole family (all its refresh and access tokens).
- Scopes granted = requested ∩ catalog ∩ the client's registered scopes; consent may narrow them; refresh may narrow them, never widen.
- Access tokens are Ed25519 JWTs (
typ: at+jwt) withsub,org,scope,client_id,gid(grant id),ipa(org IP allowlist snapshot),iat/exp(1 hour). They are verified offline: signature, expiry, IP snapshot and the user's revocation list in the bucket. The control plane also checks that the user is still a member of the org.
Connected apps
A signed-in user lists the OAuth clients they approved, per organization,
with GET /v1/me/oauth/grants (client, scopes, approval time, live refresh
tokens) and disconnects one with DELETE /v1/me/oauth/grants/{clientId}
(optionally ?orgId=). Disconnecting writes a per-client cut-off in the
user's revocation list (every access token of that client issued until
then fails verification, offline too), revokes its refresh tokens and
forgets the approval, so the next authorization asks again. Both routes
are dashboard-session only; the SPA shows them under API tokens →
Connected apps.
Legal acceptance
A dashboard session must accept the current Terms of Service and Privacy
Policy (GET /v1/legal/versions, GET /v1/me/legal,
POST /v1/me/legal/accept) before using the rest of /v1; until then it
gets 428 legal_acceptance_required. PAT and OAuth bearers (and MCP tool
calls) are not gated, because no token is issued without it: the OAuth
server never issues a code to a user without a current acceptance
(POST /oauth/authorize/decision answers 428 legal_acceptance_required,
a trusted client does not skip consent), and the fallback consent page
shows a terms step (POST /oauth/consent-fallback/terms, recorded exactly
like POST /v1/me/legal/accept) before consent. See docs/dev.md.
GET /v1/legal/versions also returns managedTerms, the current version
of the Managed Provider Terms (below).
Managed Provider Terms
Managed connections (platform-owned provider accounts) are governed by the
Managed Provider Terms and Acceptable Use Policy (/legal/managed-terms),
accepted once per organization by an owner or admin, with a session or
a token:
| Route | Access | What it does |
|---|---|---|
GET /v1/legal/managed | viewer + connections:read | {current, accepted: {version, acceptedAt, userId, ip} | null, required, url} |
POST /v1/legal/managed/accept {version} | admin + connections:write (session or PAT) | records the acceptance in billing.json (audit org.managedTerms.accept) and returns the same body; a version other than the current one answers 409 legalVersionMismatch |
Until the org accepts the current version, POST /v1/connections with
mode: managed and attaching a managed connection answer 428 managed_terms_required with details: {version, url} (after the plan
check). POST /v1/connections also takes acceptManagedTerms: "<version>"
(Terraform, rdnsctl): the acceptance is recorded the same way before the
connection is created. A new version asks again before the next managed
connection or attachment; existing managed zones keep being served.
Subdomain redundancy (parent delegation)
POST /v1/zones takes parentDelegation (boolean). When the new zone is a
subdomain of another zone of the organization (api.example.com under
example.com; the closest parent wins), the platform writes an NS record
set named after the child (api) into the parent zone with the child's NS
plan, keeps it equal to the plan whenever the child's attachments change
and removes it when the child is deleted; the data plane's schedule
converges it after a crash and removes delegations whose child is gone.
Omitted means on when such a parent exists; false turns it off; true
without a parent answers 422 parentZoneNotFound. The zone view carries
parentDelegation: {enabled, parentZoneId, parentZoneName, label}.
In the parent, those record sets carry managedBy: "delegation",
childZoneId and childName (zone view and GET .../records); editing or
deleting one answers 409 recordSetManaged, and adopting a provider's
records never replaces them. The parent's providers receive the set like
any other record, so any provider works as the parent, including
Cloudflare with "manage an existing zone" access.
Delegation check and registrar
POST /v1/zones/{zoneId}/delegation/check (and the periodic delegation
job) also looks up the registrar of a registrable domain (eTLD+1) over RDAP
(RDNS_RDAP_URL, default https://rdap.org; 5 s timeout; cached 24 h in
status.json): registrar: {name, ianaId, isCloudflare, checkedAt}. When
the registrar is Cloudflare Registrar (IANA id 1910), which only accepts
Cloudflare's nameservers, and the delegation is not complete, the result
carries hint: "cloudflare_registrar" and the Delegation tab explains the
options. Subdomain zones get no registrar lookup.
Domains (registrar)
Domains held in the platform's reseller account at an ICANN-accredited
registrar (Openprovider in production, an in-memory fake in e2e). The
customer is the registrant: every domain's owner contact is built from
the organization's registrant profile; the platform's own contact is only
tech/admin where the TLD allows it. Registrar credentials are platform-level
(RDNS_OPENPROVIDER_* on the data plane), never per tenant, and the control
plane never calls the registrar: it writes intent and asks the data plane,
which runs the registrar_sync and registrar_apply jobs (the mutation is
tried at once; a transient registrar failure leaves the job queued with
backoff, a rejection fails it).
Global ownership: domain-names/<name>.json ({name, orgId, createdAt},
If-None-Match on create). Tenant objects: orgs/<orgId>/domains/<name>.json
and orgs/<orgId>/contacts/<contactId>.json, both sealed.
Objects
Domain (GET /v1/domains/{name}; the list omits nothing):
{
"name": "example.com",
"registrar": "openprovider",
"registrarDomainId": "123456789",
"status": "active",
"expiresAt": "2027-01-29T00:00:00Z",
"autoRenew": true,
"locked": true,
"nameservers": ["ns-1.awsdns-01.org", "ns1.p01.dynect.net"],
"contacts": {"owner": "XX123456-XX", "admin": "ZD000001-XX", "tech": "ZD000001-XX", "billing": ""},
"ownerContactId": "ctc-...",
"registrant": { "...": "a Contact snapshot (the owner when last applied)" },
"transfer": {"state": "pending", "requestedAt": "...", "completedAt": null, "detail": "", "applyZoneNs": true, "nameservers": []},
"zone": {"zoneId": "zone-...", "name": "example.com", "nsPlan": ["..."], "nameserversMatch": false, "delegationState": "pending"},
"pendingJobs": [{"jobId": "job-...", "op": "nameservers", "status": "queued", "attempts": 1, "lastError": "", "createdAt": "..."}],
"lastSyncAt": "...", "lastError": "", "createdAt": "...", "updatedAt": "..."
}
status:active,payment_pending(a registration waiting for its checkout),registering(paid; the register job runs or retries),registration_failed(paid, refused by the registrar),transfer_pending,transfer_failed,pending(registration or change in progress at the registry),expired,deletedorunknown(not synced yet).registrationis null for a domain that was not registered through the platform (see Registering a domain);renewalis the last paid renewal.transferis null for a domain that was not transferred in;stateispending,completedorfailed.zoneis set when the organization has a zone with the same name:nameserversMatchcompares the domain's nameservers with the zone's NS plan (as sets, case-insensitive),delegationStateis the zone's last delegation check.pendingJobsare theregistrar_applyjobs not done yet for the domain (op:transfer,nameservers,lock,autorenew,renew,registrant;status:queued,running,failed).
Contact (orgs/<orgId>/contacts/<contactId>.json):
{
"contactId": "ctc-...", "label": "Headquarters", "default": true,
"companyName": "Example Ltd", "firstName": "Ada", "lastName": "Lovelace",
"email": "ada@example.com", "phone": "+44.2071234567",
"street": "Main Street", "houseNumber": "1", "city": "London", "state": "",
"postalCode": "SW1A 1AA", "country": "GB", "taxId": "GB123456789",
"handles": {"openprovider": "AL000001-GB"},
"createdAt": "...", "updatedAt": "..."
}
Required: firstName, lastName, email, phone (+<country code>.<number>,
EPP style), street, city, postalCode, country (ISO 3166-1 alpha-2).
handles (read-only) are the registrar contact handles created for it.
The registrant profile is the organization's default contact: the
owner of every domain transferred in unless another contact is chosen.
Mutation result (every registrar_apply route):
{"domain": Domain, "job": {"jobId", "op", "status": "done" | "queued" | "failed", "lastError"}}
with 200 when the registrar applied it, 202 when it is queued for a
retry. A registrar rejection answers 422 registrarRejected (the job is
kept as failed and listed in pendingJobs).
Routes
| Route | Access | What it does |
|---|---|---|
GET /v1/domains | viewer + domains:read | the organization's domains |
POST /v1/domains/transfer {name, authCode, contactId?, nameservers?, applyZoneNs?, autoRenew?, acceptDomainTerms?} | admin + domains:write | transfer a domain in (201, mutation result): claims domain-names/<name>, creates the domain (transfer_pending) and submits the transfer with the auth code; the owner is contactId or the registrant profile. applyZoneNs writes the NS plan of the org's zone with the same name when the transfer completes; nameservers sets them in the transfer; otherwise the current nameservers are kept (no downtime) |
GET /v1/domains/check?names=a.tools,b.com&years=1 | viewer + domains:read | availability and price of up to 20 names (see Registering a domain) |
POST /v1/domains/register {name, years?, contactId?, nameservers?, applyZoneNs?, autoRenew?, acceptDomainTerms?} | admin + domains:write | register a new domain, paid through a one-off checkout (201 {domain, checkoutUrl}) |
POST /v1/domains/{name}/register/retry | admin + domains:write | run the register job again for a paid registration that failed (mutation result; 409 domainNotRetryable otherwise) |
GET /v1/domains/{name} | viewer + domains:read | one domain |
DELETE /v1/domains/{name} | admin + domains:write | forget a domain whose transfer failed, or cancel an unpaid registration (its checkout is closed first: 503 billing_unavailable when it cannot be); releases the name. 409 domainNotRemovable otherwise (a paid registration is never removed) |
GET /v1/domains/{name}/transfer | viewer + domains:read | {name, status, transfer} |
POST /v1/domains/{name}/sync | editor + domains:write | refresh status, expiry, nameservers, lock and transfer progress from the registrar now |
PUT /v1/domains/{name}/nameservers {nameservers} | editor + domains:write | set the nameservers (2 to 13 host names) |
POST /v1/domains/{name}/nameservers/apply-zone {zoneId?} | editor + domains:write | write the NS plan of a zone (default: the org's zone with the same name; 422 zoneNsPlanEmpty without attachments) |
PUT /v1/domains/{name}/lock {locked} | admin + domains:write | transfer lock on or off |
PUT /v1/domains/{name}/autorenew {autoRenew} | admin + domains:write | auto-renewal on or off |
POST /v1/domains/{name}/renew {years?} | admin + domains:write | renew for 1 to 10 years (default 1). Billing off: renewed at once (mutation result). Billing on: 200 {domain, checkoutUrl}, the renewal runs when paid (409 domainRenewalInProgress while a paid one runs) |
POST /v1/domains/{name}/authcode | admin + domains:write | {name, authCode}: the transfer-out auth code, fetched from the registrar, never stored (audit domain.authcode.reveal) |
POST /v1/domains/{name}/registrant {contactId, confirmName} | admin + domains:write | change the registrant (trade) to another contact; confirmName is the domain name |
GET /v1/domains/contacts | viewer + domains:read | contacts |
POST /v1/domains/contacts | admin + domains:write | create a contact (201) |
PUT /v1/domains/contacts/{contactId} | admin + domains:write | replace a contact's fields (its registrar handles are updated by a job) |
DELETE /v1/domains/contacts/{contactId} | admin + domains:write | delete a contact no domain uses and that is not the registrant profile (409 contactInUse) |
GET /v1/domains/registrant-profile | viewer + domains:read | {profile: Contact | null} |
PUT /v1/domains/registrant-profile | admin + domains:write | create or replace the registrant profile (the default contact) |
GET /v1/domains/export | admin + domains:read | export everything: {exportedAt, org, contacts, domains, zones: [{zoneId, name, zoneFile}]} |
GET /v1/legal/domains | viewer + domains:read | the organization's acceptance of the Domain Registration Terms (same body as /legal/managed) |
POST /v1/legal/domains/accept {version} | admin + domains:write | accept them for the organization (audit org.domainTerms.accept) |
GET /v1/admin/domains | platform admin | every domain of the reseller account merged with its owner: [{name, registrar, registrarDomainId, status, expiresAt, autoRenew, locked, nameservers, orgId, orgName}] (orgId empty: unassigned) |
POST /v1/admin/domains/{name}/assign {orgId} | platform admin | attach an unassigned domain of the reseller account to an organization (audit admin.domain.assign), then sync it |
POST /v1/admin/tenants/{orgId}/domains/register {..., skipPayment} | platform admin | the operator path: register for an organization (same body as the org route); skipPayment: true skips the checkout (paidBy: "operator", the job runs at once, 201 {domain, job}). No plan limit; the org must have accepted the Domain Registration Terms itself (428). Audit admin.domain.register (platform and org) |
POST /v1/admin/tenants/{orgId}/domains/{name}/renew {years?, skipPayment: true} | platform admin | renew without a payment (audit admin.domain.renew) |
Exit guarantee. The auth code, unlocking (locked: false), sync and
the export are never gated: not by the plan, not by a read-only billing
status and not by the Domain Registration Terms. Everything else that
changes a domain needs the organization's acceptance of the current
Domain Registration Terms (/legal/domain-terms): 428 domain_terms_required with details: {version, url} until an admin
accepts them (POST /v1/legal/domains/accept, or acceptDomainTerms: "<version>" in the transfer body).
Plans. Domains are on every paid plan (feature domains); the trial
can hold one domain, so an organization on its trial can transfer in
the domain it cannot delegate at Cloudflare Registrar. A second transfer or
registration on the trial answers 402 plan_limit_reached (action
domain.transfer or domain.register, feature domains, upgradeTo). Transfer-in, nameservers and renew also answer
402 org_read_only for a read-only organization.
Registering a domain
A new domain is paid once, through a Stripe Checkout Session in
mode=payment with an ad-hoc price_data line (Domain registration <name> (<years> year)), then registered at the registrar by the data
plane. The customer stays the registrant (the registrant profile or
contactId); admin, tech and billing are the platform's handle
(RDNS_OPENPROVIDER_HANDLE).
GET /v1/domains/check?names=...answers, per name,{name, available, premium?, priceCents, currency, years, reason?}: the quoted price (never the registrar's cost). Names held by an organization on the platform are not available.422 invalidDomainNamefor an invalid name,503 registrarUnavailablewithout a registrar (RDNS_REGISTRAR=none).POST /v1/domains/registerchecks the terms (428 domain_terms_required, oracceptDomainTerms), the plan (actiondomain.register), the owner (422 registrantProfileRequired) and the name (409 domainUnavailable,422 domainPremium: premium names are registered on request), claimsdomain-names/<name>, writes the domain with statuspayment_pendingand aregistrationrecord{years, priceCents, currency, checkoutSessionId, checkoutUrl, contactId, nameservers, applyZoneNs, autoRenew, requestedAt, paidAt, paidBy, paymentIntentId, completedAt, error}, and returns201 {domain, checkoutUrl}. The checkout returns to/domains/<name>?checkout=success|canceled.503 billing_unavailablewithRDNS_BILLING=off.- The webhook
checkout.session.completed(orcheckout.session.async_payment_succeededfor a delayed method) withmode=paymentand our metadata (orgId,purpose: domain_register,domainName,years) marks the registration paid (paidAt,paidBy: "stripe",paymentIntentId), setsregisteringand runs theregistrar_applyjobregister: nameservers are the given ones, else the NS plan of the org's zone with the same name whenapplyZoneNs, else the registrar's default. Success setsactivewith the registrar's data. The job is idempotent: a name the account already holds completes the registration instead of failing it, and the schedule re-queues the job of a paid registration that has none. - A rejection (or a transient failure after its last retry) sets
registration_failedwith the error and fires the alertdomain_registration_failed(no zone; the event'szoneNameis the domain), notified on the org's channels. There is no automatic refund: the operator either fixes the cause and retries (POST /v1/domains/{name}/register/retry, which resolves the alert on success) or refunds the payment in the Stripe dashboard (thepaymentIntentIdis on the registration and in the audit entrydomain.register.paid). - An abandoned checkout:
DELETE /v1/domains/{name}cancels it at once; otherwise the checkout expires after 23 hours and the data plane releases the name 24 hours after the request (domain.register.expire, also oncheckout.session.expired). A payment that matches no open registration (released meanwhile) is logged and audited asdomain.payment.orphanfor a refund.
Prices (platform/config.json, edited with PUT /v1/admin/config):
domainMarginPercent (default 20, like the managed cost plus 20%) is
added to the registrar's cost for the period, rounded up to whole cents,
in the registrar's currency; domainPrices: {"tools": {"registerCents": 4000, "renewCents": 4500}} fixes the yearly price of an extension in US
cents instead (400 invalidDomainPricing for a margin outside 0 to 1000
or a non-positive price).
Renewals with billing on go through the same kind of checkout
(purpose: domain_renew, the extension's renewCents or the renewal cost
plus the margin): the renew job runs on payment and the renewal record
gets completedAt (or error). With billing off, renewals keep running at
once as in phase 1.
Schedule. The data plane syncs every domain every
RDNS_REGISTRAR_SYNC_INTERVAL (6 h) and a domain whose transfer is pending
every RDNS_REGISTRAR_TRANSFER_POLL (15 min). When a pending transfer
completes with applyZoneNs, the zone's NS plan is written right after.
Alerts
Every data plane probes every zone's nameservers from its region (SOA
plus a sample of up to 5 record sets, UDP with TCP fallback, 3 s timeout,
every RDNS_PROBE_INTERVAL, region RDNS_PROBE_REGION). The tenant's
lease holder aggregates the regions into the zone status:
status.probeRegions (nameserver → region → result: reachable, latency,
serial, match), status.probes (the worst result per nameserver),
status.probedAt, and per attachment health (ok from every region,
down from every region, degraded otherwise) with regions (health and
down streak per region). Results of a region that stopped probing drop
out after three intervals.
After every reconcile, verify, probe and delegation job it evaluates the
org's rules and opens or resolves events (one firing event per rule +
zone + attachment, the dedupe key); each transition is delivered once to
every enabled channel.
| Rule | Fires when | Resolves when | Threshold |
|---|---|---|---|
drift | an attachment's state is drift | it is in_sync again | |
sync_error | an attachment's state is error | the provider answers (in_sync or drift) | |
delegation_broken | the delegation is partial or mismatch, or went from complete back to pending (delegation.lostAt) | it is complete | |
provider_down | no nameserver of an attachment answered N consecutive probes, from at least minRegions regions | fewer than minRegions regions see it down | N (default 2, 1-20); minRegions (default 1, 1-20) |
probe_degraded (off by default) | an attachment is down from some probe regions and answers from others | every region agrees | |
zone_serial_stale | no successful verify of an attachment for N hours | a verify succeeds | N (default 24, 1-720) |
Besides these rules, the data plane raises installation alerts (not
configurable, no zone, one event per day while the condition lasts, the
day as attachmentId): license_warning, license_expired and
mail_transport_unavailable (docs/licensing.md), and the audit stream's
audit_chain_broken, audit_stream_write_failed, audit_seal_failed and
audit_unsigned (docs/audit.md), and key_custody_warning (the
installation runs without its offline master key among the key wrappers,
ADR 0004; docs/deploy.md). The daily compliance check raises
compliance_failed and compliance_warn differently: one event while the
status holds (attachment id org for the organization's report,
platform for the installation's, raised in the platform admins'
organizations), resolved when the report is back to pass
(docs/compliance.md).
Every org starts with the rules enabled except probe_degraded (orgs
created before a rule existed get it with its default on first read). A disabled rule resolves its open events. Resolving
an event by hand notifies the channels; if the problem persists the next
check opens a new event. Acknowledging keeps the event firing and records
who is on it.
| Route | Access | Purpose |
|---|---|---|
GET /v1/alerts/rules | viewer + zones:read | rules with their effective thresholds |
PUT /v1/alerts/rules/{rule} | admin + zones:write | {enabled, threshold, minRegions} (minRegions: provider_down only) |
GET /v1/alerts/channels | viewer + zones:read | channels; secrets are never returned |
POST /v1/alerts/channels | admin + zones:write | {kind: email|webhook|slack, label, target, secret?}; a webhook's signing secret is returned once |
DELETE /v1/alerts/channels/{channelId} | admin + zones:write | remove a channel |
POST /v1/alerts/channels/{channelId}/test | admin + zones:write | send a test notification now ({ok, attempts, error}) |
GET /v1/alerts/events | viewer + zones:read | history, newest first; zoneId, rule, state, limit |
POST /v1/alerts/events/{eventId}/resolve | editor + zones:write | resolve a firing event by hand |
POST /v1/alerts/events/{eventId}/ack | editor + zones:write | acknowledge a firing event |
GET /v1/admin/alerts | platform admin | per tenant: firing count and events of the last 24 hours; latest firing events; regions: probe regions seen (mode, last heartbeat, last probe) |
Channels and webhook signatures
- email: a branded multipart e-mail (plain text + HTML) through the login SMTP settings (
RDNS_SMTP_*), with a link to the zone (or domain) and to the Alerts page. - slack: an incoming-webhook URL, stored sealed (the API shows it masked); the message is Block Kit (header, fields, context) with a text fallback.
- webhook:
POSTof a JSON body{type: alert.firing|alert.resolved|alert.test, orgId, orgName, event: {eventId, rule, state, zoneId, zoneName, attachmentId, attachmentLabel, summary, firstSeenAt, resolvedAt, url}, sentAt}with the headersX-RDNS-Event(the type),X-RDNS-Delivery(<eventId>-<channelId>-<state>, stable across retries) andX-RDNS-Signature: sha256=<hex HMAC-SHA256 of the raw body with the channel secret>. Network errors,429and5xxare retried 3 times with backoff (1 s, 4 s, 10 s); other4xxfail at once. Redirects are not followed. Outside dev mode the URL must behttpsand resolve to a public address (checked at dial time).
Verify a delivery by recomputing the HMAC over the exact bytes received:
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(request.headers['x-rdns-signature']));
A failed delivery makes the notify job retry (30 s, 2 min, 10 min); a
channel that already received a transition is skipped
(orgs/<id>/alerts/deliveries/<eventId>-<channelId>-<state>.json), so a
retry never sends it twice.
Plans and billing
Plans (the catalog is compiled in; platform/config.json plans replaces
an entry by id, managedCosts the managed providers' list prices). There
is no Free plan: every new organization starts on a 30-day trial.
Yearly prices apply only while yearly billing is on sale
(billingIntervals below; monthly only by default) and only to plans that
have one:
| Plan | Price | Zones | Providers per zone | Alert channels | Features |
|---|---|---|---|---|---|
trial (every new org, 30 days; not sold, not listed in plans) | no charge | 1 | 2 | API, BYO accounts only, one domain | |
starter | $9/mo (monthly only) | 3 | 2 | email, webhook | + Terraform provider, CLI, managed accounts, domains |
pro | $99/mo, $990/yr | 50 | 3 | + Slack | + MCP, multi-region probes |
business | $299/mo, $2990/yr | unlimited | unlimited | all | + 90-day alert history, audit export, priority support |
enterprise | contract | unlimited | unlimited | all | + self-hosted or your own data plane |
The trial. A new organization is trialing on the plan trial with
trialEndsAt = creation + 30 days (GET /v1/billing trialEndsAt,
trialDays). When trialEndsAt passes, the data-plane billing job makes
it read_only through the dunning mechanism below (zones keep resolving;
no sync, attachment or edit is applied; export, the domain auth code and
unlock, billing, legal and account deletion keep working;
402 org_read_only with details.reason: "trial_ended"), until a plan is
bought: a completed checkout (or an admin override to a paid plan) makes
it active at once. A read-only trial is detached after the detach period
like any read-only organization. The billing job e-mails the organization's
owners (category billing) once per milestone: the day the trial starts,
7, 3 and 1 days before trialEndsAt, and when it ends
(billing.json trialNotices; audited as billing.trialNotice). An
organization still on the retired free plan moves to a fresh trial
(trialEndsAt = now + 30 days, source: "migration", audited as
billing.migrate) the first time it is loaded, by the dashboard, the API
or the billing job; a defaultPlan: "free" in platform/config.json
reads as trial.
GET /v1/plans (public, Cache-Control: max-age=300, CORS *) returns the
catalog in the exact shape of the website's src/data/plans.json (the
website renders its pricing from it; a test checks every field of a copy
in internal/controlplane/testdata/website/):
- top level:
source("api"),status(plansStatusinplatform/config.json:"draft","coming_soon"or"published"; unset means"coming_soon", and the legacyplansPublished: truestill reads as"published"whenplansStatusis unset),updatedAt(YYYY-MM-DD),currency("USD"),intervals(the subscription intervals on sale, monthly first:billingIntervalsinplatform/config.json,"monthly"and optionally"yearly", must include"monthly",PUT /v1/admin/configanswers400 invalidBillingIntervalsotherwise; unset means["monthly"]),annualFreeMonths(the yearly discount, meaningful only whileintervalslists"yearly"),managed: {markupPercent, billing},grace: {readOnlyAfterDays, detachAfterDays}(days past due before read-only, then days read-only before every attachment is detached; 7 and 30 by default),trial: {days, limits: {zones, providersPerZone}}(the trial every new organization starts on: a note, not a plan; the dashboard also reads itsmodes,alerts,access,channelsandfeatures),plans(starter,pro,business,enterprise; never the trial); - per plan:
id,name,priceMonthly(US dollars,nullwhen custom),custom,highlight(only on the recommended plan),billing("annual"on contract plans),summary,limits: {zones, providersPerZone}(null= unlimited),modes(byo,managed,self-hosted,byo-data-plane),alerts(channel kinds,multi-region-probes,history-90d),access(dashboard,api,terraform,cli,mcp,audit-export,priority-support,contract),available(the plan can be bought now: every plan once the catalog is"published", none before; checkout answers409 plan_not_available).
Additions for the dashboard: per plan priceMonthlyCents,
priceYearlyCents (omitted unless intervals lists "yearly" and the
plan has a yearly price),
purchasable, channels, features; top-level
managedCosts (list prices per managed provider). Unlike this document,
GET /v1/billing and the admin config keep -1 for unlimited limits.
Entitlements. A limited action answers 402 plan_limit_reached with
details: {action, plan, limit?, current?, feature?, channel?, upgradeTo, upgradeName} (upgradeTo is the cheapest plan that allows it):
| Action | Checked on |
|---|---|
zone.create | POST /v1/zones (zones in the org vs limits.zones) |
zone.attach | POST /v1/zones/{id}/attachments (the zone's attachments vs limits.providersPerZone) |
channel.create | POST /v1/alerts/channels (the channel kind) |
connection.managed | managed connections and attaching them; the data plane also refuses a managed account on a plan without it (queued jobs after a downgrade) |
access.mcp | every /mcp request |
audit.export | GET /v1/audit/export (the audit-export feature) |
access.terraform, access.cli (and access.mcp for a PAT declared mcp) | token requests to /v1, by the token's declared client (see Token clients below), then by a User-Agent declaring terraform-provider-redundantdns/ or rdnsctl/ |
Features without a 402. multi-region-probes: on a plan without it
the data plane aggregates the probes of one region only (its own, or the
first fresh region by name), status.regionsLimitedTo names it and the
Health panel says so. history-90d: GET /v1/alerts/events (and the
alert_list tool) lists resolved events last seen within the plan's
window, 30 days or 90 with the feature (firing events always), and states
the window in the X-RDNS-History-Days header. priority-support: a flag
(prioritySupport in GET /v1/billing, next to alertHistoryDays,
multiRegionProbes and auditExport).
Billing status (org.billingStatus in /v1/me): active,
trialing (the trial is running), past_due (a payment failed: the
dashboard shows a banner, everything works), read_only (past due for
longer than the grace, RDNS_BILLING_GRACE, 7 days, or a trial that
ended: the providers keep serving the last
applied records, reads work, but 402 org_read_only answers the routes
that change providers: connections create/delete, zone create/delete,
records, attach/detach, adopt, reconcile; queued reconciles only verify),
detached (read-only for RDNS_BILLING_DETACH_AFTER, 30 days: the
data-plane billing job detached every attachment of every zone, without
deleting the provider zones, which keep serving the last applied records
until the delegation moves; the canonical zones keep their records, the
attachments' firing events are resolved, billing.detach is audited with
the list, and writes stay refused like read_only), canceled (the
subscription ended: there is no free plan to fall back to, so the org keeps
its last plan and is read-only like read_only, without the detach, until
it buys a plan again). 402 org_read_only carries details: {status, reason} with reason unpaid, trial_ended or canceled
(readOnlyReason in GET /v1/billing). Paying the invoice makes the org active
again; detached attachments are not restored by themselves: the Billing
page lists them (detached in GET /v1/billing, with an "Attach again"
button) and the user attaches each connection again with
POST /v1/zones/{zoneId}/attachments {connectionId, providerZoneId, adoptExisting: true, label}, which adopts the kept provider zone
(verified first, as any adopted zone) and keeps the attachment's label.
An attachment's label is optional at attach (up to 80 characters; the
connection's label is shown when it has none); rdnsctl attach --label
passes it.
| Route | Access | Purpose |
|---|---|---|
GET /v1/billing | viewer + zones:read | plan, status, readOnlyReason, the trial's end (trialEndsAt, trialDays; plan trial only), interval, next invoice date, grace end (graceEndsAt), detach date of a read-only org (detachAt), detached attachments still to re-attach (detached), limits with usage, the current period's usage snapshot and managed pass-through lines, gateway |
POST /v1/billing/checkout | admin, session | {plan, interval: monthly|yearly} (default monthly) -> {url} of the hosted checkout; 409 plan_not_available while the plan is not available (catalog draft or coming_soon); 400 intervalNotAvailable for an interval the catalog's intervals does not list |
POST /v1/billing/portal | admin, session | {url} of the customer portal (invoices, payment method, cancel); 409 billing_no_customer before a subscription |
PUT /v1/admin/tenants/{orgId}/plan | platform admin | {plan, status?, trialEndsAt?, note?}: manual override (enterprise deals; any plan, available or not, or trial), recorded on billing.json and audited as admin.tenant.plan; the next Stripe event of the org may change it again. plan: "trial" starts a trial (trialing, trialEndsAt defaults to now + 30 days; a past trialEndsAt makes it read-only at once); trialEndsAt with another plan answers 400 invalidTrialEndsAt; leaving the trial without a status makes the org active |
POST /webhooks/stripe | Stripe signature | Stripe-Signature verified with RDNS_STRIPE_WEBHOOK_SECRET (5-minute tolerance); handles checkout.session.completed (subscriptions, and one-off payments with mode=payment: domain registrations and renewals), checkout.session.async_payment_succeeded and checkout.session.expired (one-off payments), customer.subscription.created|updated|deleted, invoice.paid, invoice.payment_succeeded, invoice.payment_failed; idempotent by event id (platform/stripe-events/<id>.json); a failed event returns 5xx and is processed again on Stripe's retry |
Managed pass-through. Each managed attachment pays its provider's
zone-month plus its queries (from the adapter's QueryMetrics) per
million, at list price plus 20%, shown line by line on the Billing page.
The data plane refreshes the current period (a UTC calendar month) every
RDNS_BILLING_JOB_INTERVAL and reports a closed period once as a Stripe
Billing Meter event (RDNS_STRIPE_METER_EVENT, value in cents,
identifier rdns-usage-<org>-<period>), billed by the metered price
RDNS_STRIPE_PRICE_MANAGED_USAGE that checkout adds to plans with managed
accounts.
Stripe setup. Products and prices per plan and interval
(RDNS_STRIPE_PRICE_<STARTER|PRO|BUSINESS>_<MONTHLY|YEARLY>; the yearly
ones are needed only once billingIntervals lists "yearly"), a meter
plus metered price for the pass-through, the Customer Portal enabled,
and a webhook endpoint https://<host>/webhooks/stripe with the events
above. Checkout sets the org id as client_reference_id and as metadata
of the session and the subscription. Domain payments need nothing more in
the dashboard: their line is an ad-hoc price_data (no product or price
to create); only the webhook endpoint must include
checkout.session.completed (plus checkout.session.async_payment_succeeded
and checkout.session.expired if delayed payment methods are enabled).
Audit export
GET /v1/audit/export (admin + zones:read; plans with audit-export,
Business and above) returns every audit entry of a period, oldest first:
format=json (default: {orgId, from, to, truncated, entries}) or
format=csv (columns at, id, action, source, actorId, zoneId, targetType, targetId, targetLabel, ip, details, cells that a spreadsheet
would read as a formula prefixed with '). from and to (exclusive)
take RFC 3339 or YYYY-MM-DD (UTC); the default period is the last 90
days; zoneId narrows it to one zone. One export holds at most 50000
entries (X-RDNS-Truncated: true and truncated otherwise: export the
rest with a later from). Each export is audited as audit.export.
Audit stream
Every audit entry and zone journal entry is also appended to the org's
hash-chained, sealed audit stream (docs/audit.md).
| Route | Access | Purpose |
|---|---|---|
GET /v1/audit/stream/verify?from&to | admin + zones:read | verification report: {org, ok, segments, lines, sealed, signed, open, platformSeals, firstSeq, lastSeq, lastSeal, keys, problems, warnings}; from/to are inclusive days (YYYY-MM-DD, UTC; default the last 30 days, at most 366) |
GET /v1/audit/stream/export?from&to | admin + zones:read, plans with audit export | the evidence bundle (application/x-tar: segments, seals with signed messages and raw signatures, platform seals with the org's inclusion proofs, public keys, verify.sh, README); audited as audit.stream.export |
GET /v1/admin/audit/stream/verify?orgId&from&to | platform admin | one org's report (orgId=platform: the platform stream); without orgId, {ok, reports, platform} for every org plus the platform seal chain |
GET /v1/admin/audit/stream/export?orgId&from&to | platform admin | one org's bundle; audited as admin.audit.stream.export (platform and org) |
POST /v1/admin/audit/stream/seal | platform admin | {day?, orgId?}: seal on demand (today: close and seal the open segments; a past day: also its platform seal); idempotent; audited |
ok is false when a line or seal was changed, removed, added or reordered
(problem codes in docs/audit.md). Without an audit stream on the instance
the routes answer 503 auditStreamUnavailable. The MCP tool audit_verify
runs the org route.
Compliance
The live answer to "are we compliant right now?" (docs/compliance.md):
a profile (baseline, iso27001, soc2, or an extra profile of the
operator) runs its controls against the current state and returns
{format, profile, profileTitle, scope, orgId, status, checkedAt, summary, controls}; each control has id, title, status (pass, warn,
fail, not_applicable), required, evidence ([{name, value, at}]),
mapping ({iso27001, soc2}) and remediation. status is the worst
control; warn never makes a profile fail.
| Route | Access | Purpose |
|---|---|---|
GET /v1/compliance?profile&scope | admin + zones:read; scope=platform: platform admin session | run a profile now (read only). Default scope org: the organization's controls; platform: the installation's |
POST /v1/compliance/run?scope {profile} | same | run it and record it: the audit log (compliance.run, or admin.compliance.run in the platform log), a compliance line in the audit stream (each control's status and the report's SHA-256) and the last report; allowed while the license is degraded |
GET /v1/compliance/last?scope | same | {report}: the last report (daily check or last run), null before the first |
An unknown profile answers 400 unknownProfile (details.profiles lists
the loaded ones); scope other than org or platform answers 400 invalidScope. The MCP tool compliance_report runs the org route
(record: true uses the run route).
Security summary
GET /v1/security.json (public, cached, CORS *) feeds the website's
/security page in the exact shape of its src/data/security.json and
holds no secret; unknown values are null:
source("api"),generatedAt;encryption:algorithm(AES-256-GCM),scope,keyWrappers(the wrappers configured on this deployment:local,aws-kms,oci-vault),keyWrappersNote, pluskeyScheme,credentials,sessions;replication:replicas(replica targets with a ledger),healthy(no pending keys and no overflow on any target;nullwithout replicas),lastReconcileAt(the oldest target's), pluspendingKeys;restoreDrill:lastRunAt,result(passed/failed), plusdurationSeconds, fromplatform/drills/last.json(written byscripts/restore-drill.shthroughrdns drill-record);probes:regions(regions seen),uptime30d(fraction, 0 to 1, of the probe runs of the last 30 days where every nameserver answered, from the read index;nullwithout runs), plusactiveRegions(heartbeat in the last 15 minutes) andlastProbeAt;license: the self-hosted installation's license (nullon the hosted service);audit:stream(on/off),lastSeal,signed,kidandpublicKey(the audit signing key auditors pin),devKey, andfindings({level: warn, code, message}:audit_unsigned,audit_dev_key,audit_seal_failed). Which orgs failed verification is never public;compliance: the installation's last daily compliance check ({profile, status, checkedAt}, baseline profile;nulluntil it ran once,docs/compliance.md).
The probe-run history lives only in the read index (probe_runs, fed
when a region probe object is written, kept 31 days, preserved by
rdns doctor --reindex); in a split deployment the control plane's index
refresh picks up the region probe objects by ETag, so the history is
sampled at the refresh interval.
Licenses (self-hosted installations)
The platform issues the licenses of self-hosted installations
(docs/licensing.md). Records live in platform/licenses/<lid>.json;
the signing key only in the environment (RDNS_LICENSE_SIGNING_KEY,
RDNS_LICENSE_SIGNING_KID, RDNS_LICENSE_ACCOUNT_SALT). Every operator
action is in the platform audit (target type license).
| Route | Access | |
|---|---|---|
GET /v1/admin/licenses?acct= | platform admin | the licenses (without tokens): [{lid, acct, acctHash, status, claims, attestationName, createdAt, createdBy, updatedAt, updatedBy, statusChangedAt}]; acct filters one organization |
POST /v1/admin/licenses {claims} | platform admin | issue (201 {license, token, published?, publishError?, publish?}): claims.acct is the customer organization id (404 orgNotFound otherwise); defaults: lid generated, product rdns-enterprise, edition enterprise, mode offline, term yearly, nbf/iat now, exp one term later; acctHash is always derived (sha256(acct + salt)), kid is the signing key's. An online license's attestation is published at once (published: false with publishError when the data plane could not, e.g. license_zone_missing: the hourly pass catches up). Audit license.issue. 503 license_issuer_unavailable without a signing key; 400 invalidClaims |
POST /v1/admin/licenses/{lid}/status {status} | platform admin | active, suspended (the installation degrades after its grace period) or revoked (degrades at once); publishes the attestation now. Audit license.status ({from, to}); the same status again changes nothing. 400 invalidLicenseStatus, 404 licenseNotFound |
GET /v1/admin/licenses/{lid}/token | platform admin | {lid, token}: the signed license to hand to the customer (audit license.token.reveal) |
GET /v1/me/licenses | any member; tokens need zones:read | the licenses of the caller's organizations (a token: its own organization's), never the token: [{lid, orgId, status, product, edition, mode, term, features, limits, graceDays, issuedAt, notBefore, expiresAt, statusChangedAt}]. MCP tool license_list |
The attestation publisher (data plane) writes one managed TXT set per
account (managedBy: "license", read-only: 409 recordSetManaged) into
the attestation zone, an ordinary zone of the operator's organization:
<first 32 hex of acctHash>.<zone>, TTL 300, one string per online
license (v=rdns1; lid=...; st=...; iat=...; exp=...; kid=...; sig=...). It re-signs an attestation after 7 days (hourly pass in the
lease of the zone's organization) and at once on issue or status change
(internal API POST /internal/licenses/publish), then queues a reconcile
of every attachment. 409 license_zone_missing until the operator
creates the zone.
/v1/security.json and /v1/me (license) of a licensed installation
add, for online licenses, attestation (ok, stale, missing,
suspended, revoked, not_checked), attestationSource (dns,
https, cache), attestationIssuedAt, attestationExpiresAt,
attestationAgeSeconds and attestationCheckedAt; the license section
is never cached.
Errors
Every error is { "error": "<code>", "message": "<English>", "details"?: … }
with an HTTP status; the code is stable (the SPA maps it to
errors.<code>). OAuth endpoints use the RFC 6749 shape
{ "error", "error_description" }. Billing errors: 402 plan_limit_reached and 402 org_read_only (above), 409 plan_not_available (checkout of a plan not open yet), 503 billing_unavailable (no gateway configured). Managed mode: 428 managed_terms_required (details: {version, url}), 409 legalVersionMismatch. Zones: 409 recordSetManaged, 422 parentZoneNotFound. Domains: 428 domain_terms_required, 409 domainNameTaken, 409 domainExists, 409 domainTransferInProgress,
409 contactInUse, 409 domainNotRemovable, 422 registrantProfileRequired,
422 registrarRejected, 422 zoneNsPlanEmpty, 400 invalidDomainName,
400 invalidNameservers, 400 invalidContact, 404 domainNotFound,
404 contactNotFound, 503 registrarUnavailable. Registration: 409 domainUnavailable, 409 domainRegistrationPending, 409 domainNotRetryable, 409 domainRenewalInProgress, 422 domainPremium,
422 invalidDomainName (check), 400 invalidYears, 400 skipPaymentRequired (admin renew), 410 checkoutExpired (fake checkout
page), 503 billing_unavailable. Licenses: 503 license_issuer_unavailable,
400 invalidClaims, 400 invalidLicenseStatus, 404 licenseNotFound,
409 licenseExists, 503 license_degraded (a degraded installation).
Examples
# list zones with a PAT
curl -s https://<host>/v1/zones -H "Authorization: Bearer rdns_..."
# create a record set
curl -s -X PUT https://<host>/v1/zones/<zoneId>/records \
-H "Authorization: Bearer rdns_..." -H 'Content-Type: application/json' \
-d '{"name":"www","type":"A","ttl":300,"values":["192.0.2.10"]}'