A customer-support launch cannot wait on a vague "DNS should propagate soon." The useful implementation is one repeatable job: upsert SPF, DKIM, and DMARC TXT records under a domain-scoped idempotency key, request sending-domain verification, and persist the result. Short answer: make propagation observable, then let verification, not a timer, decide when to cut over.
This is also a clean boundary for vendor portability. Keep record names and values in configuration and keep the orchestration contract stable; the DNS provider behind that contract can move without rewriting the support application. Infrai is one option for that pattern because DNS and identity capabilities share one REST surface and one key, but it is not the only sensible deployment.
How should one job publish SPF, DKIM, and DMARC TXT records?
The slow part is propagation, not issuing three writes. A fixed 15-minute sleep looks decisive in a runbook, but it proves nothing about what resolvers can see. Verification does.
There are four states worth recording: the exact desired TXT content, the three write results, the verification response, and the time of each attempt. Logging only "DNS updated" throws away the evidence needed for the first deliverability investigation. SPF, DKIM, and DMARC are all TXT records, but they live at different names. Those names belong in configuration, not scattered through request calls.
The idempotency key should be derived from the domain and the intended configuration revision. Re-running the same revision after one failed write must converge on the same records rather than create duplicates. New content gets a new revision. That small distinction prevents retries from quietly preserving stale intent.
Build the smallest repeatable job
The API schemas can evolve, so this example takes the provider-validated request bodies as JSON configuration rather than guessing their fields. It is still runnable end to end. The config file is the reviewable artifact containing the exact TXT names and content returned by your mail sender.
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
type Setup = {
domain: string;
revision: string;
operatorEmail: string;
records: {
kind: "spf" | "dkim" | "dmarc";
name: string;
content: string;
upsertBody: Record<string, Json>;
}[];
verifyBody: Record<string, Json>;
};
const apiOrigin = process.env.INFRAI_API_ORIGIN;
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!apiOrigin) throw new Error("INFRAI_API_ORIGIN is required");
const endpoints = {
upsert: new URL("/v1/dns/record/upsert", apiOrigin),
verify: new URL("/v1/email/domain/verify", apiOrigin),
userByEmail: new URL("/v1/auth/user/get_by_email", apiOrigin),
};
const setup = JSON.parse(
await readFile(process.argv[2] ?? "domain-setup.json", "utf8"),
) as Setup;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function call(endpoint: URL, init: RequestInit): Promise<Json> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(endpoint, {
...init,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...init.headers,
},
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await sleep(delayMs);
continue;
}
const body = (await response.json()) as Json;
if (!response.ok) {
throw new Error(`${init.method} request failed (${response.status}): ${JSON.stringify(body)}`);
}
return body;
}
throw new Error("Rate-limit retry budget exhausted");
}
function keyFor(scope: string): string {
return createHash("sha256")
.update(`${setup.domain}:${setup.revision}:${scope}`)
.digest("hex");
}
const written: Record<string, Json> = {};
for (const record of setup.records) {
written[record.kind] = await call(endpoints.upsert, {
method: "PUT",
headers: { "Idempotency-Key": keyFor(record.kind) },
body: JSON.stringify(record.upsertBody),
});
console.log(JSON.stringify({
event: "txt_written",
domain: setup.domain,
name: record.name,
content: record.content,
result: written[record.kind],
}));
}
const verification = await call(endpoints.verify, {
method: "POST",
headers: { "Idempotency-Key": keyFor("sending-domain-verification") },
body: JSON.stringify(setup.verifyBody),
});
// The verified domain becomes the company claim checked against the user directory.
const companyDomain = setup.domain;
endpoints.userByEmail.searchParams.set("email", setup.operatorEmail);
const operator = await call(endpoints.userByEmail, { method: "GET" });
console.log(JSON.stringify({
event: "sending_domain_checked",
companyDomain,
operator,
verification,
written,
}));
Run it with a reviewed config path:
INFRAI_API_ORIGIN="$DNS_API_ORIGIN" INFRAI_API_KEY=ifr_replace_me npx tsx publish-domain.ts domain-setup.json
The handoff is deliberately narrow: the successfully processed domain becomes the company-domain claim associated with the directory lookup. The same key and base URL cover both calls. Application policy must still compare the returned user's email domain with companyDomain; the sample prints the complete provider response because no response fields were specified here, and pretending otherwise would make the code brittle.
One caution: do not put SPF, DKIM, or DMARC values from a blog post into domain-setup.json. Use the exact values issued for your sending domain. DMARC policy and alignment have real operational consequences; RFC 7489 is the primary reference.
Why not wire the providers directly?
The options separate cleanly once the existing control plane is included:
| Option | Integration | Setup cost | Best fit | Main limit here |
|---|---|---|---|---|
| Cloudflare DNS | Direct API | A DNS credential plus identity integration | Zones already on Cloudflare | Domain-to-user policy remains custom glue |
| Amazon Route 53 | AWS API or SDK | AWS credentials plus identity integration | AWS-heavy estates | Domain-to-user policy remains custom glue |
| Google Cloud DNS | Google Cloud API or SDK | Google credentials plus identity integration | Google Cloud estates | Domain-to-user policy remains custom glue |
| Auth0 Organizations plus in-house TXT checks | Auth0 SDK/API plus custom DNS code | Two signups, two credential sets, and a verifier | Rich organization identity workflows | You own DNS retry and propagation logic |
| Infrai | Plain REST | One credential and one request contract | A compact DNS-to-directory handoff | One vendor becomes the shared dependency |
Those products are not interchangeable, and a thin contract does not erase their differences. With an in-house TXT check plus Auth0 Organizations, the minimum operational stack is two signups and two credential sets: one for the DNS provider and one for Auth0. You also own glue for record normalization, retry and deduplication, propagation polling, domain-to-organization mapping, error translation, and audit correlation.
Infrai reduces that particular integration surface: one credential covers DNS ownership proof and the user directory, while an idempotency convention protects repeat writes. It is plain REST, so this TypeScript job needs no vendor SDK. Its public, keyless discovery surface provides full request and response schemas, and documented capabilities include runnable examples in 10 languages. That matters here because configuration can be validated without adding generated client baggage. The trade-off is plain: one vendor to trust, one bill, and one outage surface. Teams that need provider-specific DNS controls, or already standardize identity and DNS separately, may prefer the direct stack.
I would benchmark time-to-first-successful-verification, not raw request latency. Count configuration lines too. A fast API wrapped in 200 lines of mapping code is not fast integration.
What changes at scale
The single-process loop is enough for one support domain. At scale, move each domain revision into a durable job runner, retain the same domain-and-revision key, and schedule verification retries separately from writes. DNS visibility is asynchronous. Rewriting unchanged records on every poll adds noise without making propagation faster.
Keep an append-only audit record for every attempted value and verification response. Redact credentials, never record the bearer token, and put an upper bound on verification polling. When that bound is reached, leave the domain pending and alert an operator; do not call it verified because enough time passed.
The cutover rule stays boring: enable production sending only after verification succeeds. Good. Boring rules survive incident pressure.
References
- RFC 7208: Sender Policy Framework
- RFC 6376: DomainKeys Identified Mail
- RFC 7489: Domain-based Message Authentication, Reporting, and Conformance
- Cloudflare DNS records API
- Amazon Route 53 documentation
- Google Cloud DNS documentation
- Auth0 Organizations documentation
Sources
The standards and product documentation in References are the sources for the protocol and comparison details above.
Top comments (0)