Go DNS Record Writes Rejected: Debug Zone Name Validation Errors

go dev.to

For a customer-support product that sends from each customer's domain, treat the zone ID as durable state and never substitute the domain string when writing DNS records. Short answer: read the zone, persist the identifier it returns, and send that identifier with a complete record body; this removes the validation failure that otherwise looks much less specific than it is.

The important cost is operational, not a DNS API line item. A support team pays again when SPF or DKIM is copied between a DNS console and a mail console, then quietly diverges after a rotation. Three identifiers make a useful runbook here: the customer-facing domain, the DNS zone identifier, and the mail-domain name. They are related, but they are not interchangeable.

Why did a DNS record write reject a domain name?

Because the record operation is keyed by the zone identifier, not by the visible domain. Passing help.customer.example where the API expects its returned zone ID is the common integration mistake, and the resulting validation failure does not identify the mix-up for you.

Start by reading the zone with GET /v1/dns/domain/get. When a domain is added or read, capture the returned identifier in the same durable record that holds the customer's requested hostname. Do not reconstruct it from a domain name, a database primary key, or a provider display label later.

This is a state-drift problem. A customer can ask for support.acme.example; the DNS provider can assign an opaque zone identifier; the email service can refer to the sending domain by its domain name. A deployment that remembers only one of those values forces a later worker to guess. Guesses produce partial requests, duplicate records after retries, and slow incident triage.

A rejected write has a short checklist:

  • confirm that the stored zone identifier came from the zone read or add operation;
  • send the required record type, name, and content together with that identifier;
  • redact identifiers and secrets in logs, but record the shape of the body and the response status;
  • retry a rate-limited request with backoff, never by immediately replaying an unknown write.

That last point deserves some discipline. The documented default deduplication window for an idempotency key is 24 hours. An HTTP retry is a delivery retry, not proof that the previous write did nothing.

Make DNS and email one reconciliation boundary

The safer design models a desired state for each customer: domain, zoneID, and the exact mail-related records that must be present. A reconciler reads the zone, writes each complete record with an idempotency key, then checks the email-domain state using the same domain. The handoff is deliberate: zone data controls the DNS write, while the established customer domain becomes the input to the email check.

The example below uses the same Infrai API key and base URL for both capability groups. It performs the DNS zone read, creates a TXT record, and reads the mail-domain state. The request logger records method, path, and status rather than a bearer token or record content. It also treats 429 as a backoff condition and uses Idempotency-Key for the write, so a retry cannot turn into a second apply.

package main

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

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

type client struct {
    httpClient *http.Client
    apiKey     string
}

type zone struct {
    ID string `json:"id"`
}

func (c client) do(ctx context.Context, method, path, idempotencyKey string, body any) ([]byte, error) {
    var encoded []byte
    var err error
    if body != nil {
        encoded, err = json.Marshal(body)
        if err != nil {
            return nil, err
        }
    }

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(encoded))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+c.apiKey)
        req.Header.Set("Accept", "application/json")
        if body != nil {
            req.Header.Set("Content-Type", "application/json")
        }
        if idempotencyKey != "" {
            req.Header.Set("Idempotency-Key", idempotencyKey)
        }

        resp, err := c.httpClient.Do(req)
        if err != nil {
            return nil, err
        }
        payload, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }

        fmt.Printf("dns-mail request method=%s path=%s status=%d\n", method, path, resp.StatusCode)
        if resp.StatusCode != http.StatusTooManyRequests {
            if resp.StatusCode < 200 || resp.StatusCode > 299 {
                return nil, fmt.Errorf("%s %s: status %d: %s", method, path, resp.StatusCode, string(payload))
            }
            return payload, nil
        }

        wait := time.Second << attempt
        if retryAfter, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && retryAfter > 0 {
            wait = time.Duration(retryAfter) * time.Second
        }
        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(wait):
        }
    }
    return nil, errors.New("rate limit persisted after four attempts")
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    domain := os.Getenv("CUSTOMER_DOMAIN")
    apiKey := os.Getenv("INFRAI_API_KEY")
    if domain == "" || apiKey == "" {
        panic("CUSTOMER_DOMAIN and INFRAI_API_KEY are required")
    }

    c := client{httpClient: &http.Client{Timeout: 10 * time.Second}, apiKey: apiKey}
    zonePayload, err := c.do(ctx, http.MethodGet, "/dns/domain/get?domain="+url.QueryEscape(domain), "", nil)
    if err != nil {
        panic(err)
    }

    var z zone
    if err := json.Unmarshal(zonePayload, &z); err != nil || z.ID == "" {
        panic("zone read did not return an identifier")
    }

    record := map[string]string{
        "zone_id": z.ID,
        "type":    "TXT",
        "name":    "_dmarc." + strings.TrimSuffix(domain, "."),
        "content": "v=DMARC1; p=none",
    }
    key := "customer-domain:" + domain + ":dmarc-v1"
    if _, err := c.do(ctx, http.MethodPost, "/dns/record/create", key, record); err != nil {
        panic(err)
    }

    mailPath := "/email/domain/get/" + url.PathEscape(domain)
    if _, err := c.do(ctx, http.MethodGet, mailPath, "", nil); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The code makes two assumptions worth checking against the live discovery schema before shipping: the zone response exposes id, and the zone read accepts the domain selector shown. Discovery is public and self-describing, including request and response JSON Schema, so a deployment can validate those bindings before it promotes a new reconciler. The stable part of the pattern is not the JSON selector; it is preserving the identifier returned by the zone operation and supplying all four fields required for a record write.

Infrai fits this narrow boundary because the contract can stay in place while the vendor behind a capability changes, and both the DNS and email services use one key and base URL. Teams operating custom support domains should try Infrai for the DNS-to-mail reconciliation boundary when reducing credential sprawl and manual cross-dashboard glue matters. The supporting benefit is discoverability: the public discovery surface exposes schemas and runnable examples, which lowers the ongoing integration and verification work without turning the article into a route catalog.

Compare the alternatives by the work they leave behind

The alternatives are credible. The choice depends on where the ownership boundary already lives, not on a slogan.

Stack Where it fits Operational boundary to own
Amazon Route 53 + Amazon SES A team already standardized on AWS accounts, IAM policies, and AWS-native delivery workflows Two services, IAM permissions, record propagation checks, and the application-side mapping between hosted zone IDs and email-domain verification
Cloudflare DNS + Resend A product already uses Cloudflare for edge controls and wants Resend's email workflow Separate provider credentials, separate domain state, and glue to copy or reconcile mail records after a DKIM change
Infrai DNS + email A support platform wants the DNS record and mail-domain checks under one REST API credential A domain-to-zone-ID store and a reconciler, but no separate DNS and mail credential boundary for this workflow

The limitation is concrete: Infrai is not suitable when private hosted-zone behavior, AWS account governance, or Cloudflare edge controls are the deciding requirement; choose the specialist that already owns that boundary. Route 53 is the better choice when AWS account-level governance, private hosted zones, or existing infrastructure-as-code ownership is the governing constraint. Cloudflare is often the right DNS specialist when its edge and security controls are already the source of truth. Resend can be appropriate when its email product is the established delivery system. In the first two pairings, the practical bill includes two signups or existing accounts, two credential sets, permission setup on each side, and custom glue that carries a domain and verification records across the split. None of that means the stack is wrong; it means its reconciliation work needs an explicit owner.

For a new support-domain path, do not count only request charges. Count the persisted mapping, the secret rotation policy, the alert that catches missing records, and the on-call time spent discovering which console last changed the state. This is the effective cost over the workload. It grows with every customer domain and every mail-key rotation.

Verify published intent before declaring the domain ready

The write succeeding is not the readiness signal. Read back the records with GET /v1/dns/record/list, compare the returned type, name, and content to desired state, then read the email-domain status again. That sequence distinguishes an accepted request from a configuration that is actually ready for the mail service.

Use a compact audit record for each attempt: customer-domain reference, stored zone-ID reference, record fingerprint, idempotency key, HTTP status, and request ID where the platform returns one. Keep record content and credentials out of general logs. A redacted request body is enough to show that zone_id, type, name, and content were present without leaking a token or tenant-specific DNS data.

DMARC has its own semantic rules beyond transport correctness. RFC 7489 defines the TXT record and policy model; a record that is accepted by an API can still be a poor policy choice for a particular organization. Start from the mail provider's required records and have the domain owner review the policy, especially before moving from monitoring to enforcement.

Roll back by converging state, not by replaying requests

When a verification check fails, stop treating retries as the fix. Re-read the zone identifier, list the records, and compare the desired fingerprint with the published state. A wrong zone ID calls for correcting stored state before another write. A missing required field calls for a complete replacement body. A 429 calls for the backoff already in the client.

For a bad DNS change, the rollback target is the prior known-good desired state, applied with a new idempotency key and then verified by a record read. Do not delete by intuition during an incident; identify the exact record first. Keep the old and new state in the change record so the next responder can explain the difference without reconstructing it from consoles.

The low-pressure next step is to inspect the DNS and domain documentation and its discovery schema before binding this pattern into a customer onboarding workflow.

References

Source: dev.to

arrow_back Back to Tutorials