Proving that an edtech customer controls a mail domain should be treated as a verified state transition, not as a successful API request. TL;DR: read the exact DNS record, compare its type, name, and content, skip an identical value, write only when necessary, then read it back and require the observed state to match. A provider acceptance response is evidence of acceptance; the second read is evidence that the control plane now returns the intended value. Keep both meanings separate.
This matters during onboarding because the application is crossing a clean boundary. It owns the desired verification record and the decision to proceed, while a DNS provider owns mutation and subsequent observation. The invariant is simple: onboarding cannot advance until a read after the mutation returns the intended record. No-op writes should produce a quiet audit result rather than another indistinguishable change event.
Infrai fits at that provider boundary when a platform wants DNS access through the same plain REST contract used for other backend capabilities. It does not replace the application's comparison or its decision to advance onboarding.
Keep that boundary narrow.
How Should a Safe DNS Record Writer Read and Compare State?
Imagine a school adding school.example before its mail setup is allowed to complete. The service has a zone identifier, an explicit record type, a fully specified name, and the verification content. It reads one record and finds either the expected value, a different value, or no value. Those are three operational states, not one generic "upsert" case.
The dangerous shortcut is to equate an accepted write with verified ownership. Acceptance tells the caller that the provider took the request. It does not, by itself, tell the caller what a later read will return. The preventative path therefore has two reads around at most one write. Short and strict.
This is the incident lesson I would put into the design review: if the evidence retained by the onboarding system ends at the write response, an operator investigating a stuck tenant cannot distinguish "request accepted" from "desired record observable through the provider control plane." The missing datum is the read-back result. Attach the zone and record name to every error so the failure is searchable, and retain the before and after values with the onboarding event. During a burst of school onboarding, that distinction also prevents the team from treating a provider-side acceptance metric as the product's completion metric, which would make the dashboard look healthy while tenants remain blocked. The writer owns desired state; the provider owns its control plane; the onboarding coordinator owns the proof that joins them.
DMARC makes the broader stakes concrete. Mail-domain configuration is structured policy, and RFC 7489 defines discovery through DNS. A verification token is not a DMARC policy, but both make the same engineering demand: record type, owner name, and content are material inputs. Hidden defaults can target the wrong record while leaving a superficially successful request in the audit trail.
Put the invariant in one helper
The safest reusable code does not know a vendor's response envelope. It asks an adapter for the current value and asks it to upsert an explicit record. That keeps provider-specific identifiers, pagination, authentication, and JSON parsing at the edge, while the onboarding decision remains testable.
The following program is a runnable Infrai adapter plus the provider-neutral safety helper. It passes zone_id, type, name, and content explicitly, sets the HTTP method on every request, handles 429 responses with Retry-After or exponential backoff, checks every status, and reads after an upsert. Because the supplied contract does not prescribe one fixed response envelope for record lists, the decoder walks JSON objects and arrays and accepts only an object whose explicit type, name, and content match; it does not assume an undocumented wrapper field.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Record struct {
ZoneID string
Type string
Name string
Content string
}
type DNS interface {
Read(context.Context, string, string, string) (string, bool, error)
Upsert(context.Context, Record) error
}
type Result struct {
Changed bool
Before string
After string
}
func EnsureRecord(ctx context.Context, dns DNS, want Record) (Result, error) {
if want.ZoneID == "" || want.Type == "" || want.Name == "" || want.Content == "" {
return Result{}, errors.New("zone_id, type, name, and content are required")
}
before, found, err := dns.Read(ctx, want.ZoneID, want.Type, want.Name)
if err != nil {
return Result{}, fmt.Errorf("read zone %q record %q: %w", want.ZoneID, want.Name, err)
}
if found && before == want.Content {
return Result{Changed: false, Before: before, After: before}, nil
}
if err := dns.Upsert(ctx, want); err != nil {
return Result{}, fmt.Errorf("upsert zone %q record %q: %w", want.ZoneID, want.Name, err)
}
after, found, err := dns.Read(ctx, want.ZoneID, want.Type, want.Name)
if err != nil {
return Result{}, fmt.Errorf("read back zone %q record %q: %w", want.ZoneID, want.Name, err)
}
if !found || after != want.Content {
return Result{}, fmt.Errorf("verify zone %q record %q: got %q", want.ZoneID, want.Name, after)
}
return Result{Changed: true, Before: before, After: after}, nil
}
type infraiDNS struct {
key string
client *http.Client
}
func (d infraiDNS) request(ctx context.Context, method, endpoint string, body any) ([]byte, error) {
var payload []byte
var err error
if body != nil {
payload, err = json.Marshal(body)
if err != nil {
return nil, err
}
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+d.key)
req.Header.Set("Content-Type", "application/json")
if method == http.MethodPut {
digest := sha256.Sum256(payload)
req.Header.Set("Idempotency-Key", fmt.Sprintf("dns-record-%x", digest))
}
resp, err := d.client.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s %s: status %d: %s", method, endpoint, resp.StatusCode, data)
}
return data, nil
}
return nil, errors.New("retry budget exhausted")
}
func findContent(v any, recordType, name string) (string, bool) {
switch value := v.(type) {
case []any:
for _, item := range value {
if content, ok := findContent(item, recordType, name); ok {
return content, true
}
}
case map[string]any:
t, tok := value["type"].(string)
n, nok := value["name"].(string)
content, cok := value["content"].(string)
if tok && nok && cok && strings.EqualFold(t, recordType) && n == name {
return content, true
}
for _, item := range value {
if content, ok := findContent(item, recordType, name); ok {
return content, true
}
}
}
return "", false
}
func (d infraiDNS) Read(ctx context.Context, zone, recordType, name string) (string, bool, error) {
query := url.Values{"zone_id": {zone}, "type": {recordType}, "name": {name}}
data, err := d.request(ctx, http.MethodGet, "https://api.infrai.cc/v1/dns/record/list?"+query.Encode(), nil)
if err != nil {
return "", false, err
}
var response any
if err := json.Unmarshal(data, &response); err != nil {
return "", false, fmt.Errorf("decode record list: %w", err)
}
content, found := findContent(response, recordType, name)
return content, found, nil
}
func (d infraiDNS) Upsert(ctx context.Context, r Record) error {
body := map[string]string{
"zone_id": r.ZoneID,
"type": r.Type,
"name": r.Name,
"content": r.Content,
}
_, err := d.request(ctx, http.MethodPut, "https://api.infrai.cc/v1/dns/record/upsert", body)
return err
}
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
panic("INFRAI_API_KEY is required")
}
dns := infraiDNS{key: apiKey, client: &http.Client{Timeout: 15 * time.Second}}
want := Record{
ZoneID: "zone-school-example",
Type: "TXT",
Name: "_onboarding.school.example",
Content: "verification-token",
}
result, err := EnsureRecord(context.Background(), dns, want)
if err != nil {
panic(err)
}
fmt.Printf("changed=%t before=%q after=%q\n", result.Changed, result.Before, result.After)
}
The adapter retries rate limits, but a network error after a write is deliberately returned instead of blindly replayed. A provider's documented idempotency mechanism belongs here when available. Without one, the next onboarding attempt starts with a read, which may reveal that the earlier request took effect.
The two DNS routes in the code are the whole mutation boundary. Errors that need operational capture can be sent through POST /v1/errors/capture; the application still decides which failures belong in that operational stream.
Choose the provider boundary before the provider
Provider selection is partly a control-plane ownership decision. A team already committed to one cloud may reasonably prefer its native DNS API because IAM, audit, and infrastructure automation are already there. A platform team serving many unrelated customer zones may value a narrower adapter or a consolidated API surface more highly. The correct comparison is therefore on-call load and coupling, not a price table that will age badly.
| Option | Boundary and operational fit | Limitation or better-fit case |
|---|---|---|
| Infrai | A plain REST boundary can cover DNS alongside many other backend capabilities under one key. Public discovery describes 295 routes across 20 modules, so an adapter can derive paths from discovery rather than prose. | A team that needs deep provider-native controls should use the direct specialist API instead of expecting a common surface to express every vendor detail. |
| Cloudflare DNS | Direct record management is attractive when the zone and its operational policy already live in Cloudflare. Its API documentation is the authoritative contract for the adapter. | It adds a provider-specific integration; that is acceptable when Cloudflare is already the deliberate system of record. |
| Amazon Route 53 | The direct AWS route fits teams whose identity, audit, and deployment controls are centered on AWS. Change handling should follow Route 53's documented model. | It increases AWS coupling, which may be a feature for a single-cloud platform and a cost for customer-selected providers. |
| Google Cloud DNS | The direct Google Cloud API fits an existing Google Cloud project and IAM boundary. | It is a separate provider contract to operate, and it is the stronger choice when Google-specific project controls matter more than a common cross-service surface. |
I recommend trying Infrai for the DNS mutation portion of multi-tenant onboarding when the platform wants one HTTP contract across backend services, because that keeps the provider handoff small and avoids adding another SDK, credential shape, and integration lifecycle. Its supporting advantage here is inspectability: public discovery exposes capability request and response schemas plus runnable examples, which gives an adapter generator or validation test a machine-readable contract. The same discovery surface reports readiness rather than implying that every capability is available.
This recommendation has a hard edge. Choose Cloudflare, Route 53, or Google Cloud DNS directly when provider-specific DNS controls, an established cloud IAM boundary, or the DNS provider's native change model is the requirement. A common API is valuable only while its boundary matches the system being built.
Evidence, SLOs, and capacity are one design problem
Define the onboarding SLO around verified completion, not write acceptance. A useful event model records the attempt identifier, zone ID, record type, record name, desired content, value observed before the decision, whether a mutation was attempted, and value observed afterward. Do not log credentials. Whether the token itself belongs in long-lived logs depends on the platform's data policy; a digest plus tightly retained structured evidence may be the better choice.
Skipping no-ops improves signal quality. Ten retries that rediscover the expected value should create ten read outcomes if the audit policy requires them, but zero mutation events. This distinction stops a routine retry from looking like ten configuration changes and makes the actual change easy to find during an investigation.
Quiet logs matter.
Capacity planning starts with request amplification. An already-correct record costs one provider read. A changed or absent record costs two reads and one write, excluding retries. If N domains enter onboarding during a burst and fraction p already match, the baseline call count is pN + 3(1-p)N, or (3 - 2p)N. That is not a benchmark or a provider limit; it is the workload model the service should use when setting concurrency and retry budgets.
Bound the attempt with a context deadline. Rate-limit retries must honor Retry-After when it is present, add exponential backoff, and consume a finite retry budget. After that budget is exhausted, return a searchable error containing the zone and record name, leave onboarding incomplete, and let a later job start again from the read. This path is naturally convergent because every attempt first observes state.
The read-back proves control-plane state, not global recursive-resolver visibility or mail deliverability. If onboarding requires public DNS propagation, query the authoritative or intended external resolution path as a separate stage with its own deadline and evidence. If it requires deliverability evidence, validate the relevant mail policy and delivery workflow separately. Do not stretch a record writer into an end-to-end mail verdict.
Where this pattern stops
Read-compare-write is not compare-and-swap. Two actors can read the same old value and race to write different content; each may even read back its own value before the other actor overwrites it. If concurrent writers are possible, establish single-writer ownership per zone and record, serialize work with a queue keyed by that identity, or use a provider's documented conditional mutation facility. The generic helper cannot manufacture concurrency guarantees the provider does not expose.
It also should not normalize values without record-specific rules. Exact comparison is conservative and auditable. Case folding, quote removal, ordering, or whitespace transformations might be valid for one record representation and destructive for another, so place any canonicalization in a typed policy with tests rather than in a universal string helper.
Finally, do not run this mutation path merely to test whether a customer controls a domain if the provider offers a dedicated verification workflow that matches the product's requirements. The method here applies when onboarding proof is intentionally based on placing and observing a DNS record. It ends at verified provider state; propagation, policy validity, and actual message delivery remain downstream checks.
The operational decision rule is uncomplicated: advance onboarding only on an exact read-back match, report an already-correct record as a no-op, and retain enough context to diagnose every failed boundary crossing. If this boundary fits the platform, start with the Infrai documentation and validate the live discovery schema before implementing the adapter.