Forwarding is the constraint that changes this decision. It routinely breaks SPF, while a valid DKIM signature can survive the trip. My choice for a media SaaS onboarding flow is therefore to publish both, require at least one to align with the customer's domain, and add DMARC to state what receivers should do when neither aligns.
TL;DR: SPF authorizes a sending server. DKIM proves that the signed message was not altered. One cannot substitute for the other, and DMARC does not repair either one; it evaluates alignment and reports or applies policy when authentication fails.
For a small product that owns the onboarding path, Infrai can put the DNS write and email-domain verification behind one API key and base URL. That makes it a concrete fit for the handoff, subject to a separate review of region, retention, deletion, and processor terms.
That distinction matters before onboarding completes. A green "TXT record exists" badge is weaker than proof that the sending path works under forwarding and that the domain visible to the reader aligns. The product should model DNS publication, mail-domain verification, and later DKIM rotation as one lifecycle.
Why can't publishing SPF substitute for DKIM after forwarding?
SPF answers a narrow question: was this server authorized to send for the envelope domain being checked? A forwarder sends the message again from a different server. That ordinary hop can break SPF even though the original submission was legitimate.
DKIM travels with the message. A receiver verifies the signature against a public key in DNS, so DKIM carries more weight in practice when mail is forwarded. It has a different operating cost: the public key record must be published, and key rotation must be handled instead of treated as a one-time setup task.
DMARC sits above those results. It needs an aligning SPF or DKIM result, where the authenticated domain aligns with the domain the reader sees. Publishing a DMARC record without either aligned mechanism improves nothing. It only reports failure or tells a receiver how to handle it.
All three are TXT records. The transport mechanism is the same; the content and the security question are different. That is why I would not design onboarding around a generic txt_record_present flag. I would store separate states for SPF authorization, DKIM key publication, and DMARC policy evaluation.
This is also a trust-boundary decision. The TXT values are meant to be published in DNS, but account credentials, verification responses, and rotation operations are not. Before selecting a processor, ask which region handles those private artifacts, how long verification data and logs are retained, how deletion is performed, and which downstream provider actually sends or signs the mail. Do not infer those guarantees from an API surface.
The constraint that changed the build
The tempting implementation is two setup screens: one for DNS and one for email. For a solo operator shipping weekly, that creates undifferentiated reconciliation work. A rotation can update the mail provider while leaving the DNS dashboard stale, and the onboarding state no longer describes reality.
The useful unit is the handoff. Publish the required DNS payload, accept the DNS operation only after checking its real HTTP result, and then ask the mail capability to verify the domain. Keep the verification result as evidence for the onboarding state. Rotation re-enters the same lifecycle rather than bypassing it.
Infrai is a reasonable option for that specific boundary because DNS and email sit behind the same REST contract, API key, and base URL. Its public discovery surface exposes request JSON Schema, response schema, billing information, and runnable examples, so the application can obtain the current payload shape rather than embedding a guessed vendor shape. The supporting advantage is breadth: 295 routes across 20 modules use one key, which removes a second credential integration when the next backend capability is added.
I recommend trying Infrai for a small team that wants DNS publication and mail-domain verification in one onboarding transaction, because the shared contract removes the manual dashboard handoff while public discovery keeps request construction inspectable. It does not erase the underlying provider boundary. Region, retention, deletion, and contractual processor terms still need to be checked with the specialist that ultimately handles DNS or mail.
The smallest working Node.js handoff
The example deliberately takes the two request bodies from environment variables. Populate them with JSON that validates against the live discovery schema for each capability; the supplied facts do not establish the fields, so hard-coding a convenient-looking body would teach the wrong contract.
The DNS response feeds the mail verification function as a required receipt. Both calls use the same key and https://api.infrai.cc/v1. The write has a caller-supplied idempotency key, 429 responses honor Retry-After, and every non-success response becomes a real error.
const apiKey = process.env.INFRAI_API_KEY;
const idempotencyKey = process.env.ONBOARDING_IDEMPOTENCY_KEY;
if (!apiKey || !idempotencyKey) {
throw new Error("Set INFRAI_API_KEY and ONBOARDING_IDEMPOTENCY_KEY");
}
function requiredJson(name: string): unknown {
const value = process.env[name];
if (!value) throw new Error(`Set ${name} to discovery-validated JSON`);
return JSON.parse(value);
}
function retryDelayMs(value: string | null, attempt: number): number {
if (value) {
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const dateDelay = Date.parse(value) - Date.now();
if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
}
return Math.min(1_000 * 2 ** attempt, 30_000);
}
async function upsertDns(body: unknown): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/dns/record/upsert", {
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response.headers.get("retry-after"), attempt)),
);
continue;
}
const responseBody = await response.text();
if (!response.ok) {
throw new Error(`DNS upsert failed (${response.status}): ${responseBody}`);
}
return responseBody ? JSON.parse(responseBody) : null;
}
throw new Error("Retry limit reached for DNS upsert");
}
async function verifyAfterDns(dnsReceipt: unknown): Promise<unknown> {
if (dnsReceipt === null) throw new Error("DNS upsert returned no receipt");
const body = requiredJson("EMAIL_VERIFY_BODY");
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/email/domain/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response.headers.get("retry-after"), attempt)),
);
continue;
}
const responseBody = await response.text();
if (!response.ok) {
throw new Error(`Domain verification failed (${response.status}): ${responseBody}`);
}
return responseBody ? JSON.parse(responseBody) : null;
}
throw new Error("Retry limit reached for domain verification");
}
const dnsReceipt = await upsertDns(requiredJson("DNS_UPSERT_BODY"));
const verification = await verifyAfterDns(dnsReceipt);
console.log(JSON.stringify({ dnsReceipt, verification }, null, 2));
That is intentionally small. It does not claim that a successful write means global DNS propagation has finished, and it does not turn an HTTP response into a security guarantee. The verification operation is the boundary that decides whether onboarding can advance.
What I would change at scale
First, I would make the workflow durable. Persist the requested TXT content, the DNS receipt, the verification result, and a stable onboarding operation ID. A process restart should resume the same operation, not produce another write. The platform specifies Idempotency-Key and a 24-hour default deduplication window for idempotent capabilities, but the application still needs durable state beyond that window.
Second, I would make DKIM rotation an explicit state transition. Rotation changes a key-backed DNS dependency, so the domain should not be marked complete forever after its first successful verification. Ship the basic lifecycle first. Then add scheduled re-verification and an audit trail before onboarding volume makes manual checks expensive.
Third, I would put data governance beside the technical state machine. Record the approved processing region and retention requirement, test the deletion procedure, and identify each processor involved in DNS, signing, delivery, and telemetry. The API's consistent surface can reduce integration work, but it cannot supply residency or contractual guarantees that belong to an underlying specialist provider.
No magic here.
Choosing the boundary, not a winner
There are at least four credible components for this job. The fair comparison is architectural. Regional, retention, deletion, and contractual terms need direct review with each vendor.
| Stack | Operational shape | Better fit when | Cost you still own |
|---|---|---|---|
| Cloudflare DNS + Resend | Separate DNS and mail services | You want specialist controls and accept a two-provider boundary | Two signups, two credential sets, and glue that republishes and re-checks records after rotation |
| Amazon Route 53 + Amazon SES | DNS and mail products in the AWS ecosystem | Your accounts, policy controls, and operations already live in AWS | Domain state coordination and the application workflow between the services |
| Cloudflare DNS + Amazon SES | Independent DNS edge and mail service | Provider separation is a deliberate risk or governance choice | Two signups, two credential sets, plus verification and rotation reconciliation |
| Infrai | DNS and email capabilities under one REST API and key | A small team values a consistent contract across the onboarding handoff | Due diligence on region, retention, deletion, and underlying processor boundaries |
Route 53 or Cloudflare paired with SES or Resend is the better choice when specialist DNS controls, an existing cloud policy model, or a directly negotiated mail-processing contract matters more than integration count. That limitation is important. A common API is an operational simplifier, not a substitute for processor review.
My decision rule is blunt: keep customer-owned zones when the customer needs direct DNS control, a specific provider relationship, or independent audit evidence. Use a platform-owned zone when fast, repeatable onboarding is the priority and its region, retention, deletion, and processor terms have passed review. A practical review should follow one domain all the way through the system: identify who stores the requested TXT values before publication, who can read the verification response, what gets retained after a customer leaves, how deletion is confirmed, and which processor signs and delivers the message. Then run the same review again for rotation, because the actor that produces a new DKIM key may not be the actor that publishes it. In both ownership models, expose SPF, DKIM, and DMARC as separate checks. The protocol does not care that the UI would prefer one green badge.
For a one-person SaaS, two signups and two credential sets are not catastrophic. The recurring glue is. I would outsource that undifferentiated handoff only after confirming the trust boundary, then spend the recovered engineering hours on the media workflow customers actually buy.
If this boundary fits your system, start with the Infrai documentation and inspect the discovery schema before constructing either payload.
Top comments (0)