The DNS API exposes the current DNS zone and record tooling under /api/dns.
Use it when you need to:
Base URL: https://tools.tornevall.net/api/dns
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();
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"
The current /api/dns/* controller supports:
Typical list zones responses can include:
access_type="authenticated"access_type="api_key_global"access_type="ip_whitelist"GET /api/dns/zones is not admin-only anymore.
Current behavior:
dns_zone_permissionsFor GET /api/dns/zones/{zone} and the related cache/search endpoints:
{
"ok": false,
"reason": "unauthenticated"
}
{
"ok": false,
"reason": "forbidden"
}
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"
}
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"
}
Endpoint: GET /api/dns/zones/{zone}
Default behavior is cache-first.
Useful query parameters:
method=cache - default flowmethod=axfr - force AXFR-style transfer pathmethod=file - read from local zone file pathpage=<n> - page numberlimit=<n> - page sizeExample:
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
}
}
Endpoint: GET /api/dns/zones/{zone}/cache
This is the cache-first helper endpoint for the DNS editor.
Current source values:
from_databasefrom_database_staleneeds_axfrExample 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
}
}
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.
Endpoint: GET /api/dns/zones/{zone}/search?q=...
Important behavior:
from_database_stale when only stale cache existsExample:
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
}
Endpoint: POST /api/dns/zones/{zone}/cache/clear
This endpoint is policy-gated.
It can fail with:
cache_invalidation_disabledcache_clear_protectedforbiddenunauthenticatedExample 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
}
}
These are the active ordinary DNS write endpoints:
POST /api/dns/records/addPOST /api/dns/records/deletePOST /api/dns/records/updatePOST /api/dns/records/bulkThe DNS API now also exposes narrow ACME TXT helpers under the same /api/dns auth model:
POST /api/dns/acme/presentPOST /api/dns/acme/cleanupPOST /api/dns/acme/cleanup-staleThese 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>.
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
}'
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"
}'
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:
ownerzonedry_runremoved[]removed_countkept[]errors[]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
}'
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"
}'
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
}'
Current limits for ordinary DNS bulk writes:
operations is required1 item100 itemsaction, domain, type, and targetExample:
{
"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"
}
]
}
{
"ok": true,
"message": "DNS update completed successfully.",
"results": [],
"operation_count": 2
}
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.
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 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.
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;
}
| 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 |
If an integration behaves unexpectedly, check these first:
/api/dns/... and not the older non-API path?cache_policy?Last Updated: 2026-04-24