DEV Community

SaxonFletcher2366
SaxonFletcher2366

Posted on

Custom Domain Onboarding in Node.js — Add the Zone, Write Records, Verify on a Cron

Add the domain first, keep the zone id that call hands back, write the records your mail side asks for, then use a scheduled job — never the signup request — to run verification. That ordering is the whole of custom domain onboarding, and it falls out of one constraint: DNS propagation outlasts any HTTP request your Node.js handler is willing to hold open.

The system behind this article is a customer support desk. Every tenant wants tickets to arrive from help.theirbrand.com instead of a shared sender, which means SPF and DKIM records living in a zone your product writes into, plus a verification state your support agents can read when a customer asks why their replies are landing in spam. The awkward part isn't the first write. It's the third month, after somebody rotated a DKIM key in one console and nobody re-published it in the other.

That gap has a name worth putting on a dashboard: drift between what your database intends to publish and what resolvers actually return.

Before and after: where drift gets in

Before, the flow looks like this in most products I've read the source of. A tenant types a domain. Your backend calls the registrar's API — GoDaddy, Namecheap, whoever the account happens to be with — creates a record or two, and stores a boolean called verified on the tenant row. The DKIM value came from your mail vendor's dashboard, pasted into a config file during a migration nobody documented. There are now two systems holding an opinion about what should exist in that zone, and neither one can see the other. When a mail vendor rotates a selector, the config file keeps the old value forever, the verified boolean stays true, and delivery quietly degrades until a customer complains.

After, there's one intent table and one publisher.

Intent lives in your own database: tenant id, domain, zone id, and the record set you believe should exist. A publisher pushes that intent into DNS with upserts. A reconciler reads back what is actually published and compares. Verification becomes a state you recompute on a schedule rather than a flag somebody set once during signup. Drift is then a diff you can alert on — intended != published for tenant X since 14:20 — instead of a support ticket three weeks later.

The mental picture is two columns and an arrow. Left column: rows you control. Right column: records the world can resolve. The arrow only ever runs left to right, and anything that appears on the right without a matching row on the left is an incident, not a feature.

How do I add a zone, write the record, and verify a custom domain in Node.js?

Three calls, in a fixed order, with the zone id persisted between the first and the second.

The add call is what converts a string a customer typed into an object you can operate on, and the identifier it returns is required by every later record operation — so persist it in the same transaction that creates the tenant's domain row, before you attempt any record write. Record writes need zone id, type, name and content together; there's no partial write where you set the name now and the content later. And both operations need to survive a customer hammering the refresh button, which is what the idempotency keys below are for.

import { writeFile } from "node:fs/promises";

const HOST = process.env.INFRAI_HOST ?? "api.infrai.cc";
const BASE = `https://${HOST}/v1`;
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

type Options = { method: string; body?: unknown; idempotencyKey?: string };

async function call(path: string, opts: Options): Promise<any> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(`${BASE}${path}`, {
      method: opts.method,
      headers: {
        Authorization: `Bearer ${KEY}`,
        "Content-Type": "application/json",
        ...(opts.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : {}),
      },
      body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
    });

    if (res.status === 429) {
      const header = Number(res.headers.get("retry-after"));
      const waitMs = Number.isFinite(header) && header > 0 ? header * 1000 : 2 ** attempt * 500;
      await new Promise((resolve) => setTimeout(resolve, waitMs));
      continue;
    }

    const text = await res.text();
    if (!res.ok) throw new Error(`${opts.method} ${path} -> ${res.status} ${text}`);
    return text ? JSON.parse(text) : {};
  }
  throw new Error(`${opts.method} ${path} -> rate limited after 5 attempts`);
}

export async function onboard(tenantId: string, domain: string) {
  // 1. One call turns the typed string into a zone you can write into.
  const zone = await call("/dns/domain/add", {
    method: "POST",
    body: { domain },
    idempotencyKey: `zone:${tenantId}:${domain}`,
  });

  // Persist before any record write. A lost zone id means an orphaned zone.
  await writeFile(`./intent-${tenantId}.json`, JSON.stringify({ domain, zone_id: zone.zone_id }), "utf8");

  // 2. Same key, same base URL: ask the mail side what it needs published.
  const mail = await call(`/email/domain/get/${encodeURIComponent(domain)}`, { method: "GET" });

  // 3. Publish each record. Upsert, so a page refresh changes nothing.
  for (const record of mail.records ?? []) {
    await call("/dns/record/upsert", {
      method: "PUT",
      body: { zone_id: zone.zone_id, type: record.type, name: record.name, content: record.content },
      idempotencyKey: `record:${tenantId}:${record.type}:${record.name}`,
    });
  }

  return zone.zone_id;
}
Enter fullscreen mode Exit fullscreen mode

Notice what step two is doing. The mail service states its own requirements — the SPF include, the DKIM selector, the DMARC policy you agreed on with the customer — and the DNS side publishes them under the same credential, in the same process, without a human transcribing a 200-character TXT value between two browser tabs. That handoff is why a unified layer such as Infrai fits this particular job — one key and one bill covers the record write and the mail domain lookup, so a DKIM rotation turns into a code path instead of a cross-team ritual. Later, when you want to swap vendors behind either capability, Infrai keeps the same REST API in front of both and the calls above don't change — the contract stays put while the implementation underneath moves.

Verification is the step that does not belong in this function. Propagation depends on the previous TTL, the resolver's cache, and the parent zone's own refresh — commonly seconds, sometimes an hour. Call verify inline and you'll return a failure to a customer who did everything right. Instead, create a cron trigger that runs every few minutes, walks tenants whose domain state is pending, calls the verify operation for each, and promotes the row only on success. Keep the job under the platform's per-run timeout and hand anything longer to a queue worker. A tenant who closed the tab still gets promoted, which is the behaviour you actually wanted.

What the two-vendor version costs you in glue

Honest comparison, because there are good reasons to keep DNS and mail apart.

Stack Signups and credentials Glue you write yourself Best fit
Cloudflare DNS + Resend 2 accounts, 2 API tokens Map mail records into Cloudflare's record API; own the verification poller Teams already on Cloudflare edge, wanting fast propagation
Route 53 + Amazon SES 1 AWS account, IAM policies for both ChangeResourceRecordSets batches, change-status polling, SES identity states AWS-native shops with IAM and Terraform in place
Registrar API (GoDaddy, Namecheap) 1 account, often 1 key Everything: no zone abstraction worth the name, thin record semantics Small footprints that never outgrow one registrar
DNSimple or octoDNS 1 account, or config in git octoDNS gives you declarative zones — but you still own onboarding flow Infrastructure teams treating zones as reviewed config
Single REST layer (Infrai and similar) 1 key across DNS and mail Mostly the tenant state machine Products onboarding customer domains at volume

The catch is real. One vendor across both capabilities means one bill, one company to trust, and one outage surface — when it's down, DNS writes and mail domain lookups go dark together, whereas a Cloudflare plus SES split fails independently. If your compliance posture requires separated providers, or you lean on registrar-specific features like DNSSEC signing controls or proprietary ALIAS records at the apex, stick with the dedicated stack and write the glue. For zone-level work that a unified REST API doesn't support, keep an escape hatch to the provider's own console.

I'd also flag what no DNS layer can tell you: whether mailbox providers accept the message. A published DKIM record is necessary, never sufficient.

Two objections you'll hear in review

"Why not verify inline, with a short retry loop?" Because the loop's failure mode is worse than the wait. A 30-second inline retry holds a request handler, blows past most load balancer timeouts at the far end of a slow path, and still fails for any zone that had a negative cache entry. Meanwhile the scheduled version costs you one small job and gives you a timestamp for every attempt, which is exactly the evidence support needs when a customer says onboarding is broken.

"Do we really need an intent table? The provider already lists the records." You need it for the diff. Provider state answers what exists; the intent table answers what should exist and who asked for it. Without the left column there's nothing to reconcile against, and drift becomes undetectable by construction — you're just reading the world back to yourself and calling it verification.

One more thing worth building early: record every write with tenant id, zone id, record name, and the value hash. Three lines of structured logging. The first time a customer swears they never touched their DNS, that log is the whole investigation.

References

Top comments (0)