DNS API Guide
CloudFloorDNS Icon

Blog

CloudFloorDNS API: Automate Your DNS with curl and Python

October 28, 2026
DNS API Guide

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.

01
It’s HTTP Basic auth

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.

02
Two zone platforms, two record APIs

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.

03
Zone2 records don’t have IDs

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.

04
Content is zone-file syntax

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.

05
Limits are per HTTP method

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.

06
International names may appear as punycode

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.

1

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')
2

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.

3

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'
4

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 }
    ]
  }'
5

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
  }'
6

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.

cfdns.py
"""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.

/etc/letsencrypt/cfdns.sh
# 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.

/etc/letsencrypt/cfdns-auth.sh
#!/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.

/etc/letsencrypt/cfdns-cleanup.sh
#!/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.

backup_zones.py
#!/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

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.

Let’s Encrypt is a trademark of the Internet Security Research Group, and Certbot is a project of the Electronic Frontier Foundation. CloudFloorDNS is not affiliated with either.