Quickstart
Five minutes from nothing to a verified record. Create an account at /app, then mint a key under Account → API keys (see Creating an API key).
export K="Authorization: Bearer dnsv_your_key_here"
export B="https://dns-config.net"
# 1. A customer. (Every account starts with one called "default" —
# skip this if you don't need the customer tier.)
curl -sX POST $B/api/admin/customers -H "$K" \
-H 'Content-Type: application/json' \
-d '{"id":"acme","name":"Acme Corp"}'
# 2. The record they must publish.
curl -sX POST $B/api/admin/customers/acme/records -H "$K" \
-H 'Content-Type: application/json' \
-d '{"domain":"acme.com","hostname":"em.acme.com",
"type":"CNAME","value":"u7.wl.sendgrid.net"}'
# 3. Check it against live DNS.
curl -sX POST $B/api/admin/customers/acme/verify -H "$K"
# 4. A link your customer can open to fix it themselves.
curl -sX POST $B/api/admin/customers/acme/onboarding-link -H "$K"
After that, a scheduled sweep re-verifies on its own and alerts you when a passing record drifts. You do not need to poll.
Authentication
Every request to /api/admin/** carries a per-tenant API key as a
bearer token:
curl -s https://dns-config.net/api/admin/customers \
-H "Authorization: Bearer dnsv_xK3…"
The key resolves to exactly one account, and that identity scopes every
operation — so an id belonging to somebody else simply does not resolve and
returns 404. We never answer 403 there, because that
would confirm the id exists.
Keys are transmitted over HTTPS only, stored as a SHA-256 hash, and never returned by any read endpoint.
Creating an API key
Sign in to the console, open Account → API keys, give the key a name, tick the scopes it needs, and press Mint key. The plaintext key is shown once.
There is also an API for it, but note what it will not accept:
POST /api/admin/api-keys
{ "name": "ci-pipeline", "scopes": ["write"] }
→ 201
{ "id": "3f9c…", "name": "ci-pipeline", "scopes": ["write"],
"created_at": "2026-09-10T09:12:44Z", "legacy": false,
"api_key": "dnsv_xK3…" }
/api/admin/api-keys endpoints authenticate with the console session
cookie and reject bearer-token auth with 403. If a leaked key could
mint itself a wider one, stealing a read-only key would be as good as stealing
everything — so managing keys always costs control of the account's mailbox, not
just possession of a key. In practice: mint keys in the console.
Scopes
Each key carries a subset of three scopes. They are split by blast radius rather
than by resource, so the usual shapes fall out naturally: a status dashboard gets
read, a provisioning job gets write, and neither can
touch your stored credentials.
| Scope | Grants |
|---|---|
read | Read customers, records, verification runs, TLS status and usage. |
write | Create and change customers, records, TLS endpoints and policy; trigger verification; mint customer links. Includes read. |
secrets | Read and write alert configuration and vendor connections. |
write implies read — a provisioning key that cannot read
back what it wrote is broken for every realistic caller. secrets
implies nothing and is implied by nothing.
There is no billing scope. Subscriptions are managed in the console, not over the API — so no key you mint can reach your payment details, by construction.
secrets covers reads as well as writes because
GET /api/admin/alert-config returns your Slack and webhook URLs in
plaintext, and those are bearer credentials in their own right.
A call outside a key's scopes returns 403 naming the scope it lacks:
{ "detail": "api key lacks the 'write' scope" }
Rotating a key without downtime
Keys are additive — up to 20 per account — so rotation has no gap in service:
- Mint the replacement.
- Deploy it.
- Revoke the old one (Revoke in the console, or
DELETE /api/admin/api-keys/{id}).
Revocation takes effect immediately.
If your account was set up for you, you may also have a key marked
legacy in the list. It predates scopes and carries all of them —
narrowing it silently would break whatever is already using it. Revoke it once
your scoped keys are deployed.
Data model
Your account
└── Customer "acme" one per end-customer
├── Record em.acme.com CNAME u7.wl.sendgrid.net
├── Record acme.com TXT "v=spf1 include:…"
└── TLS endpoint acme.com:443 Pro and up
Records are expected state — what you declare a customer ought to publish. Verification resolves each one against live DNS and reports the difference.
Every account is created with one implicit customer called
default. If you don't need the customer tier, address
/api/admin/customers/default/records and ignore it.
Customers & records
A customer is a container with an id you choose and a display name. A
record is one expected DNS entry beneath it: a hostname of a
given type that must resolve to a given value, within a
domain.
Deleting a customer deletes its records and run history. Field-by-field shapes for both are in Objects.
Verification
POST /api/admin/customers/{id}/verify resolves every record and
returns them grouped by domain. Each record echoes its configured fields and adds:
| Field | Meaning |
|---|---|
ok | true when a quorum of resolvers see a match. |
actual | What actually resolved (may be empty). |
propagation | {state, agree, responded, resolvers} — how confident the verdict is. |
reason | Present when ok is false. See below. |
hint | A human-readable fix instruction you can pass straight to a customer. |
Why a quorum
Each record is queried in parallel across several independent public resolvers, and
counts as ok only when a strict majority of those that answered
agree. Resolvers that error are dropped from the vote rather than poisoning it.
This is why a record you just changed reports PROPAGATING instead of
failing: one stale cache cannot produce a false alarm, and a partial resolver
outage degrades gracefully instead of paging you.
Failure reasons
| Reason | Meaning |
|---|---|
NXDOMAIN | The name does not resolve at all. |
NO_ANSWER | The name resolves, but has no record of that type. |
WRONG_VALUE | A record of the right type exists, pointing somewhere else. |
PARTIAL | An SPF/DKIM/DMARC record is published but incomplete; the hint names what's missing. |
RESOLVER_ERROR | The lookup failed everywhere. Usually transient. |
PROPAGATING | Matched on some resolvers but not yet a quorum. Re-check shortly. |
Run history is kept per customer at
GET /api/admin/customers/{id}/runs.
Customer links
Rather than relaying DNS instructions yourself, mint a signed link and send it to the customer:
POST /api/admin/customers/acme/onboarding-link
→ { "url": "https://dns-config.net/acme?t=eyJ…",
"expires_at": "2026-10-10T09:12:44Z" }
They open it with no account and no password, see exactly their own records with copy buttons, press Re-check, and — where their DNS provider supports Domain Connect — get a one-click apply button.
Vendor auto-pull
Instead of declaring records by hand, connect the vendor account that generates them and let each sweep re-pull:
| Vendor | Pulls |
|---|---|
| SendGrid | Per-subuser domain-authentication CNAMEs |
| Mailgun | Sending-domain records |
| Cloudflare for SaaS | Custom-hostname validation records |
| AWS Certificate Manager | Certificate validation CNAMEs |
| Fastly | TLS validation records |
The connection object
A connection is source plus two free-form maps:
credentials (the vendor secret — write-only) and config
(everything else). Which keys go in each depends on the vendor; see the table
below.
POST /api/admin/vendor-connections
{
"source": "sendgrid",
"credentials": { "api_key": "SG.xxxx" },
"config": { "only_valid": false },
"enabled": true
}
→ 201
{ "id": "9c2f…", "tenant_id": "acme", "source": "sendgrid",
"config": { "only_valid": false }, "enabled": true,
"created_at": "2026-09-10T09:12:44Z",
"has_credentials": true }
Note what comes back: has_credentials, never the credentials. They are
encrypted before storage and no read path can recover them — the same one-way
treatment as an API key.
On PATCH, omitting credentials leaves the stored secret
untouched; sending {} clears it. So you can flip
enabled or edit config without re-sending the key.
Per-vendor fields
Required fields are marked; everything else is optional and has a sensible
default. config keys not listed here are ignored.
source | credentials | config |
|---|---|---|
sendgrid |
api_key required |
single_account (bool) — skip subuser discovery and pull the account's own domainssubusers (list) — restrict to theseinclude_disabled (bool, default false) — also pull disabled subusersonly_valid (bool, default false) — emit only CNAMEs SendGrid already marks valid |
mailgun |
api_key required |
region — us (default) or eusingle_account (bool) — skip subaccount enumerationsubaccounts (list) — restrict to thesedomains (list) — restrict to these |
cloudflare_saas |
api_token required |
zone_id required — the zone whose custom hostnames to pullper_page (int) — pagination size |
acm |
access_key_idsecret_access_keysession_token (for temporary credentials)Omit all three to use the ambient AWS credential chain. |
region — AWS regioncertificate_arns (list) — pin exact certificates instead of listingcertificate_statuses (list) — filter the listing, e.g. ["PENDING_VALIDATION"] |
fastly |
api_token required |
page_size (int) — pagination size |
Setting one up for the first time? Step-by-step guides walk through the API key, adding a domain, and the first pull: SendGrid · Mailgun.
SendGrid subusers are a Pro-and-above feature. On a plan without them the puller
falls back to the account's own domain authentications automatically — you do not
need to set single_account unless you want to skip the lookup.
base_url, or
api_base for Cloudflare and Fastly) for testing. It is confined to
that vendor's own API host — a base pointing anywhere else is refused, so a
mistyped or hostile value cannot send your credentials to a third party.
Pulling on demand
A scheduled sweep re-pulls every connection hourly, but you rarely want to wait for it after entering a credential:
POST /api/admin/vendor-connections/{id}/sync
→ { "tenant": "acme", "source": "sendgrid",
"added": 3, "updated": 0, "retired": 0, "unchanged": 0 }
Idempotent — a second call reports everything as unchanged. Records
the vendor stops reporting are retired, because the vendor is the
source of truth for records it owns. Records you entered by hand are never
touched: reconciliation is scoped to the connection's own source tag.
422 means no puller is registered for that source,
409 that the connection is disabled, and 502 that the
vendor API could not be reached.
Managing connections, and pulling, needs the secrets scope.
Objects
Every shape the tenant API accepts and returns. Timestamps are ISO 8601 UTC
strings. A field marked req is required on create; anything else may be
omitted and takes the stated default. PATCH bodies accept any subset
of the mutable fields — omitted keys are left unchanged.
Customer
| Field | Type | Notes |
|---|---|---|
id | string | req on create. Yours to choose; it appears in the customer's link. |
name | string | req. Display name. |
tenant_id | string | Read-only. |
created_at | timestamp | Read-only. |
health | object | Read-only rollup of the latest run — see below. |
health (also the body of GET /api/health/{cid}):
| Field | Type | Notes |
|---|---|---|
status | string | all-ok, partial, failing, or unknown (never verified). |
last_verified_at | timestamp | null | null before the first run. |
passing / total | int | Records passing, out of how many. |
Record
| Field | Type | Notes |
|---|---|---|
domain | string | req. The zone the record belongs to. |
hostname | string | req. The fully-qualified name the record lives at. |
type | string | req. CNAME, TXT, MX, A, … |
value | string | req. What it must resolve to. |
priority | int | null | Default null. For MX. |
source | string | Default "manual". Vendor auto-pull sets its own tag. |
id, tenant_id, customer_id | string | Read-only. |
Verification run
Returned by POST …/verify. GET …/runs returns the same
object without records.
| Field | Type | Notes |
|---|---|---|
started_at | timestamp | When the run began. |
status | string | Same four values as health.status. |
passing / total | int | Records passing, out of how many. |
records | array | Per-record results — see below. |
record result:
| Field | Type | Notes |
|---|---|---|
record_id | string | Which record this is about. |
hostname, type | string | Echoed from the record. |
expected | string | The value you declared. |
ok | bool | Quorum of resolvers saw a match. |
current | array<string> | What actually resolved. Empty when nothing does. |
reason | string | null | Set when ok is false — see Failure reasons. |
hint | string | null | Human-readable fix instruction. |
GET /api/verify/{cid}?t= returns a richer per-record
shape than the admin one: actual instead of current, plus
a propagation object {state, agree, responded, resolvers}
reporting how many resolvers agreed out of how many answered. The admin endpoint
gives you the verdict; the customer page gives the evidence behind it.
Onboarding link
| Field | Type | Notes |
|---|---|---|
url | string | The signed link to hand your customer. |
expires_at | timestamp | null | 30 days out. |
TLS endpoint
| Field | Type | Notes |
|---|---|---|
hostname | string | req. |
port | int | Default 443. |
id | string | Read-only. |
source | string | Read-only. Explicitly added, or derived from a record. |
TLS policy
One per tenant, at GET/PUT /api/admin/tls-policy.
| Field | Type | Notes |
|---|---|---|
enabled | bool | Default false. |
expiry_warning_days | int | Default 14. Days before expiry to start failing. |
min_protocol | string | Default "TLS1.2". |
require_trusted_chain | bool | Default true. |
expected_issuer_cn | string | null | Pin the issuer common name. |
expected_fingerprint_sha256 | string | null | Pin an exact certificate. |
TLS run
POST …/tls-verify and GET …/tls return
{started_at, status, ok, total, endpoints[]};
GET …/tls-runs omits endpoints. Each endpoint result:
| Field | Type | Notes |
|---|---|---|
endpoint_id, hostname, port | string / int | What was probed. |
ok | bool | Passed the policy. |
reason | string | Why not, when ok is false. |
hint | string | null | Human-readable fix instruction. |
not_after | timestamp | null | Certificate expiry. |
days_remaining | int | null | Days until expiry. Negative once expired. |
issuer, subject | string | null | Certificate identity. |
sans | array<string> | Subject alternative names. |
negotiated_protocol | string | null | e.g. TLSv1.3. |
fingerprint | string | null | SHA-256 of the leaf certificate. |
Alert config
One per tenant, at GET/PUT /api/admin/alert-config.
Needs the secrets scope — the read returns the webhook URLs in
plaintext.
| Field | Type | Notes |
|---|---|---|
channels | array<string> | Any of email, slack, webhook. Default empty. |
failure_threshold | int ≥ 1 | Default 1. Consecutive failing runs before alerting. |
email_to | array<string> | Recipients. Default empty. |
smtp_host | string | Default "". |
smtp_port | int | Default 25. |
email_from | string | Envelope sender. |
slack_webhook | string | Incoming-webhook URL. Encrypted at rest. |
webhook_url | string | Your own HTTPS endpoint. Encrypted at rest. |
tenant_id | string | Read-only. |
Webhook and SMTP targets must be https (or a reachable relay) and must
resolve to a public address — internal and link-local targets are refused at save
time and again at send time, so a DNS rebind cannot smuggle one through.
Vendor connection
See Vendor auto-pull for the object and its per-vendor fields.
Sync summary
Returned by POST /api/admin/vendor-connections/{id}/sync.
| Field | Type | Notes |
|---|---|---|
tenant | string | Whose records were reconciled. |
source | string | The vendor tag pulled. |
added | int | Expected records the vendor reported that you did not have. |
updated | int | Stored records whose value drifted from the vendor's. |
retired | int | Records of this source the vendor no longer reports — deleted. |
unchanged | int | Already correct. A second pull reports everything here. |
API key
| Field | Type | Notes |
|---|---|---|
name | string | req. 1–64 characters. Names the integration in your audit trail. |
scopes | array<string> | req, non-empty. Subset of read, write, secrets. |
id | string | Read-only. Pass to DELETE to revoke. |
created_at | timestamp | Read-only. |
legacy | bool | Read-only. The pre-scopes key; carries every scope. |
api_key | string | Mint response only. The plaintext, shown once. |
Usage
GET /api/admin/usage:
| Field | Type | Notes |
|---|---|---|
tier | object | null | {id, name}, or null on no assigned tier. |
limits | object | Your effective caps — see below. null for any limit means unlimited. |
usage | object | Live counts, keyed by the same limit names. |
limits and usage share these keys:
| Key | Caps |
|---|---|
max_customers | Customers you may create. |
max_records_per_customer | Records per customer. usage reports the peak across customers. |
max_vendor_connections | Connected vendor accounts. |
min_monitor_interval_sec | Floor on re-check frequency. No usage counterpart. |
max_registrable_domains | Distinct apex domains, not records. |
max_tls_endpoints | TLS endpoints per customer. 0 means the plan excludes TLS monitoring. |
Errors
Failures carry a detail string:
{ "detail": "api key lacks the 'write' scope" }
A tripped plan limit is the one exception — a flat, machine-readable body:
{ "error": "limit_exceeded", "limit": "max_customers",
"current": 25, "max": 25 }
Errors & limits
| Status | Means |
|---|---|
400 | Malformed body — including an unknown scope, which is rejected rather than silently dropped. |
401 | Missing, malformed or unrecognised key. |
403 | Authenticated but not permitted: a missing scope, a tripped plan limit, or a missing CSRF token on a session call. |
404 | Unknown id — or one belonging to another account. |
429 | Rate-limited (sign-in and sign-up). |
503 | A platform feature isn't configured on this deployment. |
A tripped plan limit returns a flat, machine-readable body:
{ "error": "limit_exceeded", "limit": "max_customers",
"current": 25, "max": 25 }
Your plan caps customers, records per customer, vendor connections, distinct apex
domains, TLS endpoints, and monitoring frequency.
GET /api/admin/usage returns your live counts against each cap —
the same numbers the enforcement paths check, so it shows exactly what will trip.
The apex-domain cap counts distinct domains, not records: adding more records to a domain you already use never consumes a slot.
Endpoint reference
All paths are relative to https://dns-config.net.
Customers & records
| GET POST | /api/admin/customers | List / create |
| GET PATCH DEL | /api/admin/customers/{cid} | Get / update / delete |
| GET POST | /api/admin/customers/{cid}/records | List / create |
| GET PATCH DEL | /api/admin/customers/{cid}/records/{id} | Get / update / delete |
Verification & links
| POST | /api/admin/customers/{cid}/verify | Verify now |
| GET | /api/admin/customers/{cid}/runs | Run history |
| POST | /api/admin/customers/{cid}/onboarding-link | Mint a customer link |
TLS — Pro and up
| GET POST | /api/admin/customers/{cid}/tls-endpoints | List / add |
| DEL | /api/admin/customers/{cid}/tls-endpoints/{id} | Remove |
| POST | /api/admin/customers/{cid}/tls-verify | Probe now |
| GET | /api/admin/customers/{cid}/tls | Latest result |
| GET | /api/admin/customers/{cid}/tls-runs | Probe history |
| GET PUT | /api/admin/tls-policy | Get / set policy |
Alerts & vendors — secrets scope
| GET PUT | /api/admin/alert-config | Get / set alert channels |
| GET POST | /api/admin/vendor-connections | List / create |
| GET PATCH DEL | /api/admin/vendor-connections/{id} | Get / update / delete |
| POST | /api/admin/vendor-connections/{id}/sync | Pull this connection now |
Account
| GET | /api/admin/usage | Plan, limits, live usage |
| GET POST | /api/admin/api-keys | List / mint (session only) |
| DEL | /api/admin/api-keys/{id} | Revoke (session only) |
Public — signed customer links, no key
| GET | /api/customers/{cid}?t= | The customer's records |
| GET | /api/verify/{cid}?t= | Live verification |
| GET | /api/health/{cid}?t= | Health rollup |
| GET | /api/badge/{cid}.svg?t= | Status badge |