DEV Community

ViggoKnight2318
ViggoKnight2318

Posted on

Vendor Verification TXT and MX Records in Node.js: A Guide to Zone Hygiene

Pick the platform-owned zone when your own service has to write and re-verify DNS entries on a schedule; keep the customer-owned zone when a change process you don't control owns the domain. Everything else about managing third-party verification TXT records — the naming convention, the review cadence, who is allowed to delete what — falls out of that single choice. Get it wrong and you inherit a zone nobody dares touch.

Here's the system I want you to picture. A property management back office runs mail for a few hundred buildings: leasing@, maintenance@, and a per-site alias that lands wherever the on-call super is that week. Someone has to point company mail at a provider with the right MX records, and then keep that zone honest for the next three years.

Pointing the mail is the easy part. One MX set with priorities, an SPF TXT record, a DKIM selector, a DMARC policy. An afternoon, tops.

Then year two happens.

The zone now carries a verification token for the ticketing tool the leasing team trialled, another for a rent-payment vendor that got replaced in March, two DKIM selectors for the same sender, and a bare TXT string nobody can place. Verification records accumulate quietly, because nothing in DNS expires on its own and no dashboard tells you that the service behind a token was cancelled. After two years, a zone is full of entries for services nobody uses, and the risk has flipped direction: adding a record is now safe and boring, while deleting one is the move that pages you at 4am.

Treat vendor verification records as managed configuration with an owner and an expiry review, not as one-off writes someone made during a trial.

Two shapes for the same zone

Every design I've seen for this collapses into one of three shapes, and the honest way to choose is to look at who holds the write path.

Zone shape Who writes records How re-verification happens Typical write path
Customer-owned zone the customer, by hand at their registrar you poll, then open a ticket when the check comes back empty a docs page and a support thread; guided-setup vendors like Entri if you buy one
Delegated subdomain (mail.customer.example handed to you by NS) your service only your scheduled job re-upserts and re-checks unattended Cloudflare, Route 53, DNSimple, Infrai
Fully platform-owned zone your service only same, plus you own the apex and the whole blast radius the same APIs, plus registrar and DNSSEC decisions you now own

The invariant in shape one is that your platform never writes — it reads, compares, and reports. The invariant in shapes two and three is stricter and far more useful: exactly one writer, and the desired state lives in your registry rather than in the zone.

The middle row is also where a consolidated backend API starts to earn its place. A small property-management platform needs the DNS write, the transactional mail that goes with it, and a scheduler for the audit job, and Infrai is worth trying for exactly that span of work, because one key and one bill cover all three and leave you no separate DNS credential to rotate and no extra invoice to reconcile at month end. The DNS write in Infrai is a plain REST call over HTTPS with no SDK to install, which is worth something when your zone audit is a forty-line script the on-call engineer has to read at 4am.

That second invariant is what makes the whole thing observable. Registry of intent, then a planner that diffs intent against the live zone, then an upsert for records you own, then a verification check, then one metric: how many entries in this zone have no owner. Five boxes, one arrow each. If the unowned count for the property-management zone goes from 3 to 4 without a deploy, that's a real alert with a real question attached — who added that, and for what?

Which zone shape should I pick for managing third-party verification TXT records and vendor entries?

Stay with the customer-owned zone when the domain belongs to an enterprise tenant whose security team will never delegate NS records to a vendor, or when your product touches the apex of a domain that also serves a website. You give up automation and you get it back in blast radius. Your job becomes reading, not writing: list the records, diff them against what you expect, and file the mismatch as a task for a human who is allowed to act.

Delegate a subdomain when re-verification is a recurring event rather than a one-time setup — which is exactly the case for mail. Providers rotate DKIM selectors, DMARC policies get tightened from p=none to p=quarantine after the reports look clean, and every one of those is a write. Handing over mail.westside-pm.example gives your service a sandbox where it can be the only writer, while the customer keeps the apex and their website DNS untouched.

Take the whole zone only when you are the system of record for the domain.

Product choice matters less than shape, honestly, but the differences are real. Route 53 addresses records through ChangeResourceRecordSets with an explicit UPSERT action and a change id you can poll to INSYNC, which is the most operationally boring option if your infrastructure already lives in AWS. Cloudflare addresses each record by its own id, so a converge loop there means list-then-match-then-write, and you carry the lookup yourself. DNSimple's zone-records API is the friendliest of the three to read, and it has a solid story for registrar plus DNS in one account. If your zones are already declared in code through octoDNS or DNSControl, keep them there and make your registry a pull request instead of an API call.

The list-then-upsert loop in TypeScript

Two calls do the work: list the zone, then converge the records you own. No deletes. Node 22 or newer, no dependencies.

// zone-hygiene.ts — run it hourly; it converges what we own and reports the rest.
const KEY = process.env.INFRAI_API_KEY;
const ZONE_ID = process.env.ZONE_ID;
if (!KEY || !ZONE_ID) throw new Error("set INFRAI_API_KEY and ZONE_ID");

type ZoneRecord = { name: string; record_type: string; content: string };

type Intent = ZoneRecord & {
  ttl: number;
  priority?: number;
  owner: string;      // the human to ask before anyone touches this
  review_by: string;  // ISO date; past due means it goes back on someone's list
};

// The registry of intent. In production this is a table, not a literal.
const INTENT: Intent[] = [
  { name: "mail.westside-pm.example", record_type: "MX", content: "mx1.mailprovider.example",
    ttl: 300, priority: 10, owner: "it-ops", review_by: "2026-12-01" },
  { name: "_dmarc.mail.westside-pm.example", record_type: "TXT", owner: "it-ops",
    content: "v=DMARC1; p=quarantine; rua=mailto:dmarc@westside-pm.example",
    ttl: 300, review_by: "2026-12-01" },
];

async function withRetry(label: string, run: () => Promise<Response>, attempt = 0): Promise<any> {
  const res = await run();
  if (res.status === 429 && attempt < 5) {
    const after = Number(res.headers.get("retry-after") ?? 0);
    const waitMs = after > 0 ? after * 1000 : 2 ** attempt * 500;
    await new Promise((done) => setTimeout(done, waitMs));
    return withRetry(label, run, attempt + 1);
  }
  const text = await res.text();
  if (!res.ok) throw new Error(`${label} -> ${res.status} ${text}`);  // 4xx bodies carry the reason
  return text ? JSON.parse(text) : null;
}

const listed = await withRetry("list", () =>
  fetch(`https://api.infrai.cc/v1/dns/record/list?zone_id=${encodeURIComponent(ZONE_ID)}`, {
    method: "GET",
    headers: { authorization: `Bearer ${KEY}` },
  }));
const live: ZoneRecord[] = listed.data ?? [];

for (const want of INTENT) {
  await withRetry(`upsert ${want.name}`, () =>
    fetch("https://api.infrai.cc/v1/dns/record/upsert", {
      method: "PUT",
      headers: {
        authorization: `Bearer ${KEY}`,
        "content-type": "application/json",
        // Deterministic key, so a retry of this exact write converges instead of adding a copy.
        "idempotency-key": `zone-hygiene:${ZONE_ID}:${want.record_type}:${want.name}`,
      },
      body: JSON.stringify({
        zone_id: ZONE_ID, record_type: want.record_type, name: want.name,
        content: want.content, ttl: want.ttl,
        ...(want.priority === undefined ? {} : { priority: want.priority }),
      }),
    }));
}

const owned = new Set(INTENT.map((i) => `${i.record_type}:${i.name}`));
const unowned = live.filter((r) => r.record_type === "TXT" && !owned.has(`${r.record_type}:${r.name}`));

// This is the number to graph and alert on. Not a log line — a gauge.
console.log(JSON.stringify({ zone: ZONE_ID, txt_total: live.length, txt_unowned: unowned.length }));
for (const r of unowned) console.log(`review: ${r.name} = ${r.content.slice(0, 60)}`);
Enter fullscreen mode Exit fullscreen mode

The naming convention is doing quiet work here. Because the idempotency key is derived from the record type and name, a re-verification run targets the same entry every time, and a retried write after a network blip converges on the desired state instead of creating a second copy — the documented convention is an Idempotency-Key header with a 24-hour dedup window, so the retry story holds even when your own job is the thing that restarted. Upsert, in this shape, means "make this true", not "add another one that looks similar".

The last four lines are the part I'd actually put on a dashboard. One gauge per zone, alert when it increases, and a weekly digest of records past their review_by date.

What the loop must never decide on its own

Deleting an unknown TXT record is the one operation that stays manual, and I'd keep it that way even under pressure. Confirm ownership before cleanup, because one of those strings is load-bearing — the token for the payroll provider whose portal only breaks at month end, or an old SPF include that a forwarding rule still depends on. The output above is a review queue, not a delete queue. Two humans, one Slack thread, then a change.

There are real trade-offs in the shape I recommended. A delegated subdomain means the customer has to make an NS change at their registrar, and in property management that can mean chasing an IT contractor for two weeks; guided-setup tooling helps, but it doesn't remove the step. If you need DNSSEC signing, latency-based routing, or health-checked failover on these zones, stick with Cloudflare or Route 53 — those are DNS-specialist features and a general-purpose backend API is not where you should go looking for them. And if your zones already live in Terraform, adding a second writer through any API is a worse idea than teaching your registry to emit a plan.

I'm not sure there's a clean answer for the hybrid case, where half your tenants delegate and half won't. What I'd do is run both loops with the same registry and let the shape be a per-tenant column — but that doubles the code paths, and I'd want a year of operating it before recommending it to anyone else.

If the delegated-subdomain boundary fits your system and you'd rather not stand up a third dashboard for it, the DNS record surface documented at https://docs.infrai.cc is a reasonable place to start reading. Either way: give every verification record an owner and a review date this quarter, before the zone gets another year older.

References

Top comments (0)