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. 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.
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) |
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: 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:
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):
| Plan | Price | Zones | Providers per zone | Alert channels | Features |
|---|---|---|---|---|---|
free (every new org) | $0 | 1 | 2 | API | |
starter | $29/mo, $290/yr | 10 | 2 | email, webhook | + Terraform provider, CLI, managed accounts |
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 |
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"untilplansPublished: trueinplatform/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,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).
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):
| 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, 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.
| Route | Access | Purpose |
|---|---|---|
GET /v1/billing | viewer + zones:read | plan, 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/checkout | admin, session | {plan, interval: monthly|yearly} -> {url} of the hosted checkout |
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?, 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/stripe | Stripe signature | Stripe-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, 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.
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"]}'