---
title: "DNS — API v1 and MCP | Nodea Documentation"
description: "Complete DNS module reference: REST API v1 endpoints (PowerDNS zones and records, zone lookup, delete by value), infra:read/write and dns:acme scopes, DNS-01 certificate automation, MCP tools and curl / JSON-RPC examples."
url: "https://nodea.io/en/api-docs/dns"
lang: "en"
updated: "2026-07-16"
---

# DNS — API v1 and MCP | Nodea Documentation

> DNS module reference — API v1 endpoints for zones and records on the PowerDNS cluster, zone lookup by name, the dns:acme scope for DNS-01 certificates, supported record types, MCP tools and examples.

The **DNS** module manages zones and records hosted on our authoritative **PowerDNS** cluster: list zones, view a zone with its full record set, and create, update or delete individual records. This document covers the REST API v1 surface (/api/v1/dns) and the MCP tools in this module.

## Authentication and scopes

API v1 authenticates with a Passport personal access token via the Authorization: Bearer \<token\> header. Access tiers:

- infra:read — list zones and view a zone with its records (index, show).
- infra:write — create, update and **delete** records, plus the zone lookup. A record is subordinate zone configuration, so deleting it does NOT require infra:delete (that tier is reserved for destroying whole resources such as devices or servers).
- dns:acme — the narrow certificate-automation scope: zone lookup by name, and create / delete of TXT records named \_acme-challenge.\* only (see [DNS-01 certificate automation](#dns-01)).

The zone lookup works with any of the three scopes: infra:read, infra:write and dns:acme. infra:write does not include reading the zone list or records — combine it with infra:read for that.

The whole module is additionally gated by the Pennant flag features:hosting-suite — disabling the hosting suite hides DNS on both web UI and API/MCP. Rate limit: throttle:api-v1 = 240/min per token user.

## Customer account, ownership and visibility

DNS zones belong to a **customer account** (see [Customer accounts](https://nodea.io/en/api-docs/accounts)). A request acts in the current account (token pinning → X-Account-Id header → default account). Access requires both a token scope — infra:\*, or dns:acme for the zone lookup and the \_acme-challenge TXT records (see below) — and an account permission:

EndpointAccount permissionGET /dns/zones, GET /dns/zones/lookup, GET /dns/zones/{zone}domains POST / PATCH / DELETE /dns/zones/{zone}/records\[/{record}\]domains + managedomains

The list returns only the **current account's** zones, and DnsZoneResource carries an account\_id field. Records inherit their zone's account. A zone outside the current account (another customer's or one of your other accounts'), a system zone (shared infrastructure, no account) or an unknown zone always yields 404 — the API never confirms that a foreign zone exists. The {record} parameter uses scoped route binding against {zone}, so a record belonging to another zone yields 404 before the controller runs. A member without the permission gets 403 account\_permission\_denied with a permission field.

Mutations write the local row first (the database is the source of truth), then push the zone to PowerDNS synchronously. **If the push fails, the request answers 502 with "code": "dns\_push\_failed"** (until 2026-10 the API answered success and only logged the failure). On POST and PATCH with infra:write the record stays stored and the response carries it in data (with its id, so you can clean it up); the next push of the zone delivers it to the nameservers. On POST with dns:acme alone the record is **not** stored (no data, simply retry) — unless PowerDNS accepted exactly this RRset and refused only other RRsets of the zone: then the record is live and the answer is 201. One RRset refused by PowerDNS (HTTP 4xx) does not stop the others from being pushed; when PowerDNS is unreachable (connection error, timeout, HTTP 5xx) the push stops at the first failing RRset instead of waiting for a timeout on every other one, and the RRsets after it count as not delivered.

**Parallel changes of one zone are serialized** — across API v1, MCP and the panel (customer and admin), so a change saved in the panel at the same moment also waits for its turn and never removes a value pushed through the API from the nameservers: a request that creates, updates or deletes records waits until an earlier change of the same zone has finished, then works on the complete, current set of records. Every push therefore carries all stored values of an RRset — an ACME client may publish the apex and the wildcard challenge (two values under one name) in parallel — and the dns:acme limit of 20 challenge records counts the records of parallel requests too.

**Deleting** first fixes the affected RRset alone on the nameservers, in one atomic call (replaced with the remaining values, or removed when nothing is left). If PowerDNS refuses that, nothing is deleted: 502 with "deleted": false, retry. If it succeeds the deletion is committed in the database and only then the whole zone is pushed — if that push fails the answer is 502 with "deleted": true: the record is gone from the database AND the nameservers, only the rest of the zone waits for the next sync (do not retry the deletion — a retry answers 404 dns\_record\_not\_found).

**Retrying a POST** with the same name (case-insensitive), type and content does not create a duplicate: the answer is 200 with the existing record (the zone is pushed again; if that fails again — 502 with the record in data).

## API v1 — endpoints

MethodPathScopeDescriptionGET/api/v1/dns/zonesinfra:readThe current account's zones, paginated; with a record count. GET/api/v1/dns/zones/lookup?fqdn=infra:read, infra:write or dns:acmeThe current account's zone hosting fqdn (longest suffix match). Returns only id and name. GET/api/v1/dns/zones/{zone}infra:readZone detail with the full record set (sorted by type, name). POST/api/v1/dns/zones/{zone}/recordsinfra:write or dns:acme\*Create a record in the zone. Returns 201 with the record (incl. id), or 200 with the existing record when an identical one exists. PATCH/api/v1/dns/zones/{zone}/records/{record}infra:writePartial record update (only submitted fields). Scoped binding. DELETE/api/v1/dns/zones/{zone}/records/{record}infra:write or dns:acme\*Delete a record (scoped binding). Returns 204. DELETE/api/v1/dns/zones/{zone}/recordsinfra:write or dns:acme\*Delete the record(s) matching name + type + content exactly. Returns 204.

\* With dns:acme alone: only TXT records whose name starts with the \_acme-challenge label; anything else is 403 dns\_acme\_record\_forbidden.

### GET /dns/zones/lookup — zone by name

- fqdn (string, required, max 255) — any name inside the zone, e.g. \_acme-challenge.www.example.com. Normalised before matching: trimmed, lower-cased, the trailing dot dropped, internationalised names converted to punycode. Underscore labels are allowed, \* is not.

The zone of the current account with the **longest matching suffix** wins (with zones example.com. and eu.example.com., api.eu.example.com resolves to the latter); a suffix only matches on a label boundary. Response 200: {"data": {"id": 42, "name": "example.com."}} — no records, no zone metadata. 404 with "code": "dns\_zone\_not\_found" when no zone of the account matches (zones of other accounts and system zones never match), 422 for a missing or malformed fqdn.

### DELETE /dns/zones/{zone}/records — delete by value

Body (JSON) or query string: name (string, max 253; @, a bare label or an FQDN, like POST), type (one of the record types), content (string, max 1000). Deletes every record of the zone with that name (case-insensitive), type and content. Content must match exactly; for TXT a quoted and an unquoted form of the same value are equal (abc = "abc"), letter case still matters. 404 with "code": "dns\_record\_not\_found" when nothing matches.

### GET /dns/zones — query parameters

- search (string, optional, max 255) — matched against the zone name.
- per\_page (int 1–100, default 20), page (int ≥1).

Response: a DnsZoneResource collection — fields: id, name, kind, serial (SOA), dnssec (bool), records\_count, last\_synced\_at, created\_at, updated\_at. show adds a records\[\] array (DnsRecordResource). Internal zone config (masters, nsec3param, the remote PowerDNS handle) stays server-side and is not serialized.

### DnsRecordResource — record fields

id, zone\_id, name (FQDN), type, content, ttl, priority (nullable), comment (nullable), disabled (bool), created\_at, updated\_at.

### POST/PATCH — record body parameters

- name (string, max 253) — accepts @ (zone apex), a bare label (suffixed with the zone) or a fully-qualified FQDN. Once qualified it must be a valid DNS name: ASCII only (internationalised names as punycode, e.g. xn--ka-ola7v), no whitespace or control characters, no empty labels (a..b), labels up to 63 characters, the name up to 253; letters, digits, - and \_ per label, \* only as the whole leftmost label (wildcard record). Otherwise 422. On PATCH the name is checked only when you change it.
- type (enum) — one of: A, AAAA, CNAME, MX, TXT, NS, SRV, CAA, PTR.
- content (string, max 1000) — IP address, target host or text value. A TXT value sent without quotes is stored quoted (abc → "abc"); a value that already starts with a quote is kept as is, but must consist of complete quoted strings separated by spaces (an inner quote as \\"). A TXT value with control characters or unbalanced quotes is 422.
- ttl (int, 60–604800 s).
- priority (int, 0–65535, optional) — for MX/SRV.
- comment (string, max 255, optional).
- disabled (bool, optional) — record created/marked as not served.

A **CNAME** cannot share its name with any other record (RFC 1034): a record under a name that has a CNAME, a second CNAME, or a CNAME under a name with other records (PATCH included) answers 409 with "code": "dns\_cname\_conflict".

**POST** requires the full name/type/content/ttl (with dns:acme alone the rules of the [DNS-01](#dns-01) section apply). **PATCH** is partial — only submitted fields change (the rest stays intact), unlike the web UI which re-posts the whole record each time.

### Error codes

- 401 — missing/invalid token.
- 403 — missing required scope.
- 403 dns\_acme\_record\_forbidden — a dns:acme-only token tried a record other than TXT \_acme-challenge.\*.
- 403 — account-context error: account\_permission\_denied (the permission field names the missing flag) — domains for reads, managedomains for record changes, no\_account\_context, account\_forbidden / account\_mismatch (the X-Account-Id header or token pinning) — see [Customer accounts](https://nodea.io/en/api-docs/accounts).
- 404 — unknown zone, a system zone or a zone outside the current account; a record from another zone (scoped binding).
- 404 dns\_zone\_not\_found (lookup) / 404 dns\_record\_not\_found (delete by value) — nothing matches.
- 409 dns\_cname\_conflict — a CNAME would share its name with another record.
- 409 dns\_acme\_record\_limit — dns:acme-only token: the zone already holds 20 \_acme-challenge records.
- 422 — validation (disallowed type, TTL out of range, name too long or malformed, malformed TXT value, malformed fqdn; with dns:acme also comment/disabled/priority and a value that is not an ACME token).
- 502 dns\_push\_failed — the change could not be pushed to the nameservers; see above for what stays stored (on delete the deleted field says whether the record was removed).

## Examples — API

### List zones with search

curl -s "https://app.nodea.io/api/v1/dns/zones?search=example&per\_page=20" \\ -H "Authorization: Bearer $NODEA\_TOKEN" \\ -H "Accept: application/json"

### Create an A record

curl -s -X POST https://app.nodea.io/api/v1/dns/zones/42/records \\ -H "Authorization: Bearer $NODEA\_TOKEN" \\ -H "Content-Type: application/json" \\ -d '{ "name": "www", "type": "A", "content": "203.0.113.10", "ttl": 3600 }'

### An MX record with priority

curl -s -X POST https://app.nodea.io/api/v1/dns/zones/42/records \\ -H "Authorization: Bearer $NODEA\_TOKEN" \\ -H "Content-Type: application/json" \\ -d '{ "name": "@", "type": "MX", "content": "mail.example.com", "ttl": 3600, "priority": 10 }'

### Partial update (TTL only)

curl -s -X PATCH https://app.nodea.io/api/v1/dns/zones/42/records/1001 \\ -H "Authorization: Bearer $NODEA\_TOKEN" \\ -H "Content-Type: application/json" \\ -d '{ "ttl": 300 }'

## DNS-01 certificate automation (ACME)

For automatic TLS certificates (Let's Encrypt or another ACME CA) create a token with only the dns:acme scope. Such a token can do exactly three things: look a zone up by name, create a TXT record whose name starts with the \_acme-challenge label (\_acme-challenge.example.com, \_acme-challenge.www.example.com) and delete such a record. It cannot list zones, read records, PATCH anything or call any other API. Any other record type or name answers 403 with "code": "dns\_acme\_record\_forbidden". The account permissions still apply (domains for the lookup, domains + managedomains for records) and the token sees only the zones of its account. A token cannot be limited to a subset of zones — use a dedicated account (or pin the token to one) if you need that.

Extra limits for a dns:acme-only token (a violation is 422 unless stated otherwise):

- name: LDH labels (letters, digits, inner hyphens) with an optional leading underscore per label, no \*; the name is stored **lower-cased** (\_ACME-CHALLENGE.WWW → \_acme-challenge.www.example.com.), so values sent in different letter case land in one RRset;
- content: a single ACME token — 1–255 characters of A–Z a–z 0–9 - \_ (43 in practice), bare or in one pair of quotes;
- ttl: optional, 60–600 s, default 60;
- comment, disabled, priority: not allowed;
- at most 20 \_acme-challenge records per zone — the next one answers 409 with "code": "dns\_acme\_record\_limit" (delete stale challenges);
- retrying an identical POST answers 200 with the existing record instead of a duplicate; on 502 dns\_push\_failed the record is not stored, so a retry is safe.

1. **Find the zone** — GET /api/v1/dns/zones/lookup?fqdn=\_acme-challenge.www.example.com → {"data": {"id": 42, "name": "example.com."}}.
2. **Publish the challenge** — POST /api/v1/dns/zones/42/records with {"name": "\_acme-challenge.www", "type": "TXT", "content": "\<key authorization digest\>", "ttl": 60} → 201 with the record id (200 when the same record already exists). The value may be sent without quotes — it is stored quoted, the way PowerDNS expects TXT data. For a wildcard plus apex certificate two values under the same name are fine.
3. **Wait for propagation** — query the zone's authoritative nameservers directly (e.g. dig +short TXT \_acme-challenge.www.example.com @ns1.nodea.io) until the value appears, then let the CA validate.
4. **Clean up** — DELETE /api/v1/dns/zones/42/records/{id}, or without the id: DELETE /api/v1/dns/zones/42/records with {"name": "\_acme-challenge.www", "type": "TXT", "content": "\<same value\>"} → 204. 502 with "deleted": true means the record was removed — do not retry.

ZONE=$(curl -s "https://app.nodea.io/api/v1/dns/zones/lookup?fqdn=\_acme-challenge.www.example.com" \\ -H "Authorization: Bearer $NODEA\_ACME\_TOKEN" -H "Accept: application/json" | jq -r .data.id) curl -s -X POST "https://app.nodea.io/api/v1/dns/zones/$ZONE/records" \\ -H "Authorization: Bearer $NODEA\_ACME\_TOKEN" -H "Content-Type: application/json" \\ -d '{"name": "\_acme-challenge.www", "type": "TXT", "content": "'"$TOKEN\_VALUE"'", "ttl": 60}' # ... CA validation ... curl -s -X DELETE "https://app.nodea.io/api/v1/dns/zones/$ZONE/records" \\ -H "Authorization: Bearer $NODEA\_ACME\_TOKEN" -H "Content-Type: application/json" \\ -d '{"name": "\_acme-challenge.www", "type": "TXT", "content": "'"$TOKEN\_VALUE"'"}'

## MCP — tools

The MCP tools share the authentication and scope model with API v1 (Bearer + scope checked in ensureScope) and the features:hosting-suite flag (a tool on an inactive flag disappears from tools/list). Zones and records are addressed by integer ids; a missing, foreign or system id raises the same error (no cross-tenant enumeration). A failed push to the nameservers is a tool error ending with (code: dns\_push\_failed, record\_id: \<id\>) — the same code as the API v1 502: a created or updated record stays stored and is delivered by the next sync; a deletion is rolled back when PowerDNS refuses the RRset change itself, and an error with deleted: true means the record was removed and only the rest of the zone waits for the next sync. The write tools check the name, the TXT value and CNAME conflicts like API v1 does for infra:write (conflict: (code: dns\_cname\_conflict); dns-record-update checks the CNAME rule only when the name or the type changes). Changes of one zone are serialized as in API v1. Successful writes carry synced: true. TXT values are stored quoted, as in the API. The dns:acme scope is API-only — the MCP tools require infra:\*.

ToolScopeModeDescriptiondns-zones-listinfra:readread-onlyList zones; search, page, per\_page (max 50). Returns id/name/kind/serial/dnssec/count/last\_synced. dns-zone-getinfra:readread-onlyZone with every record (sorted type, name). Param: id. dns-record-createinfra:writewriteAdd a record; zone\_id, name, type, content, ttl, optional priority/comment/disabled. dns-record-updateinfra:writeidempotent (full replace)Replace a record; name/type/content/ttl required every time (read them first via dns-zone-get). dns-record-deleteinfra:writedestructiveDelete a record; requires confirm: true. The name stops resolving immediately.

**Customer accounts in MCP.** The tools act in the current account of the MCP connection (see [Customer accounts](https://nodea.io/en/api-docs/accounts)). dns-zones-list and dns-zone-get require the domains permission; dns-record-create, dns-record-update and dns-record-delete require domains + managedomains. Records inherit the zone's account. A denial ends with the suffix (code: account\_permission\_denied, permission: \<flag\>) or (code: no\_account\_context).

### Example — MCP (JSON-RPC)

{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "dns-record-create", "arguments": { "zone\_id": 42, "name": "www", "type": "A", "content": "203.0.113.10", "ttl": 3600 } } } { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "dns-record-delete", "arguments": { "zone\_id": 42, "record\_id": 1001, "confirm": true } } }

## Related

- [Servers — API v1 and MCP | Nodea Documentation](https://nodea.io/en/api-docs/servers)
- [SSH Keys — API v1 and MCP | Nodea Documentation](https://nodea.io/en/api-docs/ssh-keys)
- [Devices (Connection Manager) — API v1 and MCP | Nodea Documentation](https://nodea.io/en/api-docs/vault-devices)
- [Customer accounts — API v1 and MCP | Nodea Documentation](https://nodea.io/en/api-docs/accounts)

---

Languages: [pl](https://nodea.pl/pl/dokumentacja-api/dns.md)  
Site index: [llms.txt](https://nodea.io/en/llms.txt)
