Skip to content
RedundantDNS
Menu

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

MethodHowNotes
Dashboard sessionrdns_session cookie from POST /auth/code + POST /auth/verifyevery scope; limited by the user's role
Personal access tokenAuthorization: Bearer rdns_<org>_<id>_<secret>bound to one org, scopes, optional IP allowlist and expiry; created in the dashboard
OAuth 2.1 access tokenAuthorization: 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.

EndpointPurpose
POST /oauth/registerdynamic 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/authorizeresponse_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/decisionJSON {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/tokenauthorization_code (with code_verifier) and refresh_token (optional narrower scope)
POST /oauth/revokeRFC 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) with sub, 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.

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:

RouteAccessWhat it does
GET /v1/legal/managedviewer + 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, deleted or unknown (not synced yet).
  • registration is null for a domain that was not registered through the platform (see Registering a domain); renewal is the last paid renewal.
  • transfer is null for a domain that was not transferred in; state is pending, completed or failed.
  • zone is set when the organization has a zone with the same name: nameserversMatch compares the domain's nameservers with the zone's NS plan (as sets, case-insensitive), delegationState is the zone's last delegation check.
  • pendingJobs are the registrar_apply jobs 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

RouteAccessWhat it does
GET /v1/domainsviewer + domains:readthe organization's domains
POST /v1/domains/transfer {name, authCode, contactId?, nameservers?, applyZoneNs?, autoRenew?, acceptDomainTerms?}admin + domains:writetransfer 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=1viewer + domains:readavailability and price of up to 20 names (see Registering a domain)
POST /v1/domains/register {name, years?, contactId?, nameservers?, applyZoneNs?, autoRenew?, acceptDomainTerms?}admin + domains:writeregister a new domain, paid through a one-off checkout (201 {domain, checkoutUrl})
POST /v1/domains/{name}/register/retryadmin + domains:writerun the register job again for a paid registration that failed (mutation result; 409 domainNotRetryable otherwise)
GET /v1/domains/{name}viewer + domains:readone domain
DELETE /v1/domains/{name}admin + domains:writeforget 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}/transferviewer + domains:read{name, status, transfer}
POST /v1/domains/{name}/synceditor + domains:writerefresh status, expiry, nameservers, lock and transfer progress from the registrar now
PUT /v1/domains/{name}/nameservers {nameservers}editor + domains:writeset the nameservers (2 to 13 host names)
POST /v1/domains/{name}/nameservers/apply-zone {zoneId?}editor + domains:writewrite 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:writetransfer lock on or off
PUT /v1/domains/{name}/autorenew {autoRenew}admin + domains:writeauto-renewal on or off
POST /v1/domains/{name}/renew {years?}admin + domains:writerenew 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}/authcodeadmin + 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:writechange the registrant (trade) to another contact; confirmName is the domain name
GET /v1/domains/contactsviewer + domains:readcontacts
POST /v1/domains/contactsadmin + domains:writecreate a contact (201)
PUT /v1/domains/contacts/{contactId}admin + domains:writereplace a contact's fields (its registrar handles are updated by a job)
DELETE /v1/domains/contacts/{contactId}admin + domains:writedelete a contact no domain uses and that is not the registrant profile (409 contactInUse)
GET /v1/domains/registrant-profileviewer + domains:read{profile: Contact | null}
PUT /v1/domains/registrant-profileadmin + domains:writecreate or replace the registrant profile (the default contact)
GET /v1/domains/exportadmin + domains:readexport everything: {exportedAt, org, contacts, domains, zones: [{zoneId, name, zoneFile}]}
GET /v1/legal/domainsviewer + domains:readthe organization's acceptance of the Domain Registration Terms (same body as /legal/managed)
POST /v1/legal/domains/accept {version}admin + domains:writeaccept them for the organization (audit org.domainTerms.accept)
GET /v1/admin/domainsplatform adminevery 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 adminattach 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 adminthe 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 adminrenew 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).

  1. 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 invalidDomainName for an invalid name, 503 registrarUnavailable without a registrar (RDNS_REGISTRAR=none).
  2. POST /v1/domains/register checks the terms (428 domain_terms_required, or acceptDomainTerms), the plan (action domain.register), the owner (422 registrantProfileRequired) and the name (409 domainUnavailable, 422 domainPremium: premium names are registered on request), claims domain-names/<name>, writes the domain with status payment_pending and a registration record {years, priceCents, currency, checkoutSessionId, checkoutUrl, contactId, nameservers, applyZoneNs, autoRenew, requestedAt, paidAt, paidBy, paymentIntentId, completedAt, error}, and returns 201 {domain, checkoutUrl}. The checkout returns to /domains/<name>?checkout=success|canceled. 503 billing_unavailable with RDNS_BILLING=off.
  3. The webhook checkout.session.completed (or checkout.session.async_payment_succeeded for a delayed method) with mode=payment and our metadata (orgId, purpose: domain_register, domainName, years) marks the registration paid (paidAt, paidBy: "stripe", paymentIntentId), sets registering and runs the registrar_apply job register: nameservers are the given ones, else the NS plan of the org's zone with the same name when applyZoneNs, else the registrar's default. Success sets active with 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.
  4. A rejection (or a transient failure after its last retry) sets registration_failed with the error and fires the alert domain_registration_failed (no zone; the event's zoneName is 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 (the paymentIntentId is on the registration and in the audit entry domain.register.paid).
  5. 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 on checkout.session.expired). A payment that matches no open registration (released meanwhile) is logged and audited as domain.payment.orphan for 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.

RuleFires whenResolves whenThreshold
driftan attachment's state is driftit is in_sync again
sync_erroran attachment's state is errorthe provider answers (in_sync or drift)
delegation_brokenthe delegation is partial or mismatch, or went from complete back to pending (delegation.lostAt)it is complete
provider_downno nameserver of an attachment answered N consecutive probes, from at least minRegions regionsfewer than minRegions regions see it downN (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 othersevery region agrees
zone_serial_staleno successful verify of an attachment for N hoursa verify succeedsN (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.

RouteAccessPurpose
GET /v1/alerts/rulesviewer + zones:readrules with their effective thresholds
PUT /v1/alerts/rules/{rule}admin + zones:write{enabled, threshold, minRegions} (minRegions: provider_down only)
GET /v1/alerts/channelsviewer + zones:readchannels; secrets are never returned
POST /v1/alerts/channelsadmin + zones:write{kind: email|webhook|slack, label, target, secret?}; a webhook's signing secret is returned once
DELETE /v1/alerts/channels/{channelId}admin + zones:writeremove a channel
POST /v1/alerts/channels/{channelId}/testadmin + zones:writesend a test notification now ({ok, attempts, error})
GET /v1/alerts/eventsviewer + zones:readhistory, newest first; zoneId, rule, state, limit
POST /v1/alerts/events/{eventId}/resolveeditor + zones:writeresolve a firing event by hand
POST /v1/alerts/events/{eventId}/ackeditor + zones:writeacknowledge a firing event
GET /v1/admin/alertsplatform adminper 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: POST of 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 headers X-RDNS-Event (the type), X-RDNS-Delivery (<eventId>-<channelId>-<state>, stable across retries) and X-RDNS-Signature: sha256=<hex HMAC-SHA256 of the raw body with the channel secret>. Network errors, 429 and 5xx are retried 3 times with backoff (1 s, 4 s, 10 s); other 4xx fail at once. Redirects are not followed. Outside dev mode the URL must be https and 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:

PlanPriceZonesProviders per zoneAlert channelsFeatures
trial (every new org, 30 days; not sold, not listed in plans)no charge12emailAPI, BYO accounts only, one domain
starter$9/mo (monthly only)32email, webhook+ Terraform provider, CLI, managed accounts, domains
pro$99/mo, $990/yr503+ Slack+ MCP, multi-region probes
business$299/mo, $2990/yrunlimitedunlimitedall+ 90-day alert history, audit export, priority support
enterprisecontractunlimitedunlimitedall+ 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 (plansStatus in platform/config.json: "draft", "coming_soon" or "published"; unset means "coming_soon", and the legacy plansPublished: true still reads as "published" when plansStatus is unset), updatedAt (YYYY-MM-DD), currency ("USD"), intervals (the subscription intervals on sale, monthly first: billingIntervals in platform/config.json, "monthly" and optionally "yearly", must include "monthly", PUT /v1/admin/config answers 400 invalidBillingIntervals otherwise; unset means ["monthly"]), annualFreeMonths (the yearly discount, meaningful only while intervals lists "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 its modes, alerts, access, channels and features), plans (starter, pro, business, enterprise; never the trial);
  • per plan: id, name, priceMonthly (US dollars, null when 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 answers 409 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):

ActionChecked on
zone.createPOST /v1/zones (zones in the org vs limits.zones)
zone.attachPOST /v1/zones/{id}/attachments (the zone's attachments vs limits.providersPerZone)
channel.createPOST /v1/alerts/channels (the channel kind)
connection.managedmanaged connections and attaching them; the data plane also refuses a managed account on a plan without it (queued jobs after a downgrade)
access.mcpevery /mcp request
audit.exportGET /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.

RouteAccessPurpose
GET /v1/billingviewer + zones:readplan, 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/checkoutadmin, 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/portaladmin, session{url} of the customer portal (invoices, payment method, cancel); 409 billing_no_customer before a subscription
PUT /v1/admin/tenants/{orgId}/planplatform 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/stripeStripe signatureStripe-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).

RouteAccessPurpose
GET /v1/audit/stream/verify?from&toadmin + zones:readverification 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&toadmin + zones:read, plans with audit exportthe 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&toplatform adminone 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&toplatform adminone org's bundle; audited as admin.audit.stream.export (platform and org)
POST /v1/admin/audit/stream/sealplatform 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.

RouteAccessPurpose
GET /v1/compliance?profile&scopeadmin + zones:read; scope=platform: platform admin sessionrun a profile now (read only). Default scope org: the organization's controls; platform: the installation's
POST /v1/compliance/run?scope {profile}samerun 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?scopesame{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, plus keyScheme, credentials, sessions;
  • replication: replicas (replica targets with a ledger), healthy (no pending keys and no overflow on any target; null without replicas), lastReconcileAt (the oldest target's), plus pendingKeys;
  • restoreDrill: lastRunAt, result (passed/failed), plus durationSeconds, from platform/drills/last.json (written by scripts/restore-drill.sh through rdns 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; null without runs), plus activeRegions (heartbeat in the last 15 minutes) and lastProbeAt;
  • license: the self-hosted installation's license (null on the hosted service);
  • audit: stream (on/off), lastSeal, signed, kid and publicKey (the audit signing key auditors pin), devKey, and findings ({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; null until 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).

RouteAccess
GET /v1/admin/licenses?acct=platform adminthe licenses (without tokens): [{lid, acct, acctHash, status, claims, attestationName, createdAt, createdBy, updatedAt, updatedBy, statusChangedAt}]; acct filters one organization
POST /v1/admin/licenses {claims}platform adminissue (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 adminactive, 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}/tokenplatform admin{lid, token}: the signed license to hand to the customer (audit license.token.reveal)
GET /v1/me/licensesany member; tokens need zones:readthe 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"]}'