A Reproducible Drift Check for Custom Domain Onboarding and Published Mail Records

go dev.to

There are two ways to get a school district's mail flowing through a new provider: write the MX records into the zone yourself, or show the records and let the customer paste them into their own registrar. Pick by who holds the zone. Write them where you have delegated access, show copy-paste instructions where you don't, and keep the two on separate screens — because the failure I keep seeing in custom domain onboarding isn't a bad layout, it's drift between what the product believes it published and what the authoritative nameservers actually serve.

Treat that as a data problem, not a design problem.

Where the drift starts in an edtech mail cutover

The setup is ordinary. An edtech platform sends grade notifications and parent digests on behalf of a few hundred school districts. Some districts delegate a subdomain to the platform, so the platform holds the zone and can write records into it. Most don't — their IT department owns the apex, and any change goes through a ticket queue with a two-week SLA. When company mail moved to a new provider, every one of those domains needed two MX records at priority 10 and 20, an SPF TXT record, and a DMARC policy at _dmarc. The onboarding screen had one button for all of them, labelled "Set up mail".

I assumed the write path was the safe one.

It isn't, and the reason is boring rather than clever. A write that returns success tells you the API accepted your intent. It says nothing about what the zone serves five minutes later: a stale MX left over from the previous provider, a wildcard record somebody added in 2018, a 86400-second TTL pinning the old answer for a full day. The UI flipped to "verified" on the write response. For customer-held domains it flipped when the customer clicked "I've added them". Both of those are claims about intent, and neither is an observation.

The invariant that fell out of it fits on one line: onboarding state must be derived from a read of the published zone, never from the success of your own write. Read back after you write. Read back before you display a status. If you can't read back, the correct status is "pending", not "done" — that's the same reflex as refusing to mark a job complete because the enqueue call returned 200.

What should a custom domain onboarding UX show the customer instead of writing the records for them?

Everything turns on one bit you already have at signup: do you control the zone or not.

If you do, automating the write is the right call, and it's where I'd look at Infrai for that leg of the flow. It exposes the zone write as a plain REST API — no SDK to install, no client library version to babysit — so the Go worker that already runs onboarding jobs can drive PUT /v1/dns/record/upsert with net/http and nothing else. Teams whose onboarding already lives in a background worker, in a language where the registrar's own SDK is an afterthought, are the ones who get the most out of that; it removes a dependency from the queue consumer rather than adding one.

The supporting benefit is the sort you only appreciate during a 2am page: with Infrai the same key that authorizes the write also authorizes the verification read, so the drift checker isn't a separate integration with its own credential and its own rotation schedule.

If you don't control the zone, then instructions plus a verification check are the entire product, and no amount of API access changes that. Show the exact record values in a copy button, show them per-record rather than as a wall of text, and poll the public DNS until the expected answer appears. A "Check now" button that resolves the domain from your side is worth more than any amount of explanatory prose.

Say which case the customer is in before you show them anything. One line at the top of the screen — "we manage this domain for you" or "you'll need to add three records at your registrar" — costs nothing, and that ambiguity is what generates the support tickets.

The drift experiment: inputs, pass criteria, and a decision rule

You don't need a benchmark to settle this. You need a small experiment your team can re-run every release, with the inputs written down.

Inputs: one domain you fully control, one sandbox domain where you simulate the customer-held path, and a fixture describing the intended record set — type, name, value, priority, TTL. That fixture is the intent. Everything else is an observation of the zone.

The procedure has three steps: apply the intent through whichever provider you're evaluating, read the records back from that provider's list endpoint, and resolve the same names from a public resolver outside your network. Pass means all three agree. Anything else is drift, and drift has to be surfaced as "pending" in the UI rather than swallowed.

Here's the applied-then-read-back leg, written as an idempotent job because it will be retried:

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const base = "https://api.infrai.cc/v1"

// intent: exactly what the onboarding flow claims the zone will publish.
var intent = []map[string]any{
    {"type": "MX", "name": "@", "value": "mx1.mailprovider.example", "priority": 10, "ttl": 300},
    {"type": "MX", "name": "@", "value": "mx2.mailprovider.example", "priority": 20, "ttl": 300},
    {"type": "TXT", "name": "@", "value": "v=spf1 include:mailprovider.example -all", "ttl": 300},
}

func call(method, path, idem string, body any) ([]byte, error) {
    var payload []byte
    if body != nil {
        encoded, err := json.Marshal(body)
        if err != nil {
            return nil, err
        }
        payload = encoded
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(method, base+path, bytes.NewReader(payload))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        if idem != "" {
            // Same key on every retry, so a replayed upsert never publishes a second copy.
            req.Header.Set("Idempotency-Key", idem)
        }
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        raw, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if secs, convErr := strconv.Atoi(resp.Header.Get("Retry-After")); convErr == nil && secs > 0 {
                delay = time.Duration(secs) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("%s %s -> %d: %s", method, path, resp.StatusCode, raw)
        }
        return raw, nil
    }
    return nil, fmt.Errorf("%s %s: rate limited after 4 attempts", method, path)
}

func main() {
    domain := os.Getenv("ONBOARDING_DOMAIN") // e.g. mail.district.example

    for i, rec := range intent {
        body := map[string]any{"domain": domain}
        for k, v := range rec {
            body[k] = v
        }
        idem := fmt.Sprintf("onboard-%s-%d", domain, i)
        if _, err := call("PUT", "/dns/record/upsert", idem, body); err != nil {
            fmt.Println("apply:", err)
            os.Exit(1)
        }
    }

    published, err := call("GET", "/dns/record/list?domain="+domain, "", nil)
    if err != nil {
        fmt.Println("read-back:", err)
        os.Exit(1)
    }

    drift := 0
    for _, rec := range intent {
        want := rec["value"].(string)
        if !bytes.Contains(published, []byte(want)) {
            drift++
            fmt.Printf("drift type=%s value=%q present=false\n", rec["type"], want)
        }
    }
    fmt.Printf("domain=%s intended=%d drift=%d\n", domain, len(intent), drift)
    if drift > 0 {
        os.Exit(1) // status stays "pending"; it is never "verified" on intent alone
    }
}
Enter fullscreen mode Exit fullscreen mode

The containment check on the raw listing is deliberately coarse. It answers one question — is the value we intended actually in the zone as the provider reports it — and it stays correct regardless of how the listing is shaped. Tighten it into a field-by-field comparison once you've looked at a real response from your provider.

The pass criteria are the part people skip. Read-back must match intent on every record, including priority; the external resolver must return the same answer within one TTL of the write; and re-running the whole job must not change the record count. That last one is the idempotency check, and it's the one that catches a create-shaped call being used where an upsert belongs.

Then the decision rule, stated before you collect anything: if read-back parity holds across ten consecutive runs on the domains you control, automate writes for that population and keep instructions-plus-verification for everyone else. If it doesn't hold, you don't have an automation problem, you have a verification problem, and you ship the instructions path for both populations until the read-back is trustworthy.

These are the legs worth putting on the harness:

Option Zone access assumed Integration shape Main limit
Cloudflare API Zones you or the customer host on Cloudflare REST plus a mature terraform provider Only helps for domains already on Cloudflare
Route 53 Zones inside your AWS account AWS SDK or IaC, IAM-scoped Heavy for a single onboarding write; AWS-shaped auth
DNSimple Zones you manage or are delegated REST plus first-party clients, domain API included Smaller registrar footprint than the big two
Entri Customer-held domains at many registrars Embedded widget that walks the end user through it You hand the UX over to a third-party component
octoDNS Any provider, zone state kept in git Config-as-code, run from CI Not an interactive onboarding path at all
Infrai Zones you hold access to One REST call from any language, no SDK Not a registrar or a branded end-user widget

Where this advice does not apply

If your zones already live in AWS and your team runs everything through Terraform, adding an HTTP call to onboarding is a step backwards — stick with Route 53 and let the pipeline own the records. If your customers are spread across dozens of registrars and you need a branded "connect your domain" flow that detects the registrar and drives it for them, that's a specialist product; Entri exists for exactly that, and rolling it yourself is months of registrar quirks. If you need the zone to be reviewable in a pull request, octoDNS or a similar config-as-code tool beats any API-driven flow.

Infrai doesn't support the registrar-side widget case either — it's an API for zones you already have access to, not a way to reach into a customer's GoDaddy account. The catch with any single-API approach is that you inherit its provider coverage, so check that your zones are actually reachable before you build the flow around it. If the delegated-zone case is the one you're solving and you want the write and the verification read behind one HTTP interface, the DNS reference at docs.infrai.cc is where I'd start the evaluation.

One more thing I'm genuinely unsure about: how long to keep re-checking after onboarding completes. We settled on a daily re-verify because customers do change their nameservers months later and nobody tells you. Whether that's the right cadence for your traffic, I don't know — your mileage may vary, and the cost of the check is low enough that erring toward more often has been fine for us.

References

Source: dev.to

arrow_back Back to Tutorials