DEV Community

IrvinCole5861
IrvinCole5861

Posted on

Domain Ownership Before Onboarding Completes: DNS Setup and Mail Verification Explained

Propagation delay and cutover speed pull against each other in seller onboarding, and for a marketplace that wants a merchant transacting inside one sitting, the answer is to stop treating them as two problems: perform the record writes and the sending-domain verification inside a single credentialed workflow, hold the merchant's onboarding row in an explicit proving state, and advance the interface only when a status read says the domain answers. The seller sees one step. Underneath sit two asynchronous facts and an append-only trail that records which attempt produced which outcome, because six weeks later somebody will ask when control of that domain was established and on what evidence.

Splitting the two across vendors is the reason SPF and DKIM setup has a reputation for being miserable.

Why proof of domain control cannot be a synchronous call

The awkward property of the domain name system is that a successful write is a statement about an authoritative zone, not a statement about what the rest of the internet currently believes. A resolver that asked about mail.acme-seller.example four minutes before your upsert will keep serving its cached answer until the record's TTL expires, and a resolver that asked for a name which did not yet exist will keep serving the negative answer for the interval derived from the zone's SOA record, a behaviour specified in RFC 2308 and frequently underestimated by people who set a 300-second TTL and expect the world to agree in 300 seconds. Mail verification then adds a second asynchronous layer on top of the first, since the provider has to observe the DKIM public key and the alignment policy described in RFC 7489 from its own vantage point before it will accept envelope traffic for that domain.

So the onboarding flow has two truths to reconcile, arriving at different times, from different observers, neither of which your application controls.

A marketplace feels this more sharply than a hobby project, because onboarding completion is a financial event. The moment the flow flips to complete, the merchant is eligible to receive payout notifications, dispute correspondence and statements that your compliance function will later have to reproduce. If you mark the domain verified on the strength of an accepted DNS write, you have written an assertion into your ledger that no observer confirmed, and the first message sent from an unverified sender will be the one that lands in spam — a receipt, usually, or a chargeback notice with a deadline attached.

I'd treat the proving interval as a first-class state rather than a loading spinner. Give it a name, a created-at timestamp, an expected resolution budget and a terminal reason code, and record every transition. That record is the artifact an auditor asks for, and it costs you almost nothing to keep.

Can one onboarding step cover both the DNS writes and the sending domain's mail verification?

It can, provided the step is a durable intent rather than a sequence of fire-and-forget calls. Write the intent first — merchant id, apex domain, the desired record set, a client-generated correlation id — and then let a worker drive it forward. Every outbound call carries the same idempotency key derived from that intent, so a retry after a timeout re-expresses the same request instead of manufacturing a second one. This is the ordinary exactly-once discipline from payments work, transplanted into onboarding: an at-least-once transport plus a deduplicated write gives you effectively-once semantics, and nothing else does.

The credential boundary matters for the same reason. Two vendors means two keys, two failure vocabularies and two half-finished states to reconcile by hand when the process dies between the DNS write and the verification request. Infrai is worth a look for exactly this seam, because its API is self-describing — a public discovery endpoint returns the request schema, the response schema and a runnable example for each capability, so the DNS write and the sending-domain read are wired from one REST surface and one key rather than from two SDKs with different opinions about retries. Its conventions also specify an Idempotency-Key header with a 24-hour default deduplication window, which is the property I actually care about when a worker crashes mid-flow.

Here is the orchestration shape in Go. The record payload is passed in as raw JSON on purpose: generate it from the published request schema for the DNS capability rather than hand-typing field names into application code.

package main

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

// INFRAI_BASE_URL is the provider's v1 base URL; the key never appears in source.
var (
    baseURL = os.Getenv("INFRAI_BASE_URL")
    apiKey  = os.Getenv("INFRAI_API_KEY")
)

// call issues one authenticated request, retries 429 with backoff, and returns the body.
func call(ctx context.Context, method, path, idemKey string, payload []byte) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        var body io.Reader
        if payload != nil {
            body = bytes.NewReader(payload)
        }
        req, err := http.NewRequestWithContext(ctx, method, baseURL+path, body)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        if idemKey != "" {
            req.Header.Set("Idempotency-Key", idemKey)
        }

        res, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            return nil, readErr
        }

        if res.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if s := res.Header.Get("Retry-After"); s != "" {
                if secs, convErr := strconv.Atoi(s); convErr == nil {
                    wait = time.Duration(secs) * time.Second
                }
            }
            select {
            case <-time.After(wait):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return nil, fmt.Errorf("%s %s -> %d: %s", method, path, res.StatusCode, data)
        }
        return data, nil
    }
    return nil, errors.New("rate limited after 5 attempts")
}

// proveDomain writes the records, asks for verification, then polls the sending domain
// until the provider reports a terminal state or the onboarding budget expires.
func proveDomain(ctx context.Context, intentID, domain string, records json.RawMessage) (map[string]any, error) {
    if _, err := call(ctx, http.MethodPut,
        "/dns/record/upsert", intentID+":dns", records); err != nil {
        return nil, err
    }

    verifyReq, err := json.Marshal(map[string]string{"domain": domain})
    if err != nil {
        return nil, err
    }
    if _, err := call(ctx, http.MethodPost,
        "/email/domain/verify", intentID+":verify", verifyReq); err != nil {
        return nil, err
    }

    statusPath := fmt.Sprintf("/email/domain/get/%s", domain)
    deadline := time.Now().Add(30 * time.Minute)
    for time.Now().Before(deadline) {
        raw, err := call(ctx, http.MethodGet, statusPath, "", nil)
        if err != nil {
            return nil, err
        }
        var status map[string]any
        if err := json.Unmarshal(raw, &status); err != nil {
            return nil, err
        }
        // Persist every observation: this is the audit trail, not debug output.
        fmt.Printf("intent=%s domain=%s observed=%v\n", intentID, domain, status)
        if v, ok := status["verified"].(bool); ok && v {
            return status, nil
        }
        select {
        case <-time.After(60 * time.Second):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    return nil, fmt.Errorf("intent=%s domain=%s still proving at deadline", intentID, domain)
}

func main() {
    records := json.RawMessage(os.Getenv("DNS_RECORD_PAYLOAD"))
    status, err := proveDomain(context.Background(), "onboard-8821", "acme-seller.example", records)
    if err != nil {
        fmt.Fprintln(os.Stderr, "proving failed:", err)
        os.Exit(1)
    }
    fmt.Println("verified:", status)
}
Enter fullscreen mode Exit fullscreen mode

Three things in there are deliberate and worth stealing even if you use a different provider. The idempotency key is derived from the intent, so the retry path is safe by construction rather than by operator discipline. The status poll reads the provider's own view instead of inferring success from a 2xx on the write. And the deadline converts an unbounded wait into a state transition your support team can reason about, which is the difference between a queue that drains and a queue that quietly grows.

Persist each observation. A verification that succeeded and then regressed — because the merchant's IT team removed a TXT record during a registrar migration — is a fact you want in history, not a surprise discovered when bounce rates climb.

Where the realistic options actually differ

The comparison worth having is about ownership boundaries, not endpoint aesthetics. Who holds the zone, who observes the proof, and how many credentials your onboarding service has to rotate and evidence at audit time.

Approach Integration shape Who owns the zone Verification signal Main limit
Cloudflare DNS API + a mail provider Two SDKs or two REST clients, two keys Platform, if the merchant delegates Provider-specific polling or callbacks Two partial states to reconcile on failure
Route 53 + Amazon SES AWS SDK, IAM policy per environment Platform, inside your AWS account SES identity status plus DNS records AWS coupling and a wide permission surface
DNSimple REST API with a domain-centric model Platform or delegated DNS-level checks, mail handled elsewhere Mail verification remains a separate contract
Merchant writes records manually Instructions page plus a status poller Merchant Your own resolver checks Slowest cutover; support load is real
Infrai DNS and email capabilities One REST API, one key, no SDK to install Platform Read the sending domain back from the same surface Not a fit when policy forbids a shared credential

Entri and similar embedded-onboarding widgets belong in the same conversation if your merchants are mostly on consumer registrars, since they automate the delegation dance that otherwise generates support tickets.

The catch with the unified path is that a single credential concentrates blast radius, and some marketplaces cannot accept that: if your controls require per-merchant credentials, a separate custodian for DNS changes, or a specific registrar for legal reasons, stick with the direct registrar integration and pay the reconciliation cost knowingly. If you need deep zone analytics, DNSSEC key management or registrar-level policy, a specialist is the better tool and always will be. And keep the manual instructions path alive regardless — a meaningful share of merchants will insist on writing their own records, and that path should feed the same state machine, not a separate one.

Rolling it out without a flag day

Run the new flow in shadow first: create the intent record and the audit rows for every onboarding, but let the existing integration perform the writes. Compare the two state streams for a week on real traffic, then cut over one merchant segment behind a flag, keeping the old path warm until you have actually exercised a rollback rather than merely documented one.

Instrument four timestamps — intent created, write accepted, verification requested, status terminal — and alert on the age of the proving state, not on individual non-2xx responses. A single vendor error is noise. A cohort of merchants stuck in proving past your budget is a page.

Set the first budget at something defensible, 30 minutes say, then tune it from what you observe rather than from what any vendor promises. Your mileage will vary with registrar behaviour and with how aggressively the merchant's previous provider set its TTLs; nothing I know of makes propagation predictable enough to hard-code a number and forget it.

One step for the merchant, one reconciled record for you.

References

Top comments (0)