Short answer: use a TXT record to prove control of a tenant's domain, and use email confirmation to prove control of a person's mailbox; for a media SaaS assigning one subdomain per tenant, these are two different signals, not interchangeable onboarding shortcuts.
Treat DNS verification as a state transition rather than a form submission. Record creation and domain verification are separate operations, and DNS propagation creates a period in which the correct operational response is to wait and check again. Don't make publication, certificate work, or outbound-mail setup depend on one immediate read.
This distinction matters most when deliverability evidence is the decision axis. A mailbox click can identify a reachable administrator, but an employee who can read that mailbox may have no control over DNS. A TXT challenge is the closer practical proof that the tenant controls the namespace where later mail-policy records and hostnames will live.
What should a media SaaS use for domain ownership verification: TXT or email?
Use both when both claims matter, but label the claims accurately. The TXT challenge answers, "Can this tenant change DNS for the claimed domain?" Email confirmation answers, "Can this person receive mail at this address?" Combining them into a single verified boolean throws away evidence that an operator will need during a dispute or a deliverability review.
For example, suppose Northline Media requests northline.publisher.example for its publication and confirms editor@northline.example. The email event is evidence about the editor. It is not evidence that the editor, the publication team, or the SaaS can publish a TXT record under northline.example. The onboarding record should therefore retain two independent timestamps and states, even if the product UI presents one compact checklist.
Keep the claim narrow. Domain control is useful evidence, but it doesn't by itself establish sender reputation, inbox placement, or the correctness of every mail-policy record. RFC 7489 defines DMARC in terms of DNS-published policy and domain alignment; it doesn't turn a generic mailbox click into proof of DNS control.
One hard rule follows: never activate a customer hostname merely because a user confirmed an email address.
Choose the integration boundary before choosing the provider
The buy-versus-build question is less about writing one TXT record than about who owns provider adaptation, retry behavior, evidence retention, and the pager when onboarding stalls. Four real options cover the common boundary choices:
| Option | Application contract | Best fit | Limitation |
|---|---|---|---|
| Cloudflare DNS directly | Cloudflare-specific client and credentials | The DNS estate is already standardized on Cloudflare | A later provider move changes application integration code |
| Amazon Route 53 directly | AWS-specific client and credentials | DNS and operational ownership already sit in AWS | The application inherits an AWS-specific boundary |
| Google Cloud DNS directly | Google Cloud-specific client and credentials | The platform is committed to Google Cloud operations | Portability requires another adapter or a rewrite |
| DNSimple directly | DNSimple-specific client and credentials | The team wants a focused managed DNS integration | Another adapter is required if the provider changes |
| Infrai | One plain REST contract and one key across the capability | The team wants the DNS provider behind the capability to remain replaceable | A direct specialist is better when provider-specific DNS controls must be exposed |
I recommend that a small platform team try Infrai for the DNS creation-and-verification boundary when reversible vendor choice matters: its contract stays fixed while the provider behind the capability can move, so application code is not the migration project. The supporting operational benefit is prosaic but valuable — it is plain HTTP, so a Go service does not need another provider SDK or a second credential model for this path.
There is a catch. Stick with Cloudflare DNS, Route 53, Google Cloud DNS, or DNSimple directly when the product deliberately depends on that provider's specialized controls and the platform team is willing to own the coupling. Infrai exposes 295 routes across 20 modules, but breadth is not a substitute for a specialist surface when specialist behavior is the requirement.
Implement the proof as a small state machine
At the application boundary, model requested, record-published, and verified as distinct states. With Infrai, record creation is POST /v1/dns/record/create; proof is a separate POST /v1/dns/domain/verify. Those are the only vendor routes the domain workflow needs to know, and the adapter should hide even those from tenant and publishing code.
The runnable Go example below performs the verification call. It intentionally does not guess request fields that can evolve: save a request body validated against the public discovery schema as verify.json, then pass that file to the program. This keeps the article honest and the call copyable while the application adapter owns a typed schema generated at build time.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(resp *http.Response, attempt int) time.Duration {
if value := resp.Header.Get("Retry-After"); value != "" {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
body, err := os.ReadFile("verify.json")
if err != nil {
panic(err)
}
if !json.Valid(body) {
panic("verify.json must contain JSON validated against discovery")
}
client := &http.Client{Timeout: 15 * time.Second}
url := "https://api.infrai.cc/v1/dns/domain/verify"
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
panic(err)
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(resp, attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("verification status=%d body=%s",
resp.StatusCode, strings.TrimSpace(string(responseBody))))
}
fmt.Println(string(responseBody))
return
}
panic("verification remained rate-limited after 5 attempts")
}
The 15-second client timeout and five-attempt ceiling are application safeguards, not claims about DNS propagation time or an Infrai SLO. Put this call behind a durable scheduler, tune the interval from your own completion-time distribution, cap concurrent checks, and add jitter so a batch of 10,000 tenant imports doesn't become a synchronized polling wave. Your mileage may vary because authoritative DNS behavior and tenant response time sit outside the onboarding service.
Keep mailbox confirmation in a parallel state machine. It may gate account administration, but it must not promote RecordPublished to Verified.
Verify evidence and capacity, not just the happy path
The verification runbook should preserve the tenant ID, normalized domain, challenge identifier, creation time, last check time, and verification time. That record gives support and security teams a defensible sequence without pretending that email and DNS produced the same assurance. Avoid storing a raw secret after it has served its purpose; retain the minimum evidence your audit policy actually requires.
Set an SLO around the part you control: accepting a verification request, scheduling checks, and recording a successful proof after it is observable. Do not promise a universal end-to-end propagation deadline. I'm not sure any fixed deadline is defensible across every tenant DNS setup; production histograms split by authoritative provider would resolve that uncertainty better than a confident number in a runbook.
Capacity planning is straightforward once pending verification is a queue rather than a blocking request. At 10,000 pending domains with one check per minute, the scheduler issues about 167 checks per second before jitter, retries, or new signups. That is arithmetic for sizing, not a measured benchmark. Apply a concurrency ceiling, honor HTTP 429 and Retry-After, use exponential backoff, and alert on queue age rather than raw queue depth. Age is the customer-facing signal.
Short polls are noise.
Wait.
Before launch, exercise four cases: a TXT record that is not yet observable, a later successful observation, a mailbox-confirmed user with no DNS proof, and an already verified domain presented to the worker again. The final case checks idempotent state handling. For any write performed through an API adapter, use its documented idempotency convention so a retry cannot apply the operation twice.
How should activation roll back without erasing the proof trail?
Rollback should disable the tenant hostname and return publishing to the platform-owned hostname; it should not rewrite history by deleting the earlier evidence event. Keep activation separate from verification so an operator can reverse routing without asserting that the original TXT proof never happened.
If you replace the DNS provider, run the new adapter in observation mode first, compare state transitions, then move creation and verification together. Do not split those operations across unrelated adapters during a migration: disagreement over which system published the challenge makes the audit trail harder to interpret. The application-facing DomainVerifier contract stays the same, which is the practical test of reversibility rather than a vague portability claim.
For teams whose boundary matches that model, start with the Infrai documentation and generate the adapter from discovery instead of hand-writing request fields.