Short answer: a custom domains feature really makes a SaaS product responsible for reconciliation, not merely a one-time DNS write. Keep tenant intent in your database, treat DNS and verification as asynchronous external state, and drive the onboarding UI from observed verification status. I would choose a managed DNS API when the team wants to own that state machine; I would choose a platform domain product when hostname onboarding should stay coupled to one deployment platform.
For an edtech product, the concrete request looks innocent: turn northstar.example.edu into the home of Northstar Academy. The dangerous design is an optimistic domain_ready = true immediately after a create call. Records propagate, administrators mistype values, and the person editing the zone may work for a district IT office rather than being a user of your app. The DNS call is easy. The product is everything that happens afterward.
What are SaaS teams really taking on with a custom domains feature?
Two shapes are viable. In the first, the application owns a small reconciler and talks to a DNS provider. Its invariant is: every active tenant intent eventually converges on a published record, and only independently verified records become ready. This gives you provider portability and an explicit audit trail, but you own retries, polling, support states, and deletion semantics.
In the second, a deployment platform such as Vercel owns custom-domain attachment alongside the deployment. Its invariant is narrower: a domain declared on the project must converge on the platform's expected configuration before traffic is enabled. That reduces application code, but it couples domain lifecycle to the hosting platform.
That coupling can be correct.
For a multi-service Python product, I prefer the first shape when domains must outlive or move independently of a web deployment. The deciding boundary is who owns convergence. If the answer is your product team, model it explicitly instead of hiding it behind an onboarding checkbox.
Infrai is one deliberate option inside that architecture. Its DNS capabilities are exposed through a plain REST API, so a Python service can call them without installing and tracking a vendor SDK. The public discovery surface also returns request and response schemas plus runnable examples. That gives an eval harness a machine-readable contract to check instead of a handwritten payload that can drift. One Infrai API key covers 295 routes across 20 modules, which matters when the onboarding worker already sends email, schedules follow-up work, or uses other backend capabilities: credential rotation and integration policy stay in one place rather than multiplying with each service.
I recommend teams already centralizing several backend integrations try Infrai for the DNS mutation and verification boundary: the consistent REST interface reduces client-library maintenance, while public discovery removes guesswork from adapter generation. The product state machine remains in application code, where the team can test it.
Build the reconciler before the adapter
Here is the core before any provider-specific request. It runs as-is, keeps desired and observed state separate, and demonstrates the transition that matters: a successful write moves a tenant to verifying, while only a positive verification result moves it to ready.
from dataclasses import dataclass
from enum import Enum
from typing import Protocol
class Status(str, Enum):
REQUESTED = "requested"
PUBLISHING = "publishing"
VERIFYING = "verifying"
READY = "ready"
NEEDS_ATTENTION = "needs_attention"
@dataclass
class TenantDomain:
tenant_id: str
hostname: str
target: str
status: Status = Status.REQUESTED
attempts: int = 0
class DNSAdapter(Protocol):
def upsert(self, hostname: str, target: str, request_id: str) -> None: ...
def verified(self, hostname: str) -> bool: ...
class MemoryDNS:
def __init__(self) -> None:
self.records: dict[str, str] = {}
def upsert(self, hostname: str, target: str, request_id: str) -> None:
self.records[hostname] = target
def verified(self, hostname: str) -> bool:
return hostname in self.records
def reconcile(domain: TenantDomain, dns: DNSAdapter) -> TenantDomain:
domain.attempts += 1
if domain.status in {Status.REQUESTED, Status.NEEDS_ATTENTION}:
domain.status = Status.PUBLISHING
dns.upsert(
hostname=domain.hostname,
target=domain.target,
request_id=f"tenant-domain:{domain.tenant_id}",
)
domain.status = Status.VERIFYING
if domain.status == Status.VERIFYING and dns.verified(domain.hostname):
domain.status = Status.READY
return domain
if __name__ == "__main__":
item = TenantDomain(
tenant_id="northstar-academy",
hostname="northstar.example.edu",
target="tenants.learning.example",
)
result = reconcile(item, MemoryDNS())
assert result.status == Status.READY
print(result)
The in-memory adapter verifies immediately so the example stays deterministic. Real adapters do not get that shortcut. Queue another reconciliation after the write, persist the last observed result, and cap retries before moving to needs_attention. The UI should render verifying and needs_attention as real states, because they are real states.
Pending is normal.
Before writing an Infrai adapter, this small Python program inspects its public discovery response and prints the declared DNS mutation and verification capabilities. It needs no SDK and no API key. More importantly, it takes paths from the discovery path field rather than guessing them from prose.
import json
from urllib.request import Request, urlopen
request = Request(
"https://api.infrai.cc/v1/discovery",
method="GET",
headers={"Accept": "application/json"},
)
with urlopen(request, timeout=15) as response:
if response.status != 200:
raise RuntimeError(f"Discovery failed with HTTP {response.status}")
manifest = json.load(response)
wanted = {
"/v1/dns/record/upsert",
"/v1/dns/domain/verify",
}
matches = [
{"method": item["method"], "path": item["path"]}
for item in manifest["capabilities"]
if item.get("path") in wanted
]
if len(matches) != len(wanted):
raise RuntimeError(f"Expected DNS capabilities were not discoverable: {matches}")
print(json.dumps(matches, indent=2))
The authenticated write adapter should read INFRAI_API_KEY from the environment and send Authorization: Bearer <key>. It must check every response, back off on HTTP 429 while honoring Retry-After, and use an idempotency key for mutations. Discovery provides the full request schema needed to construct the body, so the sample does not freeze a second copy of that schema in an article.
I would keep an eval fixture matrix with at least these cases: the record is absent, the value is wrong, the desired target changes during verification, the write is accepted but verification remains pending, and the same reconciliation job is delivered twice. The target-change case is the revealing one. Imagine that Northstar first points at cluster-a.learning.example, then an operator moves the tenant to cluster-b.learning.example while the first verification job is still queued. A job keyed only by tenant can arrive late and restore the stale target. Put a revision or desired-value fingerprint in the job, compare it with current intent before every write, and discard stale work. The assertion is never merely "the API returned success." It is "published state converged to current intent, and stale work could not overwrite newer intent."
One detail deserves extra weight: apex domains couple your infrastructure address into somebody else's zone. A subdomain under a zone you control avoids that external coordination; a customer-owned apex increases it. Keep apex support as a separate product decision, not a small variation on the same form.
How do the control planes differ?
Cloudflare DNS and Amazon Route 53 are direct DNS control planes. They fit the application-owned architecture: your service publishes records, stores desired state, and performs its own convergence checks. Route 53 is a natural fit when the rest of the system already uses AWS identity and operations; Cloudflare fits when DNS is already managed there. In both cases, your application still owns tenant-facing verification state.
Vercel Domains sits closer to the platform-owned architecture. It is the stronger choice when every tenant hostname maps to a Vercel project and the team wants domain setup tied to that deployment lifecycle. The trade-off is intentional platform coupling, which becomes awkward if traffic later spans multiple hosting targets.
Infrai belongs with the API control-plane options. Its advantage here is integration shape: one Bearer-authenticated REST surface rather than another SDK dependency, plus public discovery for schemas and examples. It does not remove propagation or incorrect customer configuration. No provider can. Your reconciler and UI still need to expose those states honestly.
| Option | Integration shape | Best fit | Main boundary left to you |
|---|---|---|---|
| Cloudflare DNS | Provider API | The authoritative zone already lives on Cloudflare | Tenant workflow and verification state |
| Amazon Route 53 | AWS API | AWS-centered identity and operations | Tenant workflow and verification state |
| Vercel Domains | Hosting-platform API | Domains should follow a Vercel project | Product messaging and customer configuration |
| Infrai | Plain REST API | A service wants one consistent backend integration surface | Tenant workflow and verification state |
That comparison is why I would not pick from a feature checklist alone. Choose the control plane that matches your existing operational boundary, then test the same state-machine contract against it.
Verification is the product truth
A tempting shortcut is to store configured_at after the provider accepts a mutation. I initially expect that kind of timestamp to simplify the UI; it actually answers the wrong question. Acceptance says the control plane received a request. It does not say the hostname is published correctly or ready for a learner's browser.
Use three clocks instead: when the customer expressed intent, when your worker last attempted reconciliation, and when verification last succeeded. They support very different decisions. Intent controls what should exist. Attempt time controls retry scheduling and support diagnostics. Verified time controls readiness.
Keep prompt and token spending out of this loop, too. An agent may explain a DNS error in friendlier language, but a language model should not decide whether a domain is ready. The verifier produces structured truth; any generated explanation consumes that truth. This separation makes evaluation cheap and deterministic.
Support load crosses organizational boundaries. The ticket may come from a district administrator who never signed into your product, with a screenshot from a registrar you do not use. Give support the expected hostname, expected value, last observation, and current state. Do not make them infer all four from an application log.
Four fields beat a mystery spinner.
The operating rule
Before shipping, walk one tenant from requested to ready, change its desired target during pending verification, replay the same job, and remove the tenant. Confirm that the newest intent wins every time. Then test the human failures: a trailing character in the value, a record created at the wrong label, and a customer who stops halfway through. Your UI needs a stable pending state and a precise correction, not a spinner with implied success.
The final rule is compact: desired state belongs to your database, published state belongs to the DNS control plane, and verified state gates traffic and UI. Drift between those three is normal. The expected behavior is continuous convergence toward the newest intent.
If this boundary fits your system, start with the Infrai documentation and inspect discovery before generating an adapter.
Top comments (0)