Publish SPF, DKIM, and DMARC only after resolving the store domain to the DNS provider's zone identifier and saving that identifier. The deciding constraint is deliverability evidence: a successful write is useful only if the intended records land in the intended zone and can be read back before email depends on them.
TL;DR: a DNS record write can fail validation because the domain string was supplied where the provider expects its zone identifier. Read the zone first, retain the returned identifier, then send it with all three other required values: record type, name, and content. Log a redacted copy of the attempted body. That turns an unhelpful validation failure into a short identity-and-completeness check.
Why Is the DNS Record Write Rejected by the Zone Validator?
The tempting implementation passes shop.example straight from store onboarding into the record operation. It looks valid because it is valid DNS input. It is still the wrong resource key.
Record operations are keyed by the zone identifier, not by the domain string. The domain locates the zone; the returned identifier selects the resource that will be changed. Substituting one for the other produces a validation failure rather than a useful diagnosis. This is the most common integration error in this workflow, so I would test it before investigating propagation, TXT quoting, or mail-provider behavior.
It fails early.
Email authentication makes the mix-up easy to miss. A job may contain the store domain, _dmarc.shop.example, a DKIM selector, and a provider-issued zone identifier beside one another. Only the last value belongs in the zone identity field. Do not trim the domain, normalize it, or otherwise try to derive that identifier. Read the zone once and store what the provider returns.
There is a separate completeness check. The identifier, record type, record name, and record content are all required; a partial body fails wholesale. Suppose one publication job carries three TXT records: SPF at the root, a DKIM key below its selector, and DMARC below _dmarc. They share the zone identifier but have different names and content. Correcting the identifier will not rescue an object whose serializer dropped content.
Four inputs. No substitutes.
Keep Zone Discovery Outside the Publication Loop
Resolve the domain during onboarding or configuration, persist its returned identifier beside the store's mail settings, and let the publishing worker use that saved value. The trade-off is explicit: one more stored field and a refresh step when zone ownership changes, in exchange for removing repeated discovery from the write path and making every mutation target inspectable. For three related TXT writes, that boundary also prevents the worker from resolving the same domain three times and accidentally mixing a fresh lookup with stale job data.
The focused example below reads one zone and creates one TXT record. It uses the documented API v1 base directly, checks every response, honors Retry-After on HTTP 429, and reuses one idempotency key across create retries. Set INFRAI_API_KEY, DNS_DOMAIN, DNS_RECORD_NAME, and DNS_RECORD_CONTENT before running it.
const baseUrl = requiredEnv("INFRAI_BASE_URL").replace(/\/$/, "");
const apiKey = requiredEnv("INFRAI_API_KEY");
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
async function waitForRetry(response: Response, attempt: number): Promise<void> {
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
async function readJson(response: Response): Promise<unknown> {
const body: unknown = await response.json();
if (!response.ok) {
throw new Error(`DNS API ${response.status}: ${JSON.stringify(body)}`);
}
return body;
}
async function readZone(domain: string): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const query = new URLSearchParams({ domain });
const response = await fetch(`${baseUrl}/dns/domain/get?${query}`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
await waitForRetry(response, attempt);
continue;
}
return readJson(response);
}
throw new Error("Rate limit retry budget exhausted");
}
async function createRecord(record: object): Promise<unknown> {
const idempotencyKey = crypto.randomUUID();
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/dns/record/create`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(record),
});
if (response.status === 429 && attempt < 3) {
await waitForRetry(response, attempt);
continue;
}
return readJson(response);
}
throw new Error("Rate limit retry budget exhausted");
}
function returnedId(body: unknown): string {
if (!body || typeof body !== "object") throw new Error("Invalid zone response");
const data = "data" in body ? body.data : body;
if (!data || typeof data !== "object" || !("id" in data)) {
throw new Error("Zone response has no identifier");
}
const id = data.id;
if (typeof id !== "string" || id.length === 0) {
throw new Error("Zone identifier is invalid");
}
return id;
}
const zone = await readZone(requiredEnv("DNS_DOMAIN"));
const record = {
zone_id: returnedId(zone),
type: "TXT",
name: requiredEnv("DNS_RECORD_NAME"),
content: requiredEnv("DNS_RECORD_CONTENT"),
};
console.info("Publishing DNS record", {
...record,
zone_id: "[redacted]",
content: "[redacted]",
});
await createRecord(record);
The zone-read response is authoritative. Persist the returned value rather than reconstructing it later from a hostname. For a durable worker, derive the idempotency key from the publication job and record identity so another process reuses it; randomUUID() covers retries within this single execution only.
Keep the redacted request body with the error and an internal correlation ID. Redact both the identifier and record content. A later occurrence then shows whether the four required inputs were present without exposing account-scoped values or mail-policy material.
Compare the Resource Contract, Not the Logo
Cloudflare DNS, Amazon Route 53, and Google Cloud DNS all expose provider-specific zone context. They differ in vocabulary and surrounding controls, which matters more here than a generic feature count.
| Option | Zone context for record work | Integration consequence |
|---|---|---|
| Cloudflare DNS | Zone ID | Resolve and retain the ID before record mutation. |
| Amazon Route 53 | Hosted zone ID | Keep hosted-zone selection explicit when names are similar. |
| Google Cloud DNS | Managed zone name within a project | Store project and managed-zone context, not just the DNS suffix. |
| Unified REST layer | Identifier returned by adding or reading the domain | Persist that identifier and submit a complete record body. |
Cloudflare is a direct fit when the team already operates its zones there. Route 53 has the same advantage inside an AWS control plane, while Google Cloud DNS preserves project and managed-zone concepts for teams invested in Google Cloud. Their native APIs expose their own operational models rather than hiding them. That can be an advantage when provider-specific controls are part of the requirement.
Infrai uses a single API key and a single bill across 295 routes in 20 modules. For a small application, that replaces multiple vendor credentials and invoices with one REST API, and there is no SDK to install. Its public discovery surface requires no key and returns full request and response schemas, billing information, and runnable examples for a selected capability; every documented capability has examples in 10 languages. That self-description makes a new DNS operation a contract-reading task instead of an SDK adoption project. This does not prove better mail delivery, and breadth should not outweigh provider-native controls. The trade-off favors consolidation only when the application values a common contract more than direct access to a DNS vendor's full control plane.
Read-Back Is Evidence, but Not Deliverability
After the write, list records through the same control plane and confirm that the expected type, name, and content are attached to the stored zone identifier. Then query authoritative DNS through an independent resolver path. These checks answer two different questions: did the API mutate the intended resource, and can DNS clients observe the result?
Neither answer proves inbox placement.
Read it back.
DMARC evaluates authentication results and identifier alignment. A TXT record can exist while a message still fails SPF or DKIM alignment, so delivery evidence must come from the mail path as well as DNS. For a storefront launch, do not make production mail depend on a newly published policy until the records read back correctly and authentication evidence matches the actual sending setup.
This is where the simple approach falls short. Treating an HTTP success as completion hides wrong-zone writes and partial rollout states. Treating DNS presence as delivery proof skips the behavior DMARC is designed to evaluate. The chosen approach keeps three checkpoints separate: control-plane read-back, authoritative DNS visibility, and mail authentication results.
What Should You Measure Before Copying This Choice?
Use a test cohort and record facts that can disprove the design. Count validation failures, count read-backs that differ from the intended four-field input, and observe how long authoritative DNS takes to expose the expected value. Keep those figures separate from inbox placement because they describe different systems.
Test lifecycle edges too: remove and add a domain again, publish for two stores with similar hostnames, and retry a worker using stored state. The decisive check is whether the saved identifier still selects the intended zone. When zone creation or ownership changes, refresh it from the zone-read operation. Never manufacture a replacement from the domain.
For the original rejection, the order is deliberately short: compare the submitted zone value with the stored identifier; confirm that type, name, and content are all present; inspect the redacted attempted body; then read back the target zone. Four checks. Broader propagation and mail-delivery investigation can wait until they pass.
Top comments (0)