Type something to search...
How to automate DNS record changes with an API?

How to automate DNS record changes with an API?

To automate DNS record changes with an API, create a scoped API token at your DNS provider, then send authenticated HTTP requests to its DNS endpoints to list, create, update and delete records. With Cloudflare that means calls to https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records, with Route 53 it means ChangeResourceRecordSets through the AWS CLI or an SDK such as boto3, and with self-hosted servers such as BIND you can use standard RFC 2136 dynamic updates signed with a TSIG key through nsupdate. Wrap those calls in scripts that check the current state first, so running them twice gives the same result.

API-driven DNS is what makes deployments, certificate renewals, failover and onboarding of new customers hands-free. This guide shows how to authenticate safely, work with the Cloudflare and Route 53 APIs from the command line and Python, use nsupdate for providers that support dynamic updates, build an idempotent "upsert" script, verify changes, and avoid the mistakes that turn automation into an outage.

When to Automate DNS Through an API

Typical reasons to script DNS changes include:

  • Deployments: Point app.example.com at a new load balancer or server as part of a release.
  • Certificate automation: ACME DNS-01 challenges require creating and removing _acme-challenge TXT records.
  • Customer onboarding: SaaS platforms create a subdomain per customer, such as acme.example.com.
  • Failover: A monitoring system switches a record to a standby server when the primary fails.
  • Dynamic IPs: A home or office server updates its own record when its public IP changes.
  • Bulk changes: Updating hundreds of records across many zones in one go.

If your records are mostly static and change through reviewed pull requests, declarative tools such as Terraform are usually a better fit. Direct API calls shine for event-driven changes that need to happen in seconds without a human.

Step 1: Create a Scoped Credential

Use a credential that can do as little as possible.

  • Cloudflare: Create an API token (not the legacy Global API Key) with the Zone: DNS: Edit permission, restricted to the specific zones the script manages. You can also restrict it to client IP ranges and set an expiry date.
  • Route 53: Create an IAM role or user with a policy that allows route53:ChangeResourceRecordSets and route53:ListResourceRecordSets on the specific hosted zone ARN, plus route53:GetChange.
  • BIND and RFC 2136: Create a TSIG key and grant it permission to update only the names it needs.

A narrowly scoped Route 53 policy looks like this:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "route53:ChangeResourceRecordSets",
        "route53:ListResourceRecordSets"
      ],
      "Resource": "arn:aws:route53:::hostedzone/Z0123456789ABCDEFGHIJ"
    },
    {
      "Effect": "Allow",
      "Action": "route53:GetChange",
      "Resource": "arn:aws:route53:::change/*"
    }
  ]
}

Store credentials in a secrets manager or your CI system's secret store, and pass them as environment variables. Never commit them to a repository.

Automating Cloudflare DNS

The Cloudflare API is a JSON REST API. Every request carries your token in an Authorization: Bearer header.

Verify the Token

export CF_API_TOKEN="your-token"

curl -s https://api.cloudflare.com/client/v4/user/tokens/verify \
  -H "Authorization: Bearer $CF_API_TOKEN"
{
  "result": { "id": "f267e341f3dd4697bd3b9f71dd96247f", "status": "active" },
  "success": true,
  "errors": [],
  "messages": [{ "code": 10000, "message": "This API Token is valid and active", "type": null }]
}

Find the Zone ID

curl -s "https://api.cloudflare.com/client/v4/zones?name=example.com" \
  -H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result[0].id'
023e105f4ecef8ad9ca31a8372d0c353

List, Create, Update and Delete Records

export ZONE_ID="023e105f4ecef8ad9ca31a8372d0c353"
API="https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records"

# List A records for a name
curl -s "$API?type=A&name=app.example.com" \
  -H "Authorization: Bearer $CF_API_TOKEN" | jq '.result[] | {id, name, content, ttl}'

# Create a record
curl -s -X POST "$API" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"type":"A","name":"app.example.com","content":"203.0.113.30","ttl":300,"proxied":false,"comment":"Created by deploy script"}'

# Update only the content of an existing record
curl -s -X PATCH "$API/372e67954025e0ba6aaa6d586b9e0b59" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"content":"203.0.113.31"}'

# Delete a record
curl -s -X DELETE "$API/372e67954025e0ba6aaa6d586b9e0b59" \
  -H "Authorization: Bearer $CF_API_TOKEN"

Every response includes a success boolean and an errors array. Always check them, because an HTTP 200 alone doesn't guarantee your change was accepted in every client library.

An Idempotent Upsert Script in Python

Creating a record blindly fails or duplicates records if it already exists. An upsert checks first, then creates or updates only when needed. This script uses the requests library (pip install requests):

#!/usr/bin/env python3
"""Create or update a single Cloudflare DNS record so it matches the desired value."""
import os
import sys

import requests

API = "https://api.cloudflare.com/client/v4"
TOKEN = os.environ["CF_API_TOKEN"]
ZONE_ID = os.environ["CF_ZONE_ID"]
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}


def call(method: str, path: str, **kwargs) -> dict:
    response = requests.request(method, f"{API}{path}", headers=HEADERS, timeout=15, **kwargs)
    body = response.json()
    if not body.get("success"):
        raise RuntimeError(f"Cloudflare API error: {body.get('errors')}")
    return body


def upsert(name: str, record_type: str, content: str, ttl: int = 300, proxied: bool = False) -> str:
    existing = call(
        "GET", f"/zones/{ZONE_ID}/dns_records", params={"name": name, "type": record_type}
    )["result"]
    desired = {"type": record_type, "name": name, "content": content, "ttl": ttl, "proxied": proxied}

    if not existing:
        call("POST", f"/zones/{ZONE_ID}/dns_records", json=desired)
        return "created"

    if len(existing) > 1:
        raise RuntimeError(f"{len(existing)} {record_type} records exist for {name}; refusing to guess")

    record = existing[0]
    if (record["content"], record["ttl"], record.get("proxied", False)) == (content, ttl, proxied):
        return "unchanged"

    call("PATCH", f"/zones/{ZONE_ID}/dns_records/{record['id']}", json=desired)
    return "updated"


if __name__ == "__main__":
    if len(sys.argv) != 4:
        sys.exit("usage: upsert.py <name> <type> <content>")
    print(upsert(sys.argv[1], sys.argv[2].upper(), sys.argv[3]))
$ python3 upsert.py app.example.com A 203.0.113.30
created
$ python3 upsert.py app.example.com A 203.0.113.30
unchanged
$ python3 upsert.py app.example.com A 203.0.113.31
updated

Refusing to act when several records match is deliberate. It's safer for a script to stop and ask for help than to overwrite the wrong record in a round-robin set.

Automating Route 53

Route 53 changes are submitted as a change batch: a list of CREATE, DELETE or UPSERT actions that are applied atomically. Either all changes succeed, or none do.

Using the AWS CLI

Write the change batch to a file:

{
  "Comment": "Deploy release 2026.10.23",
  "Changes": [
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "app.example.com.",
        "Type": "A",
        "TTL": 300,
        "ResourceRecords": [{ "Value": "203.0.113.30" }]
      }
    }
  ]
}

Submit it and wait for the change to reach every Route 53 nameserver:

change_id=$(aws route53 change-resource-record-sets \
  --hosted-zone-id Z0123456789ABCDEFGHIJ \
  --change-batch file://change.json \
  --query 'ChangeInfo.Id' --output text)

aws route53 wait resource-record-sets-changed --id "$change_id"
echo "Change $change_id is INSYNC"

UPSERT creates the record set if it doesn't exist and replaces it if it does, so it's naturally idempotent. Note that it replaces the whole record set: if app.example.com had two A values and you upsert one, the other is removed.

Using Python and boto3

#!/usr/bin/env python3
import boto3

route53 = boto3.client("route53")


def upsert_a(zone_id: str, name: str, ips: list[str], ttl: int = 300) -> None:
    response = route53.change_resource_record_sets(
        HostedZoneId=zone_id,
        ChangeBatch={
            "Comment": f"Automated update for {name}",
            "Changes": [
                {
                    "Action": "UPSERT",
                    "ResourceRecordSet": {
                        "Name": name,
                        "Type": "A",
                        "TTL": ttl,
                        "ResourceRecords": [{"Value": ip} for ip in ips],
                    },
                }
            ],
        },
    )
    change_id = response["ChangeInfo"]["Id"]
    route53.get_waiter("resource_record_sets_changed").wait(Id=change_id)
    print(f"{name} -> {', '.join(ips)} is in sync")


upsert_a("Z0123456789ABCDEFGHIJ", "app.example.com.", ["203.0.113.30", "203.0.113.31"])

Route 53 throttles API requests per account, with a documented limit of five requests per second. Batch related changes into one call rather than making many small calls, and let the SDK's built-in retries handle occasional Throttling errors.

Using RFC 2136 Dynamic Updates

Not every DNS server has a REST API, but many speak the standard DNS UPDATE protocol defined in RFC 2136, secured with TSIG shared keys. BIND, Knot DNS, PowerDNS and Windows DNS support it, as do some hosted providers. The client tool is nsupdate, part of the BIND utilities.

Create a TSIG Key

tsig-keygen -a hmac-sha256 deploy-key | sudo tee /etc/bind/deploy-key.conf
key "deploy-key" {
	algorithm hmac-sha256;
	secret "c3VwZXJzZWNyZXRrZXlleGFtcGxlZm9yZG9jcw==";
};

Allow the Key to Update the Zone

In BIND's configuration, include the key and grant it update rights. update-policy lets you restrict which names and types the key can change:

include "/etc/bind/deploy-key.conf";

zone "example.com" {
    type primary;
    file "/var/lib/bind/db.example.com";
    update-policy {
        grant deploy-key name app.example.com. A AAAA;
        grant deploy-key subdomain _acme-challenge.example.com. TXT;
    };
};

Keep dynamically updated zone files in a directory BIND can write to, such as /var/lib/bind, and reload with sudo rndc reconfig after editing the configuration.

Send an Update

nsupdate -k /etc/bind/deploy-key.conf <<'EOF'
server ns1.example.com
zone example.com
update delete app.example.com. A
update add app.example.com. 300 A 203.0.113.30
send
EOF

Deleting before adding replaces the record set cleanly. If the key isn't allowed to make the change, nsupdate reports update failed: REFUSED.

Certificate Automation With DNS-01

Let's Encrypt and other ACME certificate authorities can verify domain control through a TXT record at _acme-challenge.<name>. This is required for wildcard certificates. Most ACME clients have plugins that call your DNS provider's API, for example Certbot's Cloudflare plugin:

sudo apt install python3-certbot-dns-cloudflare

cat > ~/.secrets/cloudflare.ini <<'EOF'
dns_cloudflare_api_token = your-scoped-token
EOF
chmod 600 ~/.secrets/cloudflare.ini

sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials ~/.secrets/cloudflare.ini \
  -d example.com -d '*.example.com'

For this use, give the token access only to the zones you're issuing certificates for. Some teams go further and delegate _acme-challenge names to a separate zone, so the certificate tool's credential can't touch the main zone at all.

Verifying Changes

An API returning success means the provider accepted the change. It doesn't mean every authoritative server is serving it yet. Check the nameservers directly:

for ns in $(dig example.com NS +short); do
  echo "$ns $(dig @"$ns" app.example.com A +short | tr '\n' ' ')"
done
ana.ns.cloudflare.com. 203.0.113.30
bob.ns.cloudflare.com. 203.0.113.30

Remember that resolvers which cached the old answer keep using it until the old record's TTL expires. If you plan a change in advance, lower the TTL first so the switch takes effect quickly.

Making Automation Safe

DNS automation fails in quiet, far-reaching ways, so build in guard rails:

  1. Read before you write: Compare the current state with the desired state and skip unnecessary changes.
  2. Limit the blast radius: Scope tokens to specific zones and, where possible, specific names.
  3. Log every change: Record the old value, new value, who or what triggered it, and when.
  4. Handle rate limits: Respect 429 responses and throttling errors with exponential backoff. Cloudflare documents a global per-user API rate limit, and Route 53 limits requests per account.
  5. Add a dry-run mode: Print what would change without applying it.
  6. Protect critical records: Exclude the apex, MX and NS records from automated deletion.
  7. Alert on unexpected changes: Compare live records against a known-good list on a schedule.
  8. Rotate credentials: Give tokens expiry dates and rotate them regularly.

A simple retry wrapper for any HTTP call might look like this:

import random
import time

import requests


def request_with_retry(method: str, url: str, attempts: int = 5, **kwargs) -> requests.Response:
    for attempt in range(attempts):
        response = requests.request(method, url, timeout=15, **kwargs)
        if response.status_code not in (429, 500, 502, 503, 504):
            return response
        time.sleep(min(30, 2 ** attempt + random.random()))
    response.raise_for_status()
    return response

FAQ: Automating DNS With an API

It can be, if the credential is tightly scoped. Use API tokens limited to specific zones and DNS editing, store them in a secrets manager, set expiry dates, and log every change the script makes.

UPSERT creates a record set if it doesn't exist or replaces it if it does. It replaces the whole set for that name and type, so include every value you want to keep.

Providers usually push changes to their nameservers within seconds to a minute. Visitors may still see the old value until cached copies expire, which depends on the TTL the old record had.

Check whether it supports RFC 2136 dynamic updates, which work with nsupdate. If it supports neither, consider moving DNS to a provider with an API, or delegating a subdomain to one for automated records.

Use Terraform or similar tools for static records you change through reviewed pull requests. Use direct API calls for event-driven changes such as certificate challenges, deployments and failover, where speed matters.

Creating a record with POST doesn't check whether a matching record already exists. Look up existing records first and update them with PATCH, as in the upsert script above.


Conclusion

Automating DNS through an API comes down to a scoped credential, a handful of well-understood calls, and scripts that check the current state before changing anything. Cloudflare's REST API, Route 53's atomic change batches and RFC 2136 dynamic updates with nsupdate cover almost every provider and server you're likely to use.

Start small with a single upsert script, add verification against your authoritative nameservers, then build in logging, retries and protection for critical records. Done carefully, API-driven DNS makes deployments, certificates and failover faster and far less error-prone than manual changes.

Tags :
Share :

Related Posts

What is the difference between an A record and a CNAME record?

What is the difference between an A record and a CNAME record?

The difference between an A record and a CNAME record is what they point to. An A record points a hostname directly at an IPv4 address, such

Dive Deeper
What are the best DNS providers for websites?

What are the best DNS providers for websites?

The best DNS providers for most websites are Cloudflare for a fast, free, easy starting point, Amazon Route 53 if you already run on AWS, **G

Dive Deeper
What are the best public DNS resolvers?

What are the best public DNS resolvers?

The best public DNS resolvers for most people are Cloudflare (1.1.1.1) for speed and privacy, Google Public DNS (8.8.8.8) for reliability and

Dive Deeper