Creating and updating DNS records from your own backend

The five record types, the fields that only apply to some of them, and the difference between create, update and upsert on a live zone.

Infrai exposes DNS records as four calls against a zone: POST /v1/dns/record/create, PATCH /v1/dns/record/update, PUT /v1/dns/record/upsert and DELETE /v1/dns/record/delete, with GET /v1/dns/record/list to read. Every one takes a zone_id you got from POST /v1/dns/domain/add, and the record_type is a closed enum — A, AAAA, CNAME, TXT or MX.

The API is small. The care goes into which fields apply to which type, and into choosing the right verb for something that resolves worldwide within seconds of your call.

The five types and their fields

curl -sS -X POST "https://api.infrai.cc/v1/dns/record/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "zone_id": "3a9d545444dc7a02e085889a3f713078",
    "record_type": "A",
    "name": "api.northwind.example",
    "content": "203.0.113.10",
    "ttl": 300,
    "proxied": true
  }'
{
  "ok": true,
  "data": {
    "record_id": "rec_9wQ1zV6pLkS3dHyB",
    "zone_id": "3a9d545444dc7a02e085889a3f713078",
    "record_type": "A",
    "name": "api.northwind.example",
    "content": "203.0.113.10",
    "ttl": 300,
    "priority": null,
    "proxied": true
  }
}
Typecontent ispriorityproxied
Aan IPv4 addressignoredapplies
AAAAan IPv6 addressignoredapplies
CNAMEa hostnameignoredapplies
TXTarbitrary textignoredno
MXa mail hostrequired in practiceno

priority without MX is silently irrelevant; MX without priority is a record whose behaviour depends on a default you didn’t choose. And proxied: true routes traffic through the provider’s network instead of resolving straight to your origin — useful for hiding an origin address, meaningless for a TXT record.

create, update, upsert: pick deliberately

create adds a record. Run it twice with the same name and you get two records, and resolvers will round-robin between them — which is a feature for load balancing and a bug for a CNAME that’s supposed to point one place.

update changes an existing record, which means you need its record_id and therefore a read first.

upsert is the one to reach for from automation. It writes the desired state whether or not the record exists, so the same call is safe on a first run and a re-run:

curl -sS -X PUT "https://api.infrai.cc/v1/dns/record/upsert" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "zone_id": "3a9d545444dc7a02e085889a3f713078",
    "record_type": "CNAME",
    "name": "portal.northwind.example",
    "content": "edge.yourapp.example",
    "ttl": 300,
    "proxied": true
  }'

If your code path can ever run twice — a retried job, a customer clicking a button again, a deploy that re-applies configuration — use upsert and stop thinking about it.

A declarative applier

The pattern that scales past a handful of records: describe the zone you want, read what’s there, write the difference.

import os

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})


def current(zone_id: str) -> dict[tuple[str, str], dict]:
    resp = SESSION.get(f"{API}/v1/dns/record/list", params={"zone_id": zone_id}, timeout=25)
    resp.raise_for_status()
    return {(r["record_type"], r["name"]): r for r in resp.json()["data"].get("records", [])}


def apply_desired(zone_id: str, desired: list[dict], prune: bool = False) -> dict:
    """Upsert everything wanted, then optionally remove records we manage that are
    no longer wanted. Pruning is opt-in because a zone usually contains records
    somebody else put there on purpose."""
    existing = current(zone_id)
    written, removed, unchanged = [], [], []

    for record in desired:
        key = (record["record_type"], record["name"])
        have = existing.get(key)
        if have and have.get("content") == record.get("content") and have.get("ttl") == record.get("ttl"):
            unchanged.append(key)
            continue
        resp = SESSION.put(f"{API}/v1/dns/record/upsert", json={"zone_id": zone_id, **record}, timeout=30)
        resp.raise_for_status()
        written.append(key)

    if prune:
        wanted = {(r["record_type"], r["name"]) for r in desired}
        for key, record in existing.items():
            if key in wanted or not record["name"].startswith("_managed."):
                continue
            SESSION.delete(f"{API}/v1/dns/record/delete",
                           params={"zone_id": zone_id, "record_id": record["record_id"]}, timeout=25)
            removed.append(key)

    return {"written": written, "unchanged": unchanged, "removed": removed}


if __name__ == "__main__":
    zone = os.environ["ZONE_ID"]
    print(apply_desired(zone, [
        {"record_type": "CNAME", "name": "portal.northwind.example",
         "content": "edge.yourapp.example", "ttl": 300, "proxied": True},
        {"record_type": "TXT", "name": "_managed.northwind.example",
         "content": "managed-by=yourapp", "ttl": 300},
    ]))

Skipping the unchanged records isn’t just tidiness — a write that changes nothing still churns the record and costs you a call, and a diff-first applier makes the log of what actually changed worth reading.

The prune guard is the important part. Never delete a record you didn’t create: mark yours with a naming convention and only reconcile within it, because a zone you manage on a customer’s behalf almost certainly contains their MX records and you do not want to be the reason their email stopped.

TTL is an operational choice

Short TTLs make mistakes cheap to fix and increase resolver traffic. Long TTLs are efficient and turn a typo into a day-long problem.

The practical rule: 300 seconds while you’re changing things, longer once stable. If you’re planning a cutover, drop the TTL a day before the change so the old value has already expired everywhere when you flip it — that sequencing is the difference between a five-minute migration and an afternoon of “some users still see the old site”.

Reading and deleting

curl -sS -G "https://api.infrai.cc/v1/dns/record/list" \
  --data-urlencode "zone_id=3a9d545444dc7a02e085889a3f713078" \
  --data-urlencode "record_type=CNAME" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Filter by record_type or name to answer a narrow question. Delete takes the same identifiers:

curl -sS -X DELETE -G "https://api.infrai.cc/v1/dns/record/delete" \
  --data-urlencode "zone_id=3a9d545444dc7a02e085889a3f713078" \
  --data-urlencode "record_id=rec_9wQ1zV6pLkS3dHyB" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Limitations

The type enum is the limitation to check first: A, AAAA, CNAME, TXT, MX and nothing else. No SRV, no CAA, no NS, no DNSSEC controls — so a zone needing certificate-authority pinning or service records needs a provider console alongside this API, and that split is worth avoiding by keeping such zones out of it entirely.

Zones live at Cloudflare, so its nameservers are what customers delegate to. Using Cloudflare’s own API directly gives you every record type and every feature it has; what you get here instead is that the DNS records, the email domain verification that needs them via POST /v1/email/domain/verify, and the scheduled re-check on POST /v1/cron/create are one credential and one invoice. DNS routes report billing_class: free in discovery (verified 2026-09-21), and platform rates drift downward as vendor contracts improve.

References

Browse more dns developer guides