API-Driven DNS Management

Programmatic DNS control with provider APIs such as Cloudflare and Route 53, and how that differs from registration lookups.

Infrastructure-as-code tools are powerful, but sometimes you need more flexibility. Maybe you’re building a platform that provisions domains for customers. Maybe you need DNS changes triggered by application events. Maybe you want a custom dashboard that shows DNS status across all your domains.

This is where DNS APIs come in. Every major DNS provider exposes a REST (or REST-like) API for full programmatic control. Let’s explore the most important ones.

Cloudflare API

Cloudflare’s API is one of the most developer-friendly DNS APIs available. It’s well-documented, consistent, and powerful.

Authentication

# Option 1: API Token (recommended — scoped permissions)
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
  https://api.cloudflare.com/client/v4/zones

# Option 2: API Key + Email (legacy — full account access)
curl -H "X-Auth-Key: YOUR_API_KEY" \
     -H "X-Auth-Email: you@example.com" \
  https://api.cloudflare.com/client/v4/zones

Always use API tokens with scoped permissions. An API token with Zone.DNS:Edit for a specific zone can’t accidentally delete your other zones.

Listing DNS Records

$ curl -s "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
  -H "Authorization: Bearer ${CF_TOKEN}" | jq '.result[] | {name, type, content, ttl}'

{
  "name": "example.com",
  "type": "A",
  "content": "203.0.113.50",
  "ttl": 3600
}
{
  "name": "www.example.com",
  "type": "CNAME",
  "content": "example.com",
  "ttl": 3600
}

Creating a Record

$ curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
  -H "Authorization: Bearer ${CF_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "A",
    "name": "api.example.com",
    "content": "203.0.113.60",
    "ttl": 300,
    "proxied": false
  }'

Updating a Record

# First, get the record ID
$ RECORD_ID=$(curl -s "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=api.example.com&type=A" \
  -H "Authorization: Bearer ${CF_TOKEN}" | jq -r '.result[0].id')

# Then update it
$ curl -X PUT "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
  -H "Authorization: Bearer ${CF_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "A",
    "name": "api.example.com",
    "content": "198.51.100.75",
    "ttl": 300,
    "proxied": false
  }'

Deleting a Record

$ curl -X DELETE "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
  -H "Authorization: Bearer ${CF_TOKEN}"

Batch Operations

Cloudflare doesn’t have a native batch endpoint for DNS records, but you can script it:

#!/bin/bash
# bulk-create-records.sh
ZONE_ID="your-zone-id"
CF_TOKEN="your-api-token"

records='[
  {"type":"A","name":"app1","content":"10.0.1.1","ttl":300},
  {"type":"A","name":"app2","content":"10.0.1.2","ttl":300},
  {"type":"A","name":"app3","content":"10.0.1.3","ttl":300}
]'

echo "$records" | jq -c '.[]' | while read -r record; do
  curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
    -H "Authorization: Bearer ${CF_TOKEN}" \
    -H "Content-Type: application/json" \
    -d "$record"
  echo ""
  sleep 0.25  # Respect rate limits
done

AWS Route 53 API

Route 53’s API is more complex than Cloudflare’s but offers features like health checks, routing policies, and tight integration with other AWS services.

Using the AWS CLI

# List hosted zones
$ aws route53 list-hosted-zones --output json | jq '.HostedZones[] | {Name, Id}'

# List records in a zone
$ aws route53 list-resource-record-sets \
  --hosted-zone-id Z1234567890 \
  --output json | jq '.ResourceRecordSets[] | {Name, Type, TTL}'

# Create/update records (Route 53 uses "change batches")
$ aws route53 change-resource-record-sets \
  --hosted-zone-id Z1234567890 \
  --change-batch '{
    "Changes": [{
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "api.example.com",
        "Type": "A",
        "TTL": 300,
        "ResourceRecords": [{"Value": "203.0.113.60"}]
      }
    }]
  }'

Route 53 Change Batches

Route 53’s batch model is atomic — all changes in a batch succeed or fail together:

{
  "Changes": [
    {
      "Action": "CREATE",
      "ResourceRecordSet": {
        "Name": "app.example.com",
        "Type": "A",
        "TTL": 300,
        "ResourceRecords": [{"Value": "10.0.1.1"}]
      }
    },
    {
      "Action": "DELETE",
      "ResourceRecordSet": {
        "Name": "old-app.example.com",
        "Type": "A",
        "TTL": 300,
        "ResourceRecords": [{"Value": "10.0.1.99"}]
      }
    },
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "www.example.com",
        "Type": "CNAME",
        "TTL": 3600,
        "ResourceRecords": [{"Value": "example.com"}]
      }
    }
  ]
}

UPSERT is particularly useful — it creates the record if it doesn’t exist, or updates it if it does. No need to check first.

Route 53 Health Checks

Route 53 can monitor endpoints and automatically remove unhealthy records:

$ aws route53 create-health-check --caller-reference "$(date +%s)" \
  --health-check-config '{
    "IPAddress": "203.0.113.50",
    "Port": 443,
    "Type": "HTTPS",
    "ResourcePath": "/health",
    "RequestInterval": 30,
    "FailureThreshold": 3
  }'

Registration data is not a DNS query

The APIs above change records in a zone you already host. They do not answer “is this name registered?” There is no DNS opcode for availability. That question belongs to the registry, reached through a registrar.

RDAP (Registration Data Access Protocol, RFC 9082 and RFC 9083) is the standard way to read registration data for a name that already exists. A public bootstrap service follows IANA’s RDAP bootstrap registry and redirects to the right server:

# Registration data for a name that exists. example.com is reserved by IANA.
$ curl -s "https://rdap.org/domain/example.com" | head

A registered name comes back as JSON: registrar, status codes such as client transfer prohibited, nameservers, and events (registration, expiration, last update). An unregistered name is an HTTP 404, which is not the same thing as “available to buy.” Premium names, reserved names, and registry policy can make a missing RDAP object unregisterable. For a live record lookup, use DNS itself:

$ dig +short example.com A

dig prints whatever address the name publishes today. It talks to a resolver. It does not bill a registrar and it does not create a zone.

A provisioning workflow

Automating a new name is three different operations, often at two different companies:

  1. Ask your registrar whether the name can be registered. Each registrar’s check endpoint, authentication, and price fields are different. Read that vendor’s docs. There is no universal URL.
  2. Register the name, or skip this step if you already hold it.
  3. Publish DNS at the DNS host, as in the Cloudflare examples above. The host might be the registrar, or it might not. See DNS hosting versus registration.
import requests

CF_API = "https://api.cloudflare.com/client/v4"

def publish_dns(zone_id, domain, ip_address, token):
    """Create basic records once the zone exists at the DNS host."""
    records = [
        {"type": "A", "name": domain, "content": ip_address, "ttl": 3600},
        {"type": "CNAME", "name": f"www.{domain}", "content": domain, "ttl": 3600},
        {"type": "TXT", "name": domain, "content": "v=spf1 -all", "ttl": 3600},
    ]
    for record in records:
        response = requests.post(
            f"{CF_API}/zones/{zone_id}/dns_records",
            headers={"Authorization": f"Bearer {token}"},
            json=record,
            timeout=10,
        )
        response.raise_for_status()

Keep the token out of the script. Cache zone ids. RDAP and registrar endpoints rate-limit, and a 404 from RDAP is a hint, not a purchase order.

Webhooks for DNS Events

Some providers support webhooks that notify your application when DNS changes occur:

Cloudflare Notifications

Cloudflare doesn’t offer per-record webhooks, but provides notification policies for important events:

# Create a notification policy for DNS changes
$ curl -X POST "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/alerting/v3/policies" \
  -H "Authorization: Bearer ${CF_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "DNS Record Changes",
    "alert_type": "zone_dns_record_update",
    "enabled": true,
    "mechanisms": {
      "webhook": [{"id": "your-webhook-id"}]
    },
    "filters": {
      "zones": ["your-zone-id"]
    }
  }'

Building Your Own DNS Change Watcher

If your provider doesn’t support webhooks, poll for changes:

import time
import hashlib
import json
import requests

def watch_dns_changes(zone_id, interval=60):
    """Poll for DNS changes and trigger webhooks."""
    last_hash = None

    while True:
        records = fetch_all_records(zone_id)
        current_hash = hashlib.sha256(
            json.dumps(records, sort_keys=True).encode()
        ).hexdigest()

        if last_hash and current_hash != last_hash:
            changes = diff_records(last_records, records)
            notify_webhook(changes)

        last_hash = current_hash
        last_records = records
        time.sleep(interval)

Rate Limiting and API Best Practices

DNS APIs have rate limits. Hitting them in production is embarrassing and disruptive.

Cloudflare Rate Limits

  • 1,200 requests per 5 minutes per API token
  • That’s 4 requests/second sustained
  • Burst is allowed but sustained high rates will get throttled

Route 53 Rate Limits

  • 5 requests per second for ChangeResourceRecordSets
  • 5 requests per second for ListResourceRecordSets
  • Hard limits — exceeding them returns Throttling errors

Best Practices

import time
from functools import wraps

def rate_limit(calls_per_second=4):
    """Simple rate limiter decorator."""
    min_interval = 1.0 / calls_per_second
    last_call = [0]

    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            elapsed = time.time() - last_call[0]
            if elapsed < min_interval:
                time.sleep(min_interval - elapsed)
            result = func(*args, **kwargs)
            last_call[0] = time.time()
            return result
        return wrapper
    return decorator

@rate_limit(calls_per_second=3)
def update_dns_record(zone_id, record_id, data):
    """Rate-limited DNS update."""
    return requests.put(
        f"{CF_API}/zones/{zone_id}/dns_records/{record_id}",
        headers={"Authorization": f"Bearer {CF_TOKEN}"},
        json=data
    )

Additional best practices:

  • Cache API responses — Don’t re-fetch the full record list for every operation
  • Use exponential backoff on rate limit errors (HTTP 429)
  • Batch changes where the API supports it (Route 53’s change batches)
  • Use separate API tokens per application/environment
  • Log all API calls for auditing and debugging
  • Implement idempotency — your automation should be safe to run multiple times

Key Takeaways

  • A DNS host API (Cloudflare, Route 53, and others) publishes records. It does not register the name.
  • Route 53’s batch model is useful when several records must change together.
  • Availability checks are registrar-specific. RDAP reads registration data for names that already exist.
  • Build rate limiting into your clients from day one — don’t wait for 429s
  • Log every call and make the operation safe to run twice