Developer documentation

The DNSconfig API

Declare the DNS records your customers must publish, verify them against live DNS worldwide, and hand each customer a self-serve fix link — from your own provisioning flow, with a scoped API key.

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.

Copy it immediately. Only a hash is stored. If you lose the key there is no recovery path — mint a replacement and revoke the old one.

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…" }
Key management needs a signed-in session, not a key. The /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.

ScopeGrants
readRead customers, records, verification runs, TLS status and usage.
writeCreate and change customers, records, TLS endpoints and policy; trigger verification; mint customer links. Includes read.
secretsRead 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:

  1. Mint the replacement.
  2. Deploy it.
  3. 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:

FieldMeaning
oktrue when a quorum of resolvers see a match.
actualWhat actually resolved (may be empty).
propagation{state, agree, responded, resolvers} — how confident the verdict is.
reasonPresent when ok is false. See below.
hintA 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

ReasonMeaning
NXDOMAINThe name does not resolve at all.
NO_ANSWERThe name resolves, but has no record of that type.
WRONG_VALUEA record of the right type exists, pointing somewhere else.
PARTIALAn SPF/DKIM/DMARC record is published but incomplete; the hint names what's missing.
RESOLVER_ERRORThe lookup failed everywhere. Usually transient.
PROPAGATINGMatched on some resolvers but not yet a quorum. Re-check shortly.

Run history is kept per customer at GET /api/admin/customers/{id}/runs.

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.

The link is bound to one customer, and the owning account travels inside the signed token rather than in the URL. Editing the address to another customer's name cannot reach their data. Links expire after 30 days.

Vendor auto-pull

Instead of declaring records by hand, connect the vendor account that generates them and let each sweep re-pull:

VendorPulls
SendGridPer-subuser domain-authentication CNAMEs
MailgunSending-domain records
Cloudflare for SaaSCustom-hostname validation records
AWS Certificate ManagerCertificate validation CNAMEs
FastlyTLS 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.

sourcecredentialsconfig
sendgrid api_key required single_account (bool) — skip subuser discovery and pull the account's own domains
subusers (list) — restrict to these
include_disabled (bool, default false) — also pull disabled subusers
only_valid (bool, default false) — emit only CNAMEs SendGrid already marks valid
mailgun api_key required regionus (default) or eu
single_account (bool) — skip subaccount enumeration
subaccounts (list) — restrict to these
domains (list) — restrict to these
cloudflare_saas api_token required zone_id required — the zone whose custom hostnames to pull
per_page (int) — pagination size
acm access_key_id
secret_access_key
session_token (for temporary credentials)
Omit all three to use the ambient AWS credential chain.
region — AWS region
certificate_arns (list) — pin exact certificates instead of listing
certificate_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.

Every vendor also accepts an API base override (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

FieldTypeNotes
idstringreq on create. Yours to choose; it appears in the customer's link.
namestringreq. Display name.
tenant_idstringRead-only.
created_attimestampRead-only.
healthobjectRead-only rollup of the latest run — see below.

health (also the body of GET /api/health/{cid}):

FieldTypeNotes
statusstringall-ok, partial, failing, or unknown (never verified).
last_verified_attimestamp | nullnull before the first run.
passing / totalintRecords passing, out of how many.

Record

FieldTypeNotes
domainstringreq. The zone the record belongs to.
hostnamestringreq. The fully-qualified name the record lives at.
typestringreq. CNAME, TXT, MX, A, …
valuestringreq. What it must resolve to.
priorityint | nullDefault null. For MX.
sourcestringDefault "manual". Vendor auto-pull sets its own tag.
id, tenant_id, customer_idstringRead-only.

Verification run

Returned by POST …/verify. GET …/runs returns the same object without records.

FieldTypeNotes
started_attimestampWhen the run began.
statusstringSame four values as health.status.
passing / totalintRecords passing, out of how many.
recordsarrayPer-record results — see below.

record result:

FieldTypeNotes
record_idstringWhich record this is about.
hostname, typestringEchoed from the record.
expectedstringThe value you declared.
okboolQuorum of resolvers saw a match.
currentarray<string>What actually resolved. Empty when nothing does.
reasonstring | nullSet when ok is false — see Failure reasons.
hintstring | nullHuman-readable fix instruction.
The public 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

FieldTypeNotes
urlstringThe signed link to hand your customer.
expires_attimestamp | null30 days out.

TLS endpoint

FieldTypeNotes
hostnamestringreq.
portintDefault 443.
idstringRead-only.
sourcestringRead-only. Explicitly added, or derived from a record.

TLS policy

One per tenant, at GET/PUT /api/admin/tls-policy.

FieldTypeNotes
enabledboolDefault false.
expiry_warning_daysintDefault 14. Days before expiry to start failing.
min_protocolstringDefault "TLS1.2".
require_trusted_chainboolDefault true.
expected_issuer_cnstring | nullPin the issuer common name.
expected_fingerprint_sha256string | nullPin 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:

FieldTypeNotes
endpoint_id, hostname, portstring / intWhat was probed.
okboolPassed the policy.
reasonstringWhy not, when ok is false.
hintstring | nullHuman-readable fix instruction.
not_aftertimestamp | nullCertificate expiry.
days_remainingint | nullDays until expiry. Negative once expired.
issuer, subjectstring | nullCertificate identity.
sansarray<string>Subject alternative names.
negotiated_protocolstring | nulle.g. TLSv1.3.
fingerprintstring | nullSHA-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.

FieldTypeNotes
channelsarray<string>Any of email, slack, webhook. Default empty.
failure_thresholdint ≥ 1Default 1. Consecutive failing runs before alerting.
email_toarray<string>Recipients. Default empty.
smtp_hoststringDefault "".
smtp_portintDefault 25.
email_fromstringEnvelope sender.
slack_webhookstringIncoming-webhook URL. Encrypted at rest.
webhook_urlstringYour own HTTPS endpoint. Encrypted at rest.
tenant_idstringRead-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.

FieldTypeNotes
tenantstringWhose records were reconciled.
sourcestringThe vendor tag pulled.
addedintExpected records the vendor reported that you did not have.
updatedintStored records whose value drifted from the vendor's.
retiredintRecords of this source the vendor no longer reports — deleted.
unchangedintAlready correct. A second pull reports everything here.

API key

FieldTypeNotes
namestringreq. 1–64 characters. Names the integration in your audit trail.
scopesarray<string>req, non-empty. Subset of read, write, secrets.
idstringRead-only. Pass to DELETE to revoke.
created_attimestampRead-only.
legacyboolRead-only. The pre-scopes key; carries every scope.
api_keystringMint response only. The plaintext, shown once.

Usage

GET /api/admin/usage:

FieldTypeNotes
tierobject | null{id, name}, or null on no assigned tier.
limitsobjectYour effective caps — see below. null for any limit means unlimited.
usageobjectLive counts, keyed by the same limit names.

limits and usage share these keys:

KeyCaps
max_customersCustomers you may create.
max_records_per_customerRecords per customer. usage reports the peak across customers.
max_vendor_connectionsConnected vendor accounts.
min_monitor_interval_secFloor on re-check frequency. No usage counterpart.
max_registrable_domainsDistinct apex domains, not records.
max_tls_endpointsTLS 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

StatusMeans
400Malformed body — including an unknown scope, which is rejected rather than silently dropped.
401Missing, malformed or unrecognised key.
403Authenticated but not permitted: a missing scope, a tripped plan limit, or a missing CSRF token on a session call.
404Unknown id — or one belonging to another account.
429Rate-limited (sign-in and sign-up).
503A 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/customersList / create
GET PATCH DEL/api/admin/customers/{cid}Get / update / delete
GET POST/api/admin/customers/{cid}/recordsList / create
GET PATCH DEL/api/admin/customers/{cid}/records/{id}Get / update / delete

Verification & links

POST/api/admin/customers/{cid}/verifyVerify now
GET/api/admin/customers/{cid}/runsRun history
POST/api/admin/customers/{cid}/onboarding-linkMint a customer link

TLS — Pro and up

GET POST/api/admin/customers/{cid}/tls-endpointsList / add
DEL/api/admin/customers/{cid}/tls-endpoints/{id}Remove
POST/api/admin/customers/{cid}/tls-verifyProbe now
GET/api/admin/customers/{cid}/tlsLatest result
GET/api/admin/customers/{cid}/tls-runsProbe history
GET PUT/api/admin/tls-policyGet / set policy

Alerts & vendors secrets scope

GET PUT/api/admin/alert-configGet / set alert channels
GET POST/api/admin/vendor-connectionsList / create
GET PATCH DEL/api/admin/vendor-connections/{id}Get / update / delete
POST/api/admin/vendor-connections/{id}/syncPull this connection now

Account

GET/api/admin/usagePlan, limits, live usage
GET POST/api/admin/api-keysList / 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
Something missing or unclear? Mail hello@dns-config.net — we'd rather fix the docs than answer the same question twice.
← Back to home