Custom domains for your SaaS, added and verified by API
Add the zone, hand back the nameservers, verify, then write the records. The four calls, the state machine, and the support ticket this replaces.
Letting customers use their own domain is usually a support conversation: you email them DNS instructions, they paste values into a registrar console, someone typos a TXT record, and the ticket runs for three days. Infrai turns the platform half into four calls — POST /v1/dns/domain/add, POST /v1/dns/domain/verify, PUT /v1/dns/record/upsert and GET /v1/dns/record/list — so your onboarding UI can drive it and show progress.
The customer still has to do one thing at their registrar. Everything after that is yours to automate.
Add the zone
curl -sS -X POST "https://api.infrai.cc/v1/dns/domain/add" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"domain": "portal.northwind.example", "metadata": {"tenant_id": "t_northwind"}}'
{
"ok": true,
"data": {
"zone_id": "3a9d545444dc7a02e085889a3f713078",
"domain": "portal.northwind.example",
"state": "pending",
"name_servers": ["lilyana.ns.cloudflare.com", "margo.ns.cloudflare.com"]
}
}
Three things to keep. zone_id is the handle for every record call afterwards. state: "pending" means the zone exists but delegation hasn’t happened. And name_servers is the pair you show the customer — those are the values they set at their registrar, and they’re specific to this zone rather than generic, so displaying them from the API beats documenting them.
Put the tenant in metadata. When you have four hundred zones, that’s the only thing linking one to a customer.
Show the customer exactly what to do
The nameserver change is the one step you can’t do for them, so make it impossible to get wrong: render the two values from the response with a copy button each, name the registrar if you know it, and tell them what happens next.
Then poll:
curl -sS -G "https://api.infrai.cc/v1/dns/domain/verify" \
--data-urlencode "domain=portal.northwind.example" \
--data-urlencode "zone_id=3a9d545444dc7a02e085889a3f713078" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-X POST
Verify returns the same record shape with an updated state. Delegation propagates on the internet’s schedule, not yours — minutes to a day — so this is a background check with a visible status in your UI, not a spinner the customer waits on.
The onboarding driver
import os
import time
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def add_domain(domain: str, tenant_id: str) -> dict:
resp = SESSION.post(f"{API}/v1/dns/domain/add",
json={"domain": domain, "metadata": {"tenant_id": tenant_id}}, timeout=30)
body = resp.json()
if not body.get("ok"):
raise RuntimeError(body["error"]["code"])
return body["data"]
def check(domain: str, zone_id: str) -> str:
"""Ask the platform to re-check delegation. Safe to call repeatedly — it reports
state, it doesn't mutate anything."""
resp = SESSION.post(f"{API}/v1/dns/domain/verify",
params={"domain": domain, "zone_id": zone_id}, timeout=30)
resp.raise_for_status()
return resp.json()["data"].get("state", "unknown")
def provision_records(zone_id: str, domain: str, target: str) -> list[dict]:
"""Upsert rather than create, so re-running onboarding is harmless. A create
that runs twice leaves two records and a resolver picking between them."""
wanted = [
{"record_type": "CNAME", "name": domain, "content": target, "ttl": 300, "proxied": True},
{"record_type": "TXT", "name": f"_verify.{domain}", "content": f"tenant={zone_id[:12]}", "ttl": 300},
]
written = []
for record in wanted:
resp = SESSION.put(f"{API}/v1/dns/record/upsert",
json={"zone_id": zone_id, **record}, timeout=30)
resp.raise_for_status()
written.append(resp.json()["data"])
return written
def onboard(domain: str, tenant_id: str, target: str, attempts: int = 20) -> dict:
zone = add_domain(domain, tenant_id)
state = zone["state"]
for _ in range(attempts):
if state == "active":
break
time.sleep(30)
state = check(domain, zone["zone_id"])
if state != "active":
return {"zone_id": zone["zone_id"], "state": state,
"name_servers": zone["name_servers"], "records": []}
return {"zone_id": zone["zone_id"], "state": state,
"name_servers": zone["name_servers"],
"records": provision_records(zone["zone_id"], domain, target)}
if __name__ == "__main__":
print(onboard("portal.northwind.example", "t_northwind", "edge.yourapp.example"))
Two deliberate choices. Polling with a wide interval, because DNS propagation is measured in minutes and hammering the check achieves nothing. And upsert rather than create, so a customer who clicks “retry” doesn’t end up with duplicate records.
Record types and the ones you’ll actually write
record_type is a closed enum: A, AAAA, CNAME, TXT and MX. For custom domains you need three of them.
| Purpose | Type | Notes |
|---|---|---|
| Point the domain at your edge | CNAME | or A for an apex where CNAME isn’t allowed |
| Prove ownership / carry metadata | TXT | also where SPF and DKIM live |
| Receive mail on the domain | MX | with priority |
| Proxy through the CDN | any, with proxied: true | hides your origin |
proxied is worth knowing about: set it and traffic goes through the provider’s network rather than resolving straight to your origin, which is usually what you want for a customer-facing hostname.
Check your work
curl -sS -G "https://api.infrai.cc/v1/dns/record/list" \
--data-urlencode "zone_id=3a9d545444dc7a02e085889a3f713078" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Filter by record_type or name to answer a specific question. This is also the read your support tooling should show — “here is what we have for your domain” ends more tickets than any instruction page.
Limitations
The vendor behind these calls is Cloudflare, so the zone lives there and the nameservers are Cloudflare’s. That’s fine for most custom-domain onboarding and it’s a real constraint if your customer’s security policy names a specific DNS provider, or if they can’t delegate the zone at all — in that case they keep their own DNS and add records by hand, and this API isn’t a good fit for them.
There’s also no DNSSEC control and no support for the rarer record types — no SRV, no CAA, no NS delegation records — so a domain needing those needs its own arrangement. And nothing here polls for you: the verification loop above is yours to run.
What is on the same credential is the thing custom domains always lead to. A verified domain that also sends email needs SPF, DKIM and DMARC records — POST /v1/email/domain/verify and POST /v1/email/domain/rotate_dkim are on this key, and the TXT records they require go through the same PUT /v1/dns/record/upsert you just used. No second vendor, one invoice, and GET /v1/account/usage covers both halves. DNS management routes report billing_class: free in discovery (verified 2026-09-21), and platform rates drift downward as vendor contracts improve.