Blog
CloudFloorDNS API: Automate Your DNS with curl and Python
October 28, 2026
The CloudFloorDNS API lets you manage DNS from code: create zones, change records, run secondary DNS, switch on DNSSEC, set up Netmon monitoring and failover, and handle domain registrations. It’s a plain JSON REST API with more than 100 endpoints, and you can call it from anything that speaks HTTP.
This guide takes you from nothing to useful automation: your first authenticated request, a small Python client, a drop-in Certbot hook for wildcard Let’s Encrypt certificates, and a nightly zone backup. The examples follow the published API reference, so you can copy them, swap in your own domain and API key, and run them.
At a glance
Base URL: https://apiv3.mtgsy.net/api/v1
Authentication: HTTP Basic: an Authorization: Basic header with your panel username and API key, base64-encoded
Format: JSON in and out; results are wrapped in a data field
Reference: apiv3.mtgsy.net/docs/v1
Six things worth knowing before you write any code
The reference documents every endpoint. These are the details that tend to cost people an afternoon.
Every request carries an Authorization: Basic header: your panel username and API key, joined with a colon and base64-encoded. There’s no token exchange step, so guard the key like a password.
Every zone lives on Zone1 or Zone2, and each has its own record endpoints. Zone2 is the newer platform and where the DNSSEC management, TSIG, bulk-edit and export endpoints live. This guide uses Zone2; Zone1 has its own section further down.
You update or delete a record by matching its type, name and current content exactly. The reliable pattern: read the zone first, then send values back exactly as the API returned them.
Hostnames in content end with a dot (mail.example.com.), TXT values keep their double quotes, and an MX priority goes inside the content (10 mail.example.com.), as do SRV priority, weight and port.
120 GETs a minute, 60 PATCHes, and 30 each of POST, PUT and DELETE. One POST can create many records and there are bulk update and delete endpoints, so batch where you can.
Internationalized domain names can show up in their xn-- form. That’s expected, so match on that form in your code.
Your first API calls, step by step
The examples use curl and example.com. Replace it with a zone on your account.
Get your API key
Sign in to the CloudFloorDNS control panel and generate an API key for your user. Then load your credentials into environment variables so they stay out of your scripts, and encode them once for the Basic auth header:
export CFDNS_USER='your-panel-username'
# Prompts without echoing, so the key stays out of your shell history
printf 'API key: '; read -rs CFDNS_KEY; echo; export CFDNS_KEY
export API='https://apiv3.mtgsy.net/api/v1'
# Credentials for the Basic auth header: base64 of username:api_key
AUTH=$(printf '%s:%s' "$CFDNS_USER" "$CFDNS_KEY" | base64 | tr -d '\n')
List your zones
GET /dns/zone2 returns your Zone2 zones. Every example sends the same Authorization: Basic header shown in the API reference, so a 401 here usually means the username or key is wrong. If you prefer, curl’s -u "$CFDNS_USER:$CFDNS_KEY" builds an identical header.
curl -s -H "Authorization: Basic $AUTH" "$API/dns/zone2"
Response (trimmed):
{
"statusCode": 200,
"data": [
{
"id": 10234,
"domainname": "example.com",
"primary": 1,
"secondary": 0,
"group_ids": []
}
]
}
GET /dns lists every domain on your account, and GET /dns/zone lists the ones on Zone1.
Read a zone’s records
curl -s -H "Authorization: Basic $AUTH" \
"$API/dns/zone2/example.com/record"
Response (trimmed):
{
"statusCode": 200,
"data": {
"id": 10234,
"domainname": "example.com",
"records": [
{ "name": "example.com", "type": "A",
"content": "203.0.113.5", "ttl": 3600,
"disabled": 0, "comment": "" },
{ "name": "www.example.com", "type": "CNAME",
"content": "example.com.", "ttl": 3600,
"disabled": 0, "comment": "" }
]
}
}
For a quick, readable listing, pipe it through jq:
curl -s -H "Authorization: Basic $AUTH" \
"$API/dns/zone2/example.com/record" |
jq -r '.data.records[] | [.name, .ttl, .type, .content] | @tsv'
Add records
POST takes a records array, so one request (and one hit against the POST limit) can create several records. ttl is in seconds; disabled and comment are optional.
curl -s -H "Authorization: Basic $AUTH" -X POST \
-H 'Content-Type: application/json' \
"$API/dns/zone2/example.com/record" \
--data '{
"records": [
{ "name": "app.example.com", "type": "A",
"content": "203.0.113.10", "ttl": 3600 },
{ "name": "example.com", "type": "MX",
"content": "10 mail.example.com.", "ttl": 3600 },
{ "name": "example.com", "type": "TXT",
"content": "\"site-verification=4f9c2a7e\"", "ttl": 3600 }
]
}'
Change a record
PATCH /record finds the record by search_type, search_name and search_content, then applies whichever of content, ttl, disabled and comment you send. Setting disabled to 1 parks a record without deleting it.
curl -s -H "Authorization: Basic $AUTH" -X PATCH \
-H 'Content-Type: application/json' \
"$API/dns/zone2/example.com/record" \
--data '{
"search_type": "A",
"search_name": "app.example.com",
"search_content": "203.0.113.10",
"content": "203.0.113.20",
"ttl": 300
}'
Delete a record
On Zone2 a delete is a PATCH to /delete_record. Always include content unless you mean to remove every record with that type and name.
curl -s -H "Authorization: Basic $AUTH" -X PATCH \
-H 'Content-Type: application/json' \
"$API/dns/zone2/example.com/delete_record" \
--data '{ "type": "A", "name": "app.example.com",
"content": "203.0.113.20" }'
If an update or delete fails on a record you can see
Zone2 compares type, name and content character for character. The usual culprits are a TXT value missing its surrounding quotes, a hostname with or without its trailing dot, or content that changed since you last read it. Copy the values from a fresh GET.
A small Python client
Past a one-off, it pays to wrap the calls. This client needs only requests (python3-requests from your distribution, or pip install requests in a virtualenv). It builds the Basic auth header, waits and retries on an HTTP 429 or a 503 on a read, and turns API errors into exceptions that carry the API’s own description.
"""Minimal client for the CloudFloorDNS API (Zone2 endpoints)."""
import base64
import time
import requests
API_BASE = "https://apiv3.mtgsy.net/api/v1"
class CloudFloorDNSError(Exception):
pass
class CloudFloorDNS:
def __init__(self, username, api_key, base_url=API_BASE, timeout=30):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
creds = base64.b64encode(f"{username}:{api_key}".encode()).decode()
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Basic {creds}", # base64(username:api_key)
"Accept": "application/json",
})
def _request(self, method, path, **kwargs):
kwargs.setdefault("timeout", self.timeout)
url = self.base_url + path
for attempt in range(5):
resp = self.session.request(method, url, **kwargs)
# Rate limited, or a read hit a temporary 503: wait and retry
retry = resp.status_code == 429 or (
resp.status_code == 503 and method == "GET")
if not retry:
break
wait = resp.headers.get("Retry-After", "")
time.sleep(int(wait) if wait.isdigit() else 2 ** attempt)
try:
body = resp.json()
except ValueError:
body = {}
if not resp.ok:
err = body.get("error") or {}
detail = err.get("description") or resp.text[:200]
raise CloudFloorDNSError(
f"{method} {path} -> {resp.status_code}: {detail}")
return body.get("data")
# Zones
def zones(self):
"""Your zones on the Zone2 platform."""
return self._request("GET", "/dns/zone2")
def export(self, domain):
"""The whole zone as BIND zone-file text."""
data = self._request("GET", f"/dns/zone2/{domain}/export")
return data["zone_content"]
# Records
def records(self, domain, rtype=None, name=None):
data = self._request("GET", f"/dns/zone2/{domain}/record")
recs = data["records"]
if rtype:
recs = [r for r in recs if r["type"] == rtype.upper()]
if name:
recs = [r for r in recs if r["name"].lower() == name.lower()]
return recs
def add(self, domain, *records):
"""Create records: dicts with name, type, content and ttl."""
path = f"/dns/zone2/{domain}/record"
return self._request("POST", path, json={"records": list(records)})
def update(self, domain, rtype, name, current_content, **changes):
"""Change content, ttl, disabled or comment on an exact match."""
body = {"search_type": rtype, "search_name": name,
"search_content": current_content, **changes}
path = f"/dns/zone2/{domain}/record"
return self._request("PATCH", path, json=body)
def delete(self, domain, rtype, name, content):
"""Delete the record with exactly this type, name and content."""
body = {"type": rtype, "name": name, "content": content}
path = f"/dns/zone2/{domain}/delete_record"
return self._request("PATCH", path, json=body)
Using it:
import os
from cfdns import CloudFloorDNS
cf = CloudFloorDNS(os.environ["CFDNS_USER"], os.environ["CFDNS_KEY"])
# Print every record in the zone
for r in cf.records("example.com"):
print(f'{r["name"]:<32} {r["ttl"]:>6} {r["type"]:<6} {r["content"]}')
# Add a host, repoint it with a short TTL, then remove it
cf.add("example.com", {"name": "app.example.com", "type": "A",
"content": "203.0.113.10", "ttl": 3600})
cf.update("example.com", "A", "app.example.com", "203.0.113.10",
content="203.0.113.20", ttl=300)
cf.delete("example.com", "A", "app.example.com", content="203.0.113.20")
Recipe: wildcard Let’s Encrypt certificates with DNS-01
Let’s Encrypt issues wildcard certificates only through the DNS-01 challenge: you prove control of a domain by publishing a TXT record at _acme-challenge.<domain>. Certbot can run one script to publish that record and another to remove it. These three small files make issuance and renewal automatic for your primary zones on Zone2.
1. Shared settings
Your credentials plus two helpers. cfdns calls the API with the Basic auth header and prints the API’s error message if a call fails. find_zone works out which of your zones a name belongs to, so the TXT record for a certificate on api.example.com goes into the example.com zone.
# Shared by both certbot hooks. It holds your API key, so: chmod 600
CFDNS_USER="your-panel-username"
CFDNS_KEY="your-api-key"
API="https://apiv3.mtgsy.net/api/v1"
AUTH=$(printf '%s:%s' "$CFDNS_USER" "$CFDNS_KEY" | base64 | tr -d '\n')
cfdns() { # usage: cfdns METHOD PATH [JSON-BODY]
local args=(-sS -w '\n%{http_code}' -H "Authorization: Basic ${AUTH}"
-X "$1" "${API}$2")
if [[ $# -ge 3 ]]; then
args+=(-H "Content-Type: application/json" --data "$3")
fi
local out
out=$(curl "${args[@]}") || { CFDNS_STATUS=000; return 1; }
CFDNS_STATUS=${out##*$'\n'}
if [[ "$CFDNS_STATUS" != 2* ]]; then
echo "CloudFloorDNS API: HTTP ${CFDNS_STATUS} ${out%$'\n'*}" >&2
return 1
fi
}
find_zone() { # walk up from the certificate name to the zone hosted with us
local zone="$1"
while [[ "$zone" == *.* ]]; do
if cfdns GET "/dns/zone2/${zone}/record" 2> /dev/null; then
echo "$zone"
return 0
elif [[ "$CFDNS_STATUS" != 404 ]]; then
echo "CloudFloorDNS API: HTTP ${CFDNS_STATUS} looking up ${zone}" >&2
return 1
fi
zone="${zone#*.}"
done
echo "No CloudFloorDNS Zone2 zone found for $1" >&2
return 1
}
2. The auth hook
Publishes the token, then waits until each of the zone’s nameservers returns it (as seen from this server), so Let’s Encrypt is less likely to check too early. If a nameserver still hasn’t got it after about two minutes, the hook prints a warning and carries on.
#!/usr/bin/env bash
# certbot --manual-auth-hook: publish the DNS-01 token as a TXT record
set -euo pipefail
source /etc/letsencrypt/cfdns.sh
command -v dig > /dev/null || { echo "dig is required" >&2; exit 1; }
ZONE=$(find_zone "$CERTBOT_DOMAIN")
NAME="_acme-challenge.${CERTBOT_DOMAIN}"
BODY=$(printf '{"records": [{"name": "%s", "type": "TXT", "ttl": 60,
"content": "\\"%s\\""}]}' "$NAME" "$CERTBOT_VALIDATION")
cfdns POST "/dns/zone2/${ZONE}/record" "$BODY"
# Wait (up to ~2 minutes per nameserver) until each one returns the token
for ns in $(dig +short NS "$ZONE"); do
for _ in $(seq 24); do
answer=$(dig +short +time=2 +tries=1 TXT "$NAME" @"$ns" || true)
[[ "$answer" == *"$CERTBOT_VALIDATION"* ]] && continue 2
sleep 5
done
echo "Warning: $ns is not serving the token yet; continuing" >&2
done
3. The cleanup hook
Removes only the value it created. That matters when one certificate covers example.com and *.example.com, because both challenges use the same _acme-challenge name with different values.
#!/usr/bin/env bash
# certbot --manual-cleanup-hook: remove the TXT record the auth hook created
set -euo pipefail
source /etc/letsencrypt/cfdns.sh
ZONE=$(find_zone "$CERTBOT_DOMAIN")
NAME="_acme-challenge.${CERTBOT_DOMAIN}"
BODY=$(printf '{"type": "TXT", "name": "%s", "content": "\\"%s\\""}' \
"$NAME" "$CERTBOT_VALIDATION")
cfdns PATCH "/dns/zone2/${ZONE}/delete_record" "$BODY"
4. Issue the certificate
sudo chmod 600 /etc/letsencrypt/cfdns.sh
sudo chmod 700 /etc/letsencrypt/cfdns-auth.sh \
/etc/letsencrypt/cfdns-cleanup.sh
sudo certbot certonly --manual --preferred-challenges dns \
--manual-auth-hook /etc/letsencrypt/cfdns-auth.sh \
--manual-cleanup-hook /etc/letsencrypt/cfdns-cleanup.sh \
-d example.com -d '*.example.com'
Certbot saves the hook paths in the certificate’s renewal settings, so certbot renew, and the renewal timer most packages install, keep working without you.
Requirements and other ACME clients
The hooks need bash, curl and dig (dnsutils on Debian and Ubuntu, bind-utils on RHEL-family systems). Recent Certbot releases also provide the name as CERTBOT_IDENTIFIER; the scripts use CERTBOT_DOMAIN, which old and new versions both set. acme.sh and lego can make the same two API calls from their own hook mechanisms.
Recipe: nightly zone file backups
GET /dns/zone2/{domain}/export returns a whole zone as zone-file text. Loop over your Zone2 zones and you have an offline copy you can diff, archive, or load into another DNS server in an emergency. The script paces itself to stay under the GET limit, and if one export fails it carries on with the rest and exits non-zero so cron can tell you.
#!/usr/bin/env python3
"""Export every Zone2 zone to a dated folder of BIND zone files."""
import datetime
import os
import pathlib
import sys
import time
from cfdns import CloudFloorDNS, CloudFloorDNSError
cf = CloudFloorDNS(os.environ["CFDNS_USER"], os.environ["CFDNS_KEY"])
outdir = pathlib.Path("zone-backups", datetime.date.today().isoformat())
outdir.mkdir(parents=True, exist_ok=True)
failed = []
for zone in cf.zones():
name = zone["domainname"]
try:
(outdir / f"{name}.zone").write_text(cf.export(name))
print(f"saved {name}")
except CloudFloorDNSError as err:
failed.append(name)
print(f"FAILED {name}: {err}", file=sys.stderr)
time.sleep(0.5) # stay under 120 GET requests a minute
sys.exit(1 if failed else 0)
Make it executable (chmod +x backup_zones.py) and run it nightly from cron, with your credentials in a root-only environment file next to the script:
# /etc/cron.d/dns-backup (cfdns.env exports CFDNS_USER and CFDNS_KEY)
15 3 * * * root cd /opt/dns-backup && . ./cfdns.env && ./backup_zones.py
Prefer a change history? Write to a fixed folder instead of a dated one and commit it to git each night. git log -p then shows exactly what changed in your DNS, and when.
Still on Zone1?
Zone1 has its own record endpoints and identifies each record by a numeric id. A record carries id, name, type, data, aux, ttl and active (Y or N), and you change or delete it by id. Deletes use the DELETE method:
# Zone1 records include an id...
curl -s -H "Authorization: Basic $AUTH" \
"$API/dns/zone/example.com/record"
# ...which you use to change or remove a record
curl -s -H "Authorization: Basic $AUTH" -X PATCH \
-H 'Content-Type: application/json' \
"$API/dns/zone/example.com/record/123456" \
--data '{ "data": "203.0.113.20", "ttl": 300 }'
curl -s -H "Authorization: Basic $AUTH" -X DELETE \
"$API/dns/zone/example.com/record/123456"
Moving a zone to Zone2 takes three calls: request the migration, approve it, then check its status. If you’d like a second pair of eyes before moving a busy zone, email support@cloudfloordns.com.
Z="$API/dns/zone/example.com"
curl -s -H "Authorization: Basic $AUTH" -X PATCH "$Z/migrate_to_zone2"
curl -s -H "Authorization: Basic $AUTH" -X PATCH \
"$Z/approve_migration_to_zone2"
curl -s -H "Authorization: Basic $AUTH" "$Z/migration_status"
What else the API covers
Records are only part of it. Here’s the rest by area; each endpoint in the reference includes a curl example.
| Area | What you can do | Where to look |
|---|---|---|
| Zones | Create zones (optionally straight from an existing zone file), list, export and delete them | /dns/zone2, /dns/zone2/{domain} |
| Records | List, add, update and delete records; bulk update and bulk delete | /dns/zone2/{domain}/record, /dns/zone2/{domain}/bulk_update_records, /dns/zone2/{domain}/bulk_delete_records |
| Secondary DNS | Add secondary zones with up to six primary IPv4 addresses and TSIG, force a transfer, check its status, convert between primary and secondary | /dns/zone2/secondary, /dns/zone2/{domain}/sync_from_primary, /dns/zone2/{domain}/secondary_status |
| DNSSEC and transfers | Enable or disable DNSSEC and read the DS records for your registrar; manage TSIG keys; restrict zone transfers and set NOTIFY targets | /dns/zone2/{domain}/enable_dns_sec, /dns/zone2/{domain}/dns_sec, /dns/zone2/{domain}/tsigkey, /dns/zone2/{domain}/restrict_zone_transfers |
| Netmon monitoring and failover | Create and manage uptime tests, read status and logs, pause, resume or force a state, and attach DNS, simple IP, CNAME, URL-redirect or custom-page failover | /netmon |
| Query statistics | Query volumes by date, hour, day, record, country and subdomain, plus a usage report across your account | /dns/stats/{domain}, /dns/zone/usage_report |
| Domain registration | Register, renew and transfer domains; check availability and pricing; manage locks, auto-renew, EPP codes and email forwarding | /domain |
| Account | Groups and user permissions, audit logs, transactions, and plan usage | /manage, /services |
Rate limits and errors
| Method | Requests per minute |
|---|---|
| GET | 120 |
| PATCH | 60 |
| POST | 30 |
| PUT | 30 |
| DELETE | 30 |
Because the limits are per method, the bulk endpoints are worth knowing: one PATCH to bulk_update_records can replace many single updates.
Errors come back with the HTTP status plus a JSON body containing statusCode and an error object with type and description fields. The description is written for humans, so log it.
| Status | What it usually means |
|---|---|
| 400 | A parameter is missing or malformed |
| 401 | Wrong username or API key, or your user lacks permission for the request |
| 403 | You’ve reached the domain limit on your plan |
| 404 | The domain or record wasn’t found |
| 409 | Already done: the zone already exists, DNSSEC is already on, or the zone is already on Zone2 |
| 422 | The request body failed validation |
| 503 | A temporary back-end problem; wait a moment and retry |
Frequently asked questions
Can I try requests from the API documentation page?
No. The reference at apiv3.mtgsy.net/docs/v1 is read-only. Use curl, Postman, Insomnia or the Python client above; every endpoint in the reference includes a curl example you can adapt.
Why does my update or delete fail when the record clearly exists?
On Zone2, records are matched on type, name and content exactly, including the quotes around TXT values and the trailing dot on hostnames in the content. Copy the values from a fresh GET of the zone’s records instead of typing them.
Can I get wildcard Let’s Encrypt certificates for domains hosted with CloudFloorDNS?
Yes, for primary zones on Zone2. Wildcard certificates require the DNS-01 challenge, and the two Certbot hook scripts in this guide handle both issuance and automatic renewal.
Does the API cover secondary DNS and DNSSEC?
Yes. On Zone2 you can add secondary zones with TSIG, force a zone transfer, check transfer status, enable or disable DNSSEC, and fetch the DS records your registrar needs.
What are the API rate limits?
120 GET, 60 PATCH, and 30 each of POST, PUT and DELETE requests per minute. For large changes, create several records per POST and use the bulk update and bulk delete endpoints.
Related guides
- Secondary DNS with Cloudflare as your primary
- Secondary DNS overview and pricing
- Moving from DNS Made Easy to CloudFloorDNS
Ready to automate your DNS?
Create an account, add a zone and generate an API key, and you’re ready to try the examples on this page. Questions about the API? Get in touch and we’ll help.