One onboarding flow for a custom domain and its email sending

The state machine that takes a customer from 'I have a domain' to authenticated sending, with the failure states named and a resumable design.

A customer who brings their own domain almost always wants two things: their app on it and their email sent from it. As separate vendors those are two onboarding flows that can’t see each other, and the failure mode is a domain that serves traffic while its mail goes to spam. On Infrai both halves are one credential — POST /v1/dns/domain/add and POST /v1/email/domain/verify — so the whole thing can be one resumable state machine.

This page is that state machine, including the states where it stops and waits for a human.

The states

requested → zone_created → awaiting_delegation → delegated
          → records_written → awaiting_email_verify → live
                                         ↘ failed_delegation
                                         ↘ failed_email_verify

Two of those states are waits on the outside world, and the whole design follows from that: nothing here is a request handler, everything is resumable, and the customer sees a status rather than a spinner.

Step one: 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": "northwind.example", "metadata": {"tenant_id": "t_northwind"}}'
{
  "ok": true,
  "data": {
    "zone_id": "3a9d545444dc7a02e085889a3f713078",
    "domain": "northwind.example",
    "state": "pending",
    "name_servers": ["lilyana.ns.cloudflare.com", "margo.ns.cloudflare.com"]
  }
}

Store zone_id against the tenant immediately, before you do anything else. Everything downstream needs it, and a crash between this call and your write leaves an orphan zone nobody can associate with a customer.

Step two: the wait you can’t avoid

The customer sets the two nameservers at their registrar. You poll:

curl -sS -X POST -G "https://api.infrai.cc/v1/dns/domain/verify" \
  --data-urlencode "domain=northwind.example" \
  --data-urlencode "zone_id=3a9d545444dc7a02e085889a3f713078" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Minutes to a day. Poll on a schedule, not in a loop inside a web request, and email them if it hasn’t landed in a few hours.

Step three and four: records, then email authentication

Once delegation is active, write the app records and ask the email side what it wants:

curl -sS -X POST "https://api.infrai.cc/v1/email/domain/verify" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"domain": "mail.northwind.example"}'

The response carries a dns_records array — SPF, DKIM, DMARC and a tracking CNAME, each with type, name, value and ttl_recommended. Feed them straight into PUT /v1/dns/record/upsert. No transcription, no customer involvement, no typo.

The whole machine

import os
from dataclasses import dataclass, field

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
EDGE = "edge.yourapp.example"
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})


@dataclass
class Onboarding:
    tenant_id: str
    domain: str
    sending_domain: str
    state: str = "requested"
    zone_id: str | None = None
    name_servers: list[str] = field(default_factory=list)
    note: str = ""


def advance(job: Onboarding) -> Onboarding:
    """One step per call, driven by a schedule. Resumable by construction: the
    caller persists the returned job and calls again later, so a crash costs one
    step rather than the whole onboarding."""
    if job.state == "requested":
        resp = SESSION.post(f"{API}/v1/dns/domain/add",
                            json={"domain": job.domain, "metadata": {"tenant_id": job.tenant_id}},
                            timeout=30)
        body = resp.json()
        if not body.get("ok"):
            job.state, job.note = "failed_delegation", body["error"]["code"]
            return job
        data = body["data"]
        job.zone_id, job.name_servers, job.state = data["zone_id"], data["name_servers"], "awaiting_delegation"
        return job

    if job.state == "awaiting_delegation":
        resp = SESSION.post(f"{API}/v1/dns/domain/verify",
                            params={"domain": job.domain, "zone_id": job.zone_id}, timeout=30)
        resp.raise_for_status()
        if resp.json()["data"].get("state") == "active":
            job.state = "delegated"
        return job

    if job.state == "delegated":
        SESSION.put(f"{API}/v1/dns/record/upsert",
                    json={"zone_id": job.zone_id, "record_type": "CNAME", "name": job.domain,
                          "content": EDGE, "ttl": 300, "proxied": True},
                    timeout=30).raise_for_status()
        job.state = "records_written"
        return job

    if job.state == "records_written":
        resp = SESSION.post(f"{API}/v1/email/domain/verify",
                            json={"domain": job.sending_domain}, timeout=30)
        body = resp.json()
        if not body.get("ok"):
            job.state, job.note = "failed_email_verify", body["error"]["code"]
            return job
        for record in body["data"].get("dns_records", []):
            SESSION.put(f"{API}/v1/dns/record/upsert",
                        json={"zone_id": job.zone_id, "record_type": record["type"],
                              "name": record["name"], "content": record["value"],
                              "ttl": record.get("ttl_recommended", 3600)},
                        timeout=30).raise_for_status()
        job.state = "awaiting_email_verify"
        return job

    if job.state == "awaiting_email_verify":
        resp = SESSION.post(f"{API}/v1/email/domain/verify",
                            json={"domain": job.sending_domain}, timeout=30)
        resp.raise_for_status()
        if resp.json()["data"].get("status") == "verified":
            job.state = "live"
        return job

    return job


if __name__ == "__main__":
    print(advance(Onboarding("t_northwind", "northwind.example", "mail.northwind.example")))

One step per invocation. POST /v1/cron/create calls your endpoint every few minutes, your endpoint advances every job that isn’t live or failed, and the customer’s status page reads the state.

What the customer sees

StateWhat you show them
awaiting_delegationthe two nameservers, a copy button, “usually minutes, sometimes a day”
failed_delegationwhat you expected versus what DNS answers now
records_written”configuring email authentication”
awaiting_email_verify”verifying — no action needed”
livethe domain, and a test-send button

The failure states are the ones worth designing properly, because a stuck onboarding with no explanation becomes a support ticket and then a churn risk. Show the mismatch, don’t just say “pending”.

Limitations

The customer must delegate their zone to the platform’s nameservers, which is what makes the automated half possible. A customer who keeps DNS at Route 53 or GoDaddy can still send authenticated mail — they take the dns_records array and add it themselves — but for them this flow is an instruction page, not automation, and that’s a real limitation rather than a framing choice.

Delegation also moves their whole zone, including MX records for mail they receive. That’s a bigger ask than it sounds for an established company, so offer a subdomain (app.northwind.example) as the default path and full delegation as the option.

There’s no webhook for either verification step, so the polling loop is yours. And email sending starts with a warm-up ceiling rather than full volume — daily_limit_current in the verify response is the number to surface, because “authenticated” and “can send your whole campaign” are not the same state.

What the single credential buys is that the two halves can’t disagree: one account holds the zone, the sending domain, the schedule that advances the job, the log line and the notification via POST /v1/email/send, with everything visible in one GET /v1/account/usage. Both route families report billing_class: free in discovery (verified 2026-09-21), and sending rates drift downward as vendor contracts improve.

References

Browse more dns developer guides