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. 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.

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)

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: plain text through the login SMTP settings (RDNS_SMTP_*).
  • 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):

PlanPriceZonesProviders per zoneAlert channelsFeatures
free (every new org)$012emailAPI
starter$29/mo, $290/yr102email, webhook+ Terraform provider, CLI, managed accounts
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

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 ("draft" until plansPublished: true in platform/config.json, then "published"), updatedAt (YYYY-MM-DD), currency ("USD"), annualFreeMonths, 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), plans;
  • 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).

Additions for the dashboard: per plan priceMonthlyCents, priceYearlyCents, 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, 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: 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: back to Free). 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, 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} -> {url} of the hosted checkout
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?, note?}: manual override (enterprise deals), recorded on billing.json and audited as admin.tenant.plan; the next Stripe event of the org may change it again
POST /webhooks/stripeStripe signatureStripe-Signature verified with RDNS_STRIPE_WEBHOOK_SECRET (5-minute tolerance); handles checkout.session.completed, 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>), 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.

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.

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.

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.

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), 503 billing_unavailable (no gateway configured). Managed mode: 428 managed_terms_required (details: {version, url}), 409 legalVersionMismatch. Zones: 409 recordSetManaged, 422 parentZoneNotFound.

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"]}'