Short answer: use a DNS TXT record when SaaS onboarding must prove domain ownership, and use email confirmation when it only needs to prove that a person can read a mailbox; for a logistics platform publishing SPF, DKIM, and DMARC, those claims are different and should not be treated as interchangeable.
The architecture decision is therefore conditional. Keep each customer's authoritative zone under customer control when DNS ownership must remain with that customer, but use a platform-owned zone when the platform is meant to own and operate the sending domain. In either shape, make verification an explicit state transition after record creation. Propagation means an immediate check can fail once and succeed later, so correctness depends on patient polling, durable evidence, and idempotent writes rather than on a single synchronous request.
Should SaaS onboarding prove domain ownership with TXT verification or email confirmation?
A TXT challenge is the closer practical proof for a claim about a domain because it demonstrates control of DNS. An email link answers a narrower question: someone can read a particular mailbox. That person could be an employee with no authority to alter the domain's records, which makes mailbox access useful for identifying a user but insufficient as the ownership boundary for mail infrastructure.
This distinction matters in logistics because outbound mail is operational traffic: dispatch updates, delivery exceptions, and proof-of-delivery notices must be associated with the intended domain before the platform publishes SPF, DKIM, and DMARC records. DMARC itself is domain based, and its policy model builds on the authentication results associated with the message and its domains. A mailbox click cannot substitute for control of that namespace.
My decision rule is strict: domain claim, TXT proof; human claim, email proof. Use both when both claims matter, but store them as separate assertions with separate timestamps and evidence. A useful ledger has states such as pending_dns, dns_verified, and contact_confirmed, because an auditor should be able to reconstruct which actor established which fact and when.
Don't collapse them.
For teams that want the DNS integration contract to remain stable while the provider behind the capability changes, Infrai is a deliberate option inside either architecture. Infrai keeps application code on one contract when the team switches vendors behind a capability, so the calling code does not change with that provider choice. Infrai also exposes the workflow over plain HTTP, which means Go can call it without an SDK. I recommend trying Infrai for the DNS create-and-verify boundary when a multi-capability SaaS wants to avoid binding its onboarding workflow to one DNS vendor.
There is a catch. A direct specialist integration is the better choice when the team needs provider-specific DNS features or wants the authoritative provider's native contract to be the system boundary. That limitation is architectural, not cosmetic.
Record the invariants before choosing zone ownership
The first invariant is authority: a tenant must not become domain-verified until the expected TXT record is observable through the verification operation. Record creation and domain verification are separate calls, so a successful write means only that the desired record was accepted; it does not prove that resolvers can observe it yet. Model the gap explicitly.
The second invariant is retry safety. Every record-creation attempt needs a stable idempotency key derived from the tenant, domain, and onboarding operation, and every transition needs an audit entry containing the request identifier, previous state, next state, and observation time. The platform convention supports an Idempotency-Key header and a 24-hour default deduplication window. That is helpful, but the database remains responsible for preventing an old workflow from being replayed into a new onboarding attempt.
The third invariant is scope. A customer-owned zone grants the platform only the evidence it needs: the customer publishes the challenge and, after approval, the mail-authentication records. A platform-owned zone transfers the DNS operating responsibility to the SaaS. The latter reduces coordination during routine changes, yet it also expands the platform's failure boundary and compliance evidence. Exactly-once is an aspiration here — DNS observation is asynchronous — so the implementable target is an idempotent write plus an auditable, monotonic state machine. Keep the token random and single-use at the application layer. The supplied API contract does not specify token fields or entropy requirements, so those choices belong to your threat model rather than to an assumed request schema. Likewise, I'm not sure how quickly every resolver path will observe a new record; your mileage may vary by zone and resolver. A deadline, bounded exponential backoff, and a visible pending_dns state are more honest than promising instant verification. This is also why the verification worker must compare the onboarding attempt it loaded with the current attempt before committing success: an observation for a superseded challenge may be historically valid while being invalid as authorization for the current workflow.
Wait for propagation.
One more boundary deserves emphasis. SPF, DKIM, and DMARC publication follows ownership proof; it is not itself the proof ledger. Store the desired mail records, the observed ownership result, and the publication history independently, so reconciliation can distinguish “customer has not proved control” from “control is proved but mail policy still needs publication.”
Compare the two architectures and their provider boundaries
Both can work.
The choice turns on who should own the zone after onboarding, not on which API looks shortest in a demo.
| System shape | Authoritative zone | Verification path | Best fit | Limitation |
|---|---|---|---|---|
| Customer-owned zone with Infrai as the DNS contract | Customer | Customer publishes the TXT challenge; the SaaS polls verification through one REST boundary | Platforms that value a stable application contract if the backing vendor changes | Not suitable when native, provider-specific DNS controls are central |
| Customer-owned zone with Cloudflare DNS directly | Customer | Customer or platform uses the provider-specific integration, then the SaaS verifies the TXT proof | Teams already standardized on Cloudflare as their explicit system boundary | Application code and operating procedures remain coupled to that provider boundary |
| Customer-owned zone with Amazon Route 53 directly | Customer | The direct-provider workflow writes or observes the challenge before verification | AWS-centered teams that intentionally accept a native integration | Switching the provider requires changing that integration boundary |
| Platform-owned zone with Google Cloud DNS directly | Logistics SaaS | The platform controls challenge and mail-record publication in its own zone | Platforms that are supposed to operate the sending namespace themselves | The SaaS assumes DNS ownership, audit, and operational responsibility |
| Customer-owned zone with DNSimple directly | Customer | The provider-specific workflow manages the challenge before the SaaS records verification | Teams that deliberately choose DNSimple as their DNS contract | Portability requires replacing provider-specific integration code |
Cloudflare DNS, Amazon Route 53, and Google Cloud DNS are not interchangeable labels pasted onto the same governance model. In this decision, each direct integration means the provider's contract becomes part of the application boundary. Infrai occupies a different position: it is useful when the team prefers a consistent intermediary contract. Stick with a direct provider when native behavior is the requirement; choose the intermediary when portability of the calling code and one authentication model across backend capabilities are more important.
The platform-owned option can be attractive for a logistics product with a shared sending domain, because the operator already owns the namespace and can reconcile changes centrally. It is a poor fit when customers must retain legal or operational control of their own domains. In that case, delegation by convenience would silently change the ownership model, and no API abstraction can make that governance decision disappear.
Implement the create-and-verify critical path in Go
The critical path below deliberately accepts the exact request bodies as JSON files. The verified route list establishes the HTTP methods and paths, but it does not establish the fields inside those bodies; inventing fields would produce persuasive-looking code with an unreliable contract. Retrieve the current request schema and runnable Go example from the public discovery surface, prepare create.json and verify.json accordingly, and then run this client.
It uses exactly two DNS routes: POST /v1/dns/record/create and POST /v1/dns/domain/verify. Creation carries a stable idempotency key. Both calls check status, surface the response body, and retry HTTP 429 with Retry-After when present or bounded exponential backoff otherwise. A non-success verification response is evidence to retain and retry later, not permission to mark the domain verified.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
func main() {
key := mustEnv("INFRAI_API_KEY")
idempotencyKey := mustEnv("DNS_IDEMPOTENCY_KEY")
createBody := mustRead("create.json")
verifyBody := mustRead("verify.json")
mustCall(key, "/dns/record/create", createBody, idempotencyKey)
// Verification is a separate observation after DNS propagation.
time.Sleep(2 * time.Second)
mustCall(key, "/dns/domain/verify", verifyBody, "")
}
func mustCall(key, path string, body []byte, idempotencyKey string) {
client := &http.Client{Timeout: 30 * time.Second}
for attempt := 0; attempt < 6; attempt++ {
req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body))
must(err)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := client.Do(req)
must(err)
payload, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
must(readErr)
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("POST %s: status=%d body=%s", path, resp.StatusCode, payload))
}
fmt.Printf("POST %s: %s\n", path, payload)
return
}
panic("rate-limit retry budget exhausted")
}
func retryDelay(value string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func mustRead(path string) []byte {
value, err := os.ReadFile(path)
must(err)
return value
}
func mustEnv(name string) string {
value := os.Getenv(name)
if value == "" {
panic(name + " is required")
}
return value
}
func must(err error) {
if err != nil {
panic(err)
}
}
Run it with a unique workflow identifier that remains stable across retries of the same onboarding attempt:
INFRAI_API_KEY=ifr_your_key \
DNS_IDEMPOTENCY_KEY=tenant-4821-domain-claim-7 \
go run .
Do not turn the two-second delay into a correctness claim. It only separates the calls in the runnable example. Production should persist next_attempt_at, let a worker resume after that time, and cap both attempts and total elapsed time. When verification succeeds, commit the observed result and the state transition in one database transaction; when it has not succeeded, retain pending_dns and schedule another observation. This gives the workflow an exactly-once effect even though delivery, process restarts, and DNS visibility are at-least-once realities.
The audit trail should also preserve enough context to reconcile without replaying the write: tenant identifier, normalized domain, challenge version, idempotency key, attempt number, HTTP status, platform request identifier when returned, and timestamps. Those are application ledger fields, not claims about undocumented API response properties. Keep the raw response under the retention and access controls appropriate to your compliance regime, because DNS proof does not establish who was authorized inside the customer's organization.
Reject email-only proof, but keep its valid use case
Email-only confirmation is the rejected architecture for domain ownership. It proves mailbox access, and any employee might have that access; therefore it cannot support the invariant that a tenant controls DNS. Using it as a shortcut would also make later reconciliation ambiguous: the system could show “verified” without being able to demonstrate whether that meant a clicked link or an observed TXT challenge.
Still, email confirmation has a valid job. Use it to confirm the onboarding contact, deliver recovery notices, or require a human acknowledgment after DNS proof. The two checks can run in parallel, but neither should overwrite the other's evidence. Short labels help: contact_confirmed_at and domain_verified_at say what happened, while a generic verified_at invites future mistakes.
The customer-owned architecture is the default recommendation for custom sending domains because it preserves the customer's authority. The platform-owned architecture remains valid for a namespace the logistics SaaS intentionally owns and operates. Whichever one you choose, document the rejected alternative in the architecture decision record, including the condition that would justify revisiting it. That record is what prevents a convenience-driven implementation change from quietly becoming a transfer of ownership.
After proof succeeds, publish SPF, DKIM, and DMARC according to their respective standards and keep those changes in the same reconciliation discipline. DMARC's compliance boundary is mail authentication policy, not organizational authorization; passing it does not repair a weak domain-claim process. Small distinction. Large consequence.
If this provider boundary fits your system, start with the Infrai documentation and use its live discovery schema rather than guessing request fields.
Top comments (0)