← Back to docs

DNS API User Guide

Language: EN | EN | SV

DNS API User Guide

Overview

The DNS API exposes the current DNS zone and record tooling under /api/dns.

Use it when you need to:

  • list zones the caller is allowed to see
  • read zone data with cache-first or AXFR-backed flows
  • search cached zone rows without triggering a new transfer
  • add, delete, update, or bulk-write ordinary DNS records

Base URL: https://tools.tornevall.net/api/dns


Quick start

Browser / web app flow

Use the same logged-in session as the Tools web UI.

const response = await fetch('https://tools.tornevall.net/api/dns/zones', {
  method: 'GET',
  credentials: 'include'
});
const data = await response.json();

API key flow

Use one of these transports:

  • Authorization: Bearer <token>
  • X-API-Key: <token>
  • ?api_key=<token>

Example:

curl -s "https://tools.tornevall.net/api/dns/zones" \
  -H "Authorization: Bearer YOUR_API_KEY"

What auth modes the DNS endpoints accept

The current /api/dns/* controller supports:

  • authenticated web session
  • API key auth (personal or global)
  • IP whitelist fallback when enabled server-side

Typical list zones responses can include:

  • access_type="authenticated"
  • access_type="api_key_global"
  • access_type="ip_whitelist"

Authentication and access

Zone visibility

GET /api/dns/zones is not admin-only anymore.

Current behavior:

  • admin users see all zones
  • regular users see only zones delegated through dns_zone_permissions
  • global API keys can return all zones without a bound user
  • IP-whitelisted callers can also read without a normal user session

Zone-specific reads

For GET /api/dns/zones/{zone} and the related cache/search endpoints:

  • admins can read any zone
  • non-admin users must have a matching delegated zone permission
  • unauthenticated callers must match a server-side IP whitelist rule

Common auth failures

{
  "ok": false,
  "reason": "unauthenticated"
}
{
  "ok": false,
  "reason": "forbidden"
}

Common response fields

You will see these fields frequently in zone-read responses:

Field Meaning
ok Success flag
zone Requested zone name
zoneData Paged record rows
record_count Total matching rows before page trimming
page Current page number
per_page Current page size
has_more Whether another page exists
display_signature Hash of the currently visible rows + paging state
cache_ttl_seconds Effective cache TTL
cache_policy Current invalidate/clear policy for the zone

Typical row shape in zoneData:

{
  "name": "www.tornevall.net",
  "ttl": 3600,
  "class": "IN",
  "type": "A",
  "rdata": "192.168.1.100"
}

Read endpoints

1) List zones

Endpoint: GET /api/dns/zones

Example:

curl -s "https://tools.tornevall.net/api/dns/zones" \
  -H "Authorization: Bearer YOUR_API_KEY"

Example response:

{
  "ok": true,
  "count": 2,
  "zones": [
    {
      "zone": "tornevall.net",
      "file": "master/tornevall/tornevall.net",
      "key": "tornevall.net"
    },
    {
      "zone": "10.10.10.in-addr.arpa",
      "file": "master/reverse/10.10.10.in-addr.arpa",
      "key": "10.10.10.in-addr.arpa"
    }
  ],
  "is_admin": false,
  "access_type": "authenticated"
}

2) Get one zone

Endpoint: GET /api/dns/zones/{zone}

Default behavior is cache-first.

Useful query parameters:

  • method=cache - default flow
  • method=axfr - force AXFR-style transfer path
  • method=file - read from local zone file path
  • page=<n> - page number
  • limit=<n> - page size

Example:

curl -s "https://tools.tornevall.net/api/dns/zones/tornevall.net?method=cache&page=1&limit=50" \
  -H "Authorization: Bearer YOUR_API_KEY"

Example response:

{
  "ok": true,
  "zone": "tornevall.net",
  "method": "CACHE",
  "zoneData": [
    {
      "name": "tornevall.net",
      "ttl": 3600,
      "class": "IN",
      "type": "NS",
      "rdata": "ns1.tornevall.net."
    }
  ],
  "record_count": 120,
  "page": 1,
  "per_page": 50,
  "has_more": true,
  "display_signature": "7f0d...",
  "cached_at": "2026-04-24T08:15:00+00:00",
  "cache_age": 12,
  "cache_ttl_seconds": 300,
  "cache_policy": {
    "invalidate_enabled": false,
    "invalidate_interval_seconds": 259200,
    "last_invalidated_at": null,
    "protect_from_clear": false
  }
}

3) Check cache state only

Endpoint: GET /api/dns/zones/{zone}/cache

This is the cache-first helper endpoint for the DNS editor.

Current source values:

  • from_database
  • from_database_stale
  • needs_axfr

Example fresh/stale cache response:

{
  "ok": true,
  "source": "from_database_stale",
  "zone": "tornevall.net",
  "zoneData": [],
  "record_count": 0,
  "page": 1,
  "per_page": 50,
  "has_more": false,
  "display_signature": "abc123",
  "cached_at": "2026-04-24T07:55:00+00:00",
  "cache_age": 1240,
  "cache_is_stale": true,
  "cache_ttl_seconds": 300,
  "cache_policy": {
    "invalidate_enabled": false,
    "invalidate_interval_seconds": 259200,
    "last_invalidated_at": null,
    "protect_from_clear": false
  }
}

Example cache-miss response:

{
  "ok": true,
  "source": "needs_axfr",
  "zone": "tornevall.net",
  "message": "Cache miss or expired, AXFR required",
  "cache_ttl_seconds": 300,
  "cache_policy": {
    "invalidate_enabled": false,
    "invalidate_interval_seconds": 259200,
    "last_invalidated_at": null,
    "protect_from_clear": false
  }
}

4) Force AXFR path

Endpoint: GET /api/dns/zones/{zone}/axfr

Use this when you explicitly want the transfer-based path because journal-backed updates may be newer than the local file view.

5) Search cached zone rows

Endpoint: GET /api/dns/zones/{zone}/search?q=...

Important behavior:

  • searches cached rows only
  • never triggers AXFR on its own
  • can still use from_database_stale when only stale cache exists
  • supports exact IP, CIDR, and plain text fallback searches

Example:

curl -s "https://tools.tornevall.net/api/dns/zones/dnsbl.tornevall.org/search?q=203.0.113.4" \
  -H "Authorization: Bearer YOUR_API_KEY"

Example response:

{
  "ok": true,
  "zone": "dnsbl.tornevall.org",
  "source": "from_database",
  "cache_age": 31,
  "cache_is_stale": false,
  "query": "203.0.113.4",
  "search_mode": "ip",
  "zoneData": [],
  "record_count": 0,
  "page": 1,
  "per_page": 50,
  "has_more": false
}

6) Clear one zone cache

Endpoint: POST /api/dns/zones/{zone}/cache/clear

This endpoint is policy-gated.

It can fail with:

  • cache_invalidation_disabled
  • cache_clear_protected
  • forbidden
  • unauthenticated

Example success response:

{
  "ok": true,
  "zone": "tornevall.net",
  "message": "Cache cleared",
  "cache_policy": {
    "invalidate_enabled": true,
    "invalidate_interval_seconds": 259200,
    "last_invalidated_at": null,
    "protect_from_clear": false
  }
}

Write endpoints

These are the active ordinary DNS write endpoints:

  • POST /api/dns/records/add
  • POST /api/dns/records/delete
  • POST /api/dns/records/update
  • POST /api/dns/records/bulk

ACME helper endpoints

The DNS API now also exposes narrow ACME TXT helpers under the same /api/dns auth model:

  • POST /api/dns/acme/present
  • POST /api/dns/acme/cleanup
  • POST /api/dns/acme/cleanup-stale

These endpoints are intended for DNS-01 console tooling and future Tools-side certificate flows.

They normalize the requested domain into one owner name (_acme-challenge.<domain>), write or delete TXT rows through the normal DNS update service, and keep the row-based zone cache in sync.

For manual server-side maintenance, the same stale-cleanup logic is also available through Artisan as php artisan dns:acme:cleanup-stale <domain>.

Present one challenge value

curl -s "https://tools.tornevall.net/api/dns/acme/present" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "example.com",
    "challenge": "demo-acme-value",
    "ttl": 60
  }'

Remove one exact challenge value

curl -s "https://tools.tornevall.net/api/dns/acme/cleanup" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "example.com",
    "challenge": "demo-acme-value"
  }'

Remove stale challenge values but keep the active ones

Use this when older _acme-challenge TXT rows have accumulated and you want to clean them up explicitly instead of relying only on the exact cleanup hook.

curl -s "https://tools.tornevall.net/api/dns/acme/cleanup-stale" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "example.com",
    "keep_challenges": ["demo-acme-value"],
    "dry_run": false,
    "refresh_zone_cache": true
  }'

Typical fields in the stale-cleanup response:

  • owner
  • zone
  • dry_run
  • removed[]
  • removed_count
  • kept[]
  • errors[]

Add one record

curl -s "https://tools.tornevall.net/api/dns/records/add" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "test.example.com",
    "type": "A",
    "target": "192.0.2.20",
    "ttl": 300
  }'

Delete one record

target is required so the API does not accidentally delete the whole RRset.

curl -s "https://tools.tornevall.net/api/dns/records/delete" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "test.example.com",
    "type": "A",
    "target": "192.0.2.20"
  }'

Update one record

old_target is optional in the current controller, but you should include it when replacing an existing record.

curl -s "https://tools.tornevall.net/api/dns/records/update" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "test.example.com",
    "type": "A",
    "old_target": "192.0.2.20",
    "new_target": "192.0.2.21",
    "ttl": 300
  }'

Bulk write

Current limits for ordinary DNS bulk writes:

  • operations is required
  • minimum 1 item
  • maximum 100 items
  • each item must include action, domain, type, and target

Example:

{
  "operations": [
    {
      "action": "ADD",
      "domain": "one.example.com",
      "type": "A",
      "target": "192.0.2.30",
      "ttl": 300
    },
    {
      "action": "DELETE",
      "domain": "two.example.com",
      "type": "A",
      "target": "192.0.2.31"
    }
  ]
}

Typical write response

{
  "ok": true,
  "message": "DNS update completed successfully.",
  "results": [],
  "operation_count": 2
}

Cache behavior to be aware of

Row-based cache sync

The DNS cache is row-based now.

Record writes no longer default to full-zone invalidation.

Instead, successful add, delete, update, and bulk calls synchronize matching cache rows after the master DNS update succeeds.

Stale cache fallback

GET /api/dns/zones/{zone}/cache and cached searches can return source="from_database_stale".

That is intentional: it lets a UI render the last known page immediately while a later AXFR refresh happens in the background.

Display signature

display_signature is additive metadata for cache-first UIs.

Use it to decide whether a finished AXFR actually changed the currently visible page before you redraw the table.


JavaScript example

const apiBase = 'https://tools.tornevall.net/api/dns';

async function loadZone(zoneName) {
  const response = await fetch(`${apiBase}/zones/${zoneName}/cache?page=1&limit=50`, {
    method: 'GET',
    credentials: 'include'
  });

  const data = await response.json();

  if (!data.ok) {
    throw new Error(data.reason || 'DNS request failed');
  }

  if (data.source === 'needs_axfr') {
    const axfrResponse = await fetch(`${apiBase}/zones/${zoneName}/axfr?page=1&limit=50`, {
      method: 'GET',
      credentials: 'include'
    });
    return axfrResponse.json();
  }

  return data;
}

Error guide

Reason Typical status Meaning
unauthenticated 401 No accepted session, API key, or IP whitelist access
forbidden 403 Caller is authenticated but not allowed to access that zone
required_paths_missing_or_unreadable 404 Server-side DNS zone/key paths are not readable
missing_query 422 Search endpoint was called without q
cache_unavailable 409 Search was requested before any cache page existed
cache_invalidation_disabled 403 Manual clear is blocked by zone policy
cache_clear_protected 403 Full clear is blocked for that zone
validation_failed 400 Write payload failed validation

Related documentation


Support

If an integration behaves unexpectedly, check these first:

  1. Are you calling /api/dns/... and not the older non-API path?
  2. Does the caller actually have delegated access to the requested zone?
  3. Are you expecting fresh AXFR data when you are really reading cache?
  4. Is a manual cache clear blocked by the zone's cache_policy?

Last Updated: 2026-04-24