Upsert a DNS record instead of creating a duplicate

Why create-on-retry produces two records and a resolver flipping between them, what upsert changes, and how to clean up a zone that already has duplicates.

POST /v1/dns/record/create does exactly what it says, which is the problem. Call it twice with the same name and Infrai’s zone ends up with two records, resolvers hand out whichever they feel like, and half your users reach the old target. PUT /v1/dns/record/upsert writes the desired state instead: one record, whether or not it existed before.

For anything an automated system calls, upsert is the correct default and create is the special case.

How the duplicate happens

It isn’t carelessness. It’s retries.

Your onboarding job creates a CNAME, the HTTP response is lost to a timeout, the job retries, and now there are two. Or a customer clicks “connect domain” twice. Or a deploy re-applies configuration that was already applied. Each of those is normal behaviour in a distributed system, and create faithfully turns each into another record.

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
  }'
{
  "ok": true,
  "data": {
    "record_id": "rec_9wQ1zV6pLkS3dHyB",
    "zone_id": "3a9d545444dc7a02e085889a3f713078",
    "record_type": "CNAME",
    "name": "portal.northwind.example",
    "content": "edge.yourapp.example",
    "ttl": 300,
    "priority": null,
    "proxied": true
  }
}

Run that ten times and the zone has one record. The record_id stays stable, which also means your own bookkeeping doesn’t drift.

The identity of a record

Upsert matches on the pair that actually identifies a record: its record_type and its name. Change the content and you’ve updated where it points; change the name and you’ve created a different record.

That has a consequence worth internalising. If your desired state moves a hostname from one target to another, upsert is the whole operation — no read, no delete, no window where the name doesn’t resolve. If your desired state renames a hostname, upsert writes the new one and leaves the old one alone, so you also need a delete.

ChangeCalls needed
Point portal at a new targetone upsert
Change TTL onlyone upsert
Rename portal to appupsert the new, delete the old
Add a second MX for redundancytwo records, so create is right
Load-balance across two A recordstwo records, so create is right

Those last two rows are why create still exists. Multiple records with the same name are legitimate for MX and for round-robin A records — there, duplication is the feature.

A safe applier for the retry case

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
const RETRYABLE = new Set(["RATE_LIMIT_ACCOUNT", "RATE_LIMIT_VENDOR", "VENDOR_TIMEOUT", "NETWORK_ERROR"]);

/**
 * Upsert with retries. This is safe to retry precisely BECAUSE it's an upsert —
 * the same call with create would multiply records on every attempt, which is how
 * a transient network error becomes a permanent resolution problem.
 */
export async function upsertRecord(record, attempt = 0) {
  const res = await fetch(`${API}/v1/dns/record/upsert`, {
    method: "PUT",
    headers,
    body: JSON.stringify(record),
  });
  const body = await res.json();
  if (body.ok) return body.data;

  const code = body.error?.code;
  if (!RETRYABLE.has(code) || attempt >= 4) {
    throw new Error(`upsert failed: ${code ?? res.status}`);
  }
  const delay = Math.min(30_000, 2 ** attempt * 500) + Math.random() * 250;
  await new Promise((r) => setTimeout(r, delay));
  return upsertRecord(record, attempt + 1);
}

export async function listRecords(zoneId, recordType) {
  const url = new URL(`${API}/v1/dns/record/list`);
  url.searchParams.set("zone_id", zoneId);
  if (recordType) url.searchParams.set("record_type", recordType);
  const res = await fetch(url, { headers });
  const { data } = await res.json();
  return data.records ?? [];
}

Cleaning up a zone that already has duplicates

If you’ve been using create from a retrying job, you probably have some. Find them by grouping:

import os
from collections import defaultdict

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"})
# Types where several records with one name are LEGITIMATE. Never dedupe these.
MULTI_VALUED = {"MX", "A", "AAAA", "TXT"}


def duplicates(zone_id: str) -> dict[tuple[str, str], list[dict]]:
    resp = SESSION.get(f"{API}/v1/dns/record/list", params={"zone_id": zone_id}, timeout=25)
    resp.raise_for_status()
    grouped = defaultdict(list)
    for record in resp.json()["data"].get("records", []):
        grouped[(record["record_type"], record["name"])].append(record)
    return {key: rows for key, rows in grouped.items()
            if len(rows) > 1 and key[0] not in MULTI_VALUED}


def dedupe(zone_id: str, apply: bool = False) -> list[str]:
    """Keep one record per (type, name) for single-valued types only. A CNAME with
    two entries is always wrong; an MX with two entries is a mail setup."""
    removed = []
    for (record_type, name), rows in duplicates(zone_id).items():
        keep = rows[0]
        for extra in rows[1:]:
            if apply:
                SESSION.delete(f"{API}/v1/dns/record/delete",
                               params={"zone_id": zone_id, "record_id": extra["record_id"]},
                               timeout=25)
            removed.append(f"{record_type} {name} -> dropped {extra['record_id']}, kept {keep['record_id']}")
    return removed


if __name__ == "__main__":
    for line in dedupe(os.environ["ZONE_ID"], apply=os.environ.get("APPLY") == "1"):
        print(line)

The MULTI_VALUED guard is not optional. Deduplicating MX records breaks a customer’s mail, and deduplicating round-robin A records silently halves someone’s capacity — dry-run this and read the output before letting it delete anything.

What upsert doesn’t solve

It doesn’t make DNS transactional. There’s no way to write three records atomically, so a partial failure halfway through provisioning leaves a zone in a mixed state — which is exactly why the applier should be re-runnable rather than clever, and why upsert matters more than a rollback would.

It also doesn’t give you optimistic concurrency: there’s no version or ETag on a record, so two systems upserting the same name will simply take turns, last write winning. If more than one process manages the same records, that’s a design problem the API can’t fix — and the reason to keep DNS writes behind one service.

Cloudflare’s own API, which sits behind these routes, exposes more: every record type, DNSSEC, and bulk import. Going direct is the right call if you need those. What you get here is that the DNS write, the email domain verification that depends on it via POST /v1/email/domain/verify, and the scheduled drift check on POST /v1/cron/create are one credential, one invoice and one GET /v1/account/usage — 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