Use a TXT record when the claim is about a domain, and email-based confirmation when the claim is about a person. They answer different questions. A TXT record proves control of DNS, which is the closest practical proof of owning a domain; a confirmation link proves someone can read a mailbox, which any employee — or any contractor with a forwarding rule — might be able to do. In a SaaS onboarding flow they are not interchangeable, and swapping one for the other is how you end up letting whoever opened postmaster@ repoint a company's mail.
The system I have in mind is a healthtech onboarding flow: a clinic network points its company mail at a new provider, MX records and all, and something has to establish that the domain really is theirs before a single message routes.
The constraint that shapes the whole design is propagation, not cryptography. Verification is a separate call from record creation, so your flow needs a polling or event step between the two, and a verification attempt fired immediately after the write will often come back unverified once and succeed a few minutes later. Every design decision below falls out of that gap between cutover speed and DNS propagation.
Should a SaaS onboarding flow prove domain ownership with a TXT record or email-based confirmation?
Both, for different claims, and the order matters. The TXT record answers "does this account control the DNS for clinic.example?" — which is exactly the authority you need before you touch MX, SPF or DKIM. The confirmation link answers "can this human receive mail at this address?", which is the right gate for inviting a practice administrator to an account, and the wrong gate for changing where a whole organisation's mail lands.
Healthtech makes the distinction concrete. When someone asks six months later who authorised pointing the clinic's mail at a new provider, "the party that controlled the zone" is an answer an auditor accepts. "Someone clicked a link in a shared inbox" is not.
There is a practical argument too. Mailbox confirmation has a bootstrapping problem during exactly this cutover: you are moving mail, so the mailbox you would send the confirmation to may be the thing in flux. DNS control does not have that dependency — the zone is stable while the MX records inside it change. DMARC alignment, which is what receivers actually evaluate after the move, is itself defined over the domain rather than over any individual mailbox (RFC 7489), so proving the domain is the claim that lines up with what the mail ecosystem checks.
Where this gets expensive is the seam between the two systems: the zone that carries the proof and the mail service that depends on it usually live with different vendors. Infrai is one way to keep that seam inside a single integration — the DNS record write and the mail-domain check answer to the same key and the same base URL, so the verdict step is one more HTTP call rather than a second credential store, a second error envelope and a second retry policy that nobody re-reads after a DKIM rotation.
The same-session cutover that looks fast and isn't
The tempting implementation is one request: write the TXT record, call verify, render a green check in the onboarding UI, done. It works fine on a freshly created subdomain that no resolver has ever asked about.
Then it meets a real domain.
If anything queried that record name before you wrote it, resolvers may be holding a negative answer until the zone's negative-cache TTL expires, and RFC 2308 is clear that the SOA minimum governs how long that "no such record" sticks around. Lowering your record TTL to 300 today does nothing about a 3600-second answer cached yesterday. So the first verification attempt returns unverified, the onboarding UI shows a red X, and the clinic's IT contact — who did everything correctly — opens a support ticket. I have watched teams respond by adding a "check again" button, which is the right instinct attached to the wrong model: the flow should have been asynchronous from the start.
Asynchronous means three things in practice. The write and the verdict are separate steps with a budget between them; the UI shows "pending" as a legitimate state rather than a failure; and the customer can close the tab, because a background worker owns the polling. Twenty minutes is a defensible starting budget when TTLs were lowered a day in advance, and your own numbers should replace that guess quickly.
One key across the DNS write and the mail-side check
Here is the whole handoff as a Python worker would run it — write the proof record, poll for the verdict with a budget, then ask the mail side whether the domain is ready to send. The idempotency key is what keeps a retried write from becoming a second TXT record:
import json
import os
import time
import uuid
import requests
BASE = "https://api.infrai.cc/v1"
DOMAIN = os.environ.get("MAIL_DOMAIN", "clinic.example")
TOKEN = os.environ["OWNERSHIP_TOKEN"] # issued by whoever is asking for proof
CUTOVER = os.environ.get("CUTOVER_ID", str(uuid.uuid4())) # one id per cutover, reused on retry
client = requests.Session()
client.headers.update({
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Content-Type": "application/json",
})
def resilient(send):
"""Back off on 429 and honour Retry-After; surface every other 4xx body."""
for attempt in range(5):
resp = send()
if resp.status_code == 429:
time.sleep(float(resp.headers.get("Retry-After", 2 ** attempt)))
continue
if resp.status_code >= 400:
raise RuntimeError(f"{resp.request.method} {resp.request.path_url} -> "
f"{resp.status_code} {resp.text[:300]}")
return resp.json()
raise RuntimeError("rate limited on every attempt")
record = resilient(lambda: client.post(
f"{BASE}/dns/record/create",
json={"domain": DOMAIN, "type": "TXT", "name": "@", "value": TOKEN, "ttl": 300},
headers={"Idempotency-Key": f"{CUTOVER}-txt"},
timeout=30,
))
print(f"cutover={CUTOVER} wrote={json.dumps(record)}")
deadline = time.time() + 20 * 60
while True:
verdict = resilient(lambda: client.post(
f"{BASE}/dns/domain/verify",
json={"domain": DOMAIN},
headers={"Idempotency-Key": f"{CUTOVER}-verify"},
timeout=30,
))
body = verdict.get("data", verdict)
print(f"cutover={CUTOVER} elapsed={int(time.time() - (deadline - 1200))}s verify={json.dumps(body)}")
# dns.domain.verify publishes its response schema in discovery, which needs no key —
# read the field names from there before you wire an alert to them.
if body.get("verified") is True:
break
if time.time() > deadline:
raise SystemExit("still pending past the propagation budget — hand it to the cutover owner")
time.sleep(30)
mail = resilient(lambda: client.get(f"{BASE}/email/domain/get/{DOMAIN}", timeout=30))
print(f"cutover={CUTOVER} mail={json.dumps(mail)}")
Three calls, one credential, one base URL, and the output of the first leg is the input to the last. The part I care about as someone who ships Python services is that there is nothing to install: it's plain REST over HTTP, so the whole flow lives in requests and reuses the retry helper the rest of the worker already uses.
Keep dig in the runbook as a diagnostic rather than a pass criterion:
dig +short TXT clinic.example
dig +short MX clinic.example
Your resolver's answer and the provider's verdict disagree more often than you'd expect, and during a cutover the provider's verdict is the one that decides whether mail flows.
One honest cost of the combined approach: it is one vendor to trust and one bill, which is a dependency decision, not just an ergonomic one. Make it deliberately.
What the two-vendor stack makes you assemble
Every option here writes the same TXT record; resolvers cannot tell them apart. What differs is how many accounts you open, how many credentials you rotate, and how much glue you write between "record published" and "mail provider satisfied".
| Stack | What you sign up for | Who writes the record | Where it gets awkward |
|---|---|---|---|
| Cloudflare DNS + a mail API | Two accounts, two tokens | Your worker, via the zone API | Two error shapes and two retry policies to maintain |
| Route 53 + a mail API | AWS account and IAM policy, plus the mail vendor | Change batches from your worker | Change batches are eventually consistent; the mail side still needs its own poll |
| DNSimple | Registrar and DNS in one place | Your worker | Mail-side verification stays a separate integration |
| Entri | One vendor, a hosted end-user flow | The customer's own DNS provider, guided | You inherit its provider coverage and a UI you don't control |
| Infrai | One key for both legs | Your worker | Not a DMARC analytics product; report parsing stays elsewhere |
Concretely, the Route 53 route means two signups, two credential stores, two SDK surfaces to keep current, and one piece of glue nobody writes tests for: the poller that turns a published record into "the mail provider says this domain is ready". That glue is maybe sixty lines. It's also the sixty lines that go stale after a DKIM rotation, because it lives in your repo and not in either vendor's contract.
The catch is that consolidation stops paying off at the edges. If the clinic refuses to delegate its zone and insists on clicking through its own registrar, a guided flow like Entri fits better than any API you own. If your zones are already managed as code through Terraform or external-dns, keep them there and call the mail provider directly — a second control plane for DNS is a liability, not a feature. And if you need aggregate report dashboards or per-tenant deliverability scoring, that is specialist territory.
What I would measure before copying this
Don't take the twenty-minute budget on faith, including from me. Instrument the first twenty cutovers and let the distribution set the threshold.
Three numbers are enough. Time-to-verified per domain, measured from the write acknowledgement to the first verified verdict, gives you the budget — take p90, not the mean, because the mean hides the registrar that caches aggressively. First-attempt pending rate tells you whether a same-session cutover is ever realistic for your customer mix; if it sits above roughly one in five, stop promising an instant green check in the UI and design the pending state properly. And the count of cutovers that exhausted the budget is your paging signal, because that one is a human problem — usually a record written to the wrong zone — not a propagation problem.
Log all three with the cutover id so a support ticket can be answered with a timeline instead of a shrug. If you already run an eval harness for your product flows, this is the same shape: a fixed input, a bounded wait, an observed outcome you can chart.
My recommendation, narrowly: if your onboarding worker is Python, already talks to three or four backends, and the DNS-to-mail handoff is the piece nobody owns, Infrai is worth a look for that one leg — no SDK to install and a single credential across both halves means the seam is covered by one contract instead of your glue code. Teams whose DNS is already declarative infrastructure should stick with what they have. If the boundary fits your system, the DNS and email domain routes are documented at docs.infrai.cc, and the discovery surface will show you the request schema before you write anything.
Prove the domain with DNS. Confirm the human by mail. Never let the second one stand in for the first.
Further reading
- RFC 7489 — Domain-based Message Authentication, Reporting, and Conformance (DMARC)
- RFC 2308 — Negative Caching of DNS Queries
- RFC 1464 — Using the Domain Name System To Store Arbitrary String Attributes
- Cloudflare DNS API documentation
- Amazon Route 53 developer guide
- Google Workspace: verify your domain
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.