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
}
}
| Type | content is | priority | proxied |
|---|---|---|---|
A | an IPv4 address | ignored | applies |
AAAA | an IPv6 address | ignored | applies |
CNAME | a hostname | ignored | applies |
TXT | arbitrary text | ignored | no |
MX | a mail host | required in practice | no |
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.