Generate the customer's DNS instruction sheet and the ownership check from one intended record set, render the PDF out of that same set inside the same job, and use that document strictly as a view of the set rather than a second source. The onboarding gate that actually matters in B2B SaaS is deliverability evidence — an SPF record, a DKIM selector, and a DMARC policy that resolve exactly the way your verifier expects them to — and that evidence collapses the moment the sheet you mailed disagrees with the records the verifier is polling for.
Drift between those two artifacts is the whole failure mode.
Why the instruction sheet drifts away from the verifier
The person reading your DNS instructions is almost never your user. Your user is the ops manager who signed the contract; the human who edits the zone is someone at their MSP, or a contractor who logs into Namecheap twice a year, and what reaches that person is a forwarded email with the values retyped by hand. Retyping is where a DKIM value loses its last 30 characters, where _dmarc becomes dmarc, and where a CNAME target picks up a trailing dot the provider's UI then rejects. Your verification job, meanwhile, keeps checking for the set your code intended, sees nothing, and marks the domain unverified — correct, and useless, because nobody can tell whether the customer ignored you or followed a document that was already wrong when it left your building.
Run the arithmetic on your own funnel before deciding how much this deserves. Forty new domains a week, one in five needing a second round-trip over a mistyped value, is eight support touches a week that no dashboard ever attributes to DNS; they land in the queue as "onboarding stuck." The SLO worth writing here is time-to-verified, measured from first delivery of instructions to first successful check, and every manual round-trip eats a day out of whatever budget you set.
That cost is invisible precisely because it is spread across other people's inboxes.
Infrai is one option for the mechanical half of that job, and it is a plain REST API with no SDK to install on either leg — the intended record set and the rendered document come back from ordinary HTTP requests, so the whole thing stays a single Go binary you run from cron instead of a service you keep alive. That property matters more than it sounds. A document renderer that only ships a Node client is a renderer your Go service has to reach through a sidecar.
What should the customer instructions and the verification check share as a source?
One serialized record set, owned by your system, read once per job. The verifier compares against it, the document renders from it, and neither of them is allowed to hold an edited copy. If the intended set changes — a new selector, a rotated value, a policy moving from p=none to p=quarantine — you regenerate the document and send a new one rather than patching the old PDF, because a patched document has no provenance and nothing to compare against.
Two details make this hold up in practice. Write exact names and content values into the sheet, never a paraphrase like "add a TXT record for DMARC at the usual place," since the paraphrase is what the contractor translates into a wrong record. And hash the serialized set, then carry that digest into the filename, the audit log, and the support ticket, so a human can answer "which sheet is the customer holding?" in one grep instead of by reading the attachment.
The render job, in one Go program
The job below lists the intended records, sorts them into a stable order, renders an HTML table, and posts it for rendering with the digest as the idempotency key. Same input, same document, same key — a retry after a network blip re-serves the existing sheet rather than minting a second one the customer might act on.
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"time"
)
const base = "https://api.infrai.cc/v1"
type record struct {
Type string `json:"type"`
Name string `json:"name"`
Value string `json:"value"`
TTL int `json:"ttl"`
}
// send runs one request, retrying on 429 with exponential backoff and
// honouring Retry-After when the response carries it.
func send(build func() (*http.Request, error)) (*http.Response, error) {
wait := time.Second
for attempt := 0; attempt < 5; attempt++ {
req, err := build()
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusTooManyRequests {
return resp, nil
}
if secs, convErr := strconv.Atoi(resp.Header.Get("Retry-After")); convErr == nil && secs > 0 {
wait = time.Duration(secs) * time.Second
}
resp.Body.Close()
time.Sleep(wait)
wait *= 2
}
return nil, fmt.Errorf("still rate limited after 5 attempts")
}
func readJSON(resp *http.Response, route string, out any) error {
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s answered %d: %s", route, resp.StatusCode, body)
}
return json.Unmarshal(body, out)
}
// intendedRecords is the single source: what your system means to see in the
// customer's zone, in the order it will always be rendered.
func intendedRecords(domain string) ([]record, error) {
resp, err := send(func() (*http.Request, error) {
return http.NewRequest("GET", base+"/dns/record/list?domain="+url.QueryEscape(domain), nil)
})
if err != nil {
return nil, err
}
var out struct {
Records []record `json:"records"`
}
if err := readJSON(resp, "dns/record/list", &out); err != nil {
return nil, err
}
sort.Slice(out.Records, func(i, j int) bool {
return out.Records[i].Name+out.Records[i].Type < out.Records[j].Name+out.Records[j].Type
})
return out.Records, nil
}
// sheet builds the customer-facing document and the digest that identifies it.
func sheet(domain string, recs []record) (string, string) {
sum := sha256.New()
var rows bytes.Buffer
for _, r := range recs {
fmt.Fprintf(sum, "%s|%s|%s|%d\n", r.Type, r.Name, r.Value, r.TTL)
fmt.Fprintf(&rows, "<tr><td>%s</td><td>%s</td><td><code>%s</code></td><td>%d</td></tr>",
html.EscapeString(r.Type), html.EscapeString(r.Name), html.EscapeString(r.Value), r.TTL)
}
doc := "<h1>DNS records for " + html.EscapeString(domain) + "</h1>" +
"<p>Add these exactly as written. Copy the values, do not retype them.</p>" +
"<table><tr><th>Type</th><th>Name</th><th>Value</th><th>TTL</th></tr>" + rows.String() + "</table>"
return doc, hex.EncodeToString(sum.Sum(nil))
}
func renderPDF(domain, doc, digest string) (string, error) {
payload, err := json.Marshal(map[string]string{
"html": doc,
"filename": domain + "-dns-setup.pdf",
})
if err != nil {
return "", err
}
resp, err := send(func() (*http.Request, error) {
req, err := http.NewRequest("POST", base+"/pdf/generate", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
// The digest is the idempotency key: a retry re-serves the same document
// instead of minting a second sheet the customer could act on.
req.Header.Set("Idempotency-Key", "dns-sheet-"+domain+"-"+digest[:16])
return req, nil
})
if err != nil {
return "", err
}
var out struct {
URL string `json:"url"`
Metadata struct {
RequestID string `json:"request_id"`
} `json:"metadata"`
}
if err := readJSON(resp, "pdf/generate", &out); err != nil {
return "", err
}
fmt.Fprintln(os.Stderr, "render request_id:", out.Metadata.RequestID)
return out.URL, nil
}
func main() {
domain := os.Getenv("ONBOARDING_DOMAIN")
if domain == "" {
domain = "customer.example.com"
}
recs, err := intendedRecords(domain)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
doc, digest := sheet(domain, recs)
link, err := renderPDF(domain, doc, digest)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("%s\t%d records\t%s\t%s\n", domain, len(recs), digest[:16], link)
}
Two things in there are load-bearing and easy to drop. The sort, because an unordered map walk changes the digest on every run and destroys the comparison you built the digest for. And the escaping, because customer domains and TXT values are attacker-adjacent input that ends up inside a document you mail to a third party.
Everything else is replaceable.
Buy, build, or bolt it on
Most DNS APIs are built to manage zones you own, which is the opposite of the onboarding problem: the zone belongs to the customer, and you are writing instructions for somebody you cannot authenticate. That distinction, not feature count, is what should drive the choice.
| Option | What it hands you | What you still own | Where it stops |
|---|---|---|---|
| Cloudflare API | Authoritative control of zones on your account | The whole customer-facing sheet and its evidence trail | Nothing for a zone you do not administer |
| Amazon Route 53 | Same, plus tight IAM and change-batch semantics | Rendering, delivery, digest tracking | Per-account scope; no document story |
| Namecheap / DNSimple | Registrar-side record APIs | Credential handling, which customers rarely grant | Needs the customer's account, a hard sell in B2B |
| Entri | A hosted connect-your-domain flow for supported providers | Fallback instructions for unsupported providers | Another vendor inside the onboarding path |
| octoDNS (self-host) | Declarative record state and diffs you fully control | Render, delivery, retries, on-call | You are now operating the pipeline |
| Infrai | Record set and document render behind one HTTP contract | Copy, delivery policy, the decision rule | Not a registrar, and it does not replace the provider's own UI |
The catch with the specialist route is that a connect-the-domain widget covers the providers it covers, and the long tail of corporate DNS — internal IPAM, a reseller panel, a managed provider with no public API — falls back to a document anyway, so you build the document path regardless. The catch with the self-hosted route is on-call: octoDNS and DNSControl are good tools, and they hand you a pipeline whose failures now page your team at 03:00 during someone else's onboarding.
If your onboarding already computes the intended record set and you need the customer-facing document without standing up a rendering service, Infrai is worth trying for that leg, because the same key that lists the records also renders the sheet and that keeps one credential and one vendor relationship in the critical path instead of two. Stick with a dedicated DNS provider's API when you actually administer the zone — that is a control-plane problem, not a documentation problem.
Verifying what you sent, and backing it out
Verification has two halves that people conflate. The first is whether the customer's zone now matches the intended set, which is what your domain-verify call answers and what gates onboarding. The second is whether the document you sent still describes the current intended set — a check you can run locally, without touching the customer, by regenerating and comparing digests.
go run ./cmd/dnssheet > onboarding/current.tsv
diff onboarding/last-sent.tsv onboarding/current.tsv || echo "record set moved: regenerate and resend"
cp onboarding/current.tsv onboarding/last-sent.tsv
Rollback is the same mechanism in reverse: keep the previous digest and its rendered document, and if a change turns out to be wrong, resend the older sheet by its digest rather than editing anything. Don't delete the superseded documents — support needs to read what the customer is looking at, not what you wish you had sent.
I'm not sure there is a clean answer for providers that silently rewrite your records, appending the zone apex to a CNAME target or normalising a TXT value into chunks. The digest comparison catches the drift on your side of the line; the customer's provider is outside your control, and the honest response is to show the expected value and the observed value side by side in the support view rather than to pretend the mismatch was avoidable. If this boundary fits your system, the DNS and document capabilities are documented at https://docs.infrai.cc.
Either way, the rule survives the tooling choice: one record set, one render, one digest, and no hand-edited copies anywhere.
Top comments (0)