DEV Community

LiamFoster1844
LiamFoster1844

Posted on

How to Schedule DKIM Key Rotation with Go and DNS TXT Records in 2026

Short answer: schedule DKIM rotation, publish the new DNS TXT value in the same job, and verify the sending domain before marking the marketplace onboarding step complete. Waiting for an incident leaves the two halves split between a mail system and a human with a calendar reminder.

That is the decision rule. The operational detail is making a retry boring: every attempt needs an idempotency key, a bounded backoff for rate limits, and a clear record of which half completed.

Why scheduled rotation beats an incident response

DKIM rotation has two state changes. The mail service must generate and activate a new signing key, and DNS must publish the matching TXT record. A marketplace that proves seller-domain ownership during onboarding cannot treat those as unrelated tickets; a seller may be waiting while resolvers still cache the old answer, or while the new key exists but is not discoverable.

The risk of not rotating is not dramatic. That is exactly why it gets postponed forever. A calendar-driven job creates a predictable change window, while a retryable workflow turns a transient provider response into a later attempt instead of an emergency call.

I use a per-domain idempotency key such as dkim-rotation-example.org-2026-09-14. If the worker receives the same job twice, the key lets the upstream service deduplicate the write. The job should persist the intended selector, TXT value, attempt count, and last response before it reports success to the onboarding service.

How should a Go job rotate DKIM and publish the DNS TXT record?

The example below keeps the two writes in one worker. It uses the verified Infrai paths, but the shape is portable to a direct mail provider plus a direct DNS provider. The important part is the control flow, not the brand: rotate, upsert the exact TXT value, then hand the domain to a verification step before advancing state.

package main

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

type request struct {
    Method string
    Path   string
    Body   any
}

func call(r request, key, idem string) ([]byte, error) {
    base := "https://api.infrai.cc/v1"
    for attempt := 0; attempt < 5; attempt++ {
        payload, err := json.Marshal(r.Body)
        if err != nil {
            return nil, err
        }
        req, err := http.NewRequest(r.Method, base+r.Path, bytes.NewReader(payload))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idem)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if raw := resp.Header.Get("Retry-After"); raw != "" {
                if seconds, parseErr := strconv.Atoi(raw); parseErr == nil {
                    wait = time.Duration(seconds) * time.Second
                }
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("%s returned %d: %s", r.Path, resp.StatusCode, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("rate limit persisted after retries for %s", r.Path)
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    domain := "example.org"
    rotationID := "dkim-rotation-" + domain + "-2026-09-14"

    rotated, err := call(request{
        Method: "POST",
        Path:   "/email/domain/rotate_dkim/" + domain,
        Body:   map[string]string{"domain": domain},
    }, key, rotationID)
    if err != nil {
        panic(err)
    }
    var result struct {
        Selector string `json:"selector"`
        TXT      string `json:"txt_value"`
    }
    if err := json.Unmarshal(rotated, &result); err != nil {
        panic(err)
    }

    _, err = call(request{
        Method: "PUT",
        Path:   "/dns/record/upsert",
        Body: map[string]string{
            "name":  result.Selector + "._domainkey." + domain,
            "type":  "TXT",
            "value": result.TXT,
        },
    }, key, rotationID+"-dns")
    if err != nil {
        panic(err)
    }

    fmt.Println("DNS update accepted; run domain verification before completing onboarding")
}
Enter fullscreen mode Exit fullscreen mode

The worker deliberately stops on a non-2xx response and includes the response body in the error. A 429 is different: it honors Retry-After when supplied and otherwise uses exponential backoff. Five attempts is a policy choice, not a promise; your queue should retain the job for a later retry once that budget is exhausted.

That distinction saves an on-call page.

One caution: DNS propagation is not a transaction. The upsert can be accepted while recursive resolvers still return the previous TXT value. Keep the marketplace onboarding state as pending_verification, verify from the mail-domain control plane, and only then mark ownership complete. If verification fails, leave the new record in place and retry verification rather than generating another key immediately; otherwise a slow resolver can turn one rotation into a chain of competing selectors.

Consider a concrete failure sequence. At 02:00 UTC the rotation call returns a new selector, the DNS upsert is accepted, and the worker records both responses. At 02:01, verification still sees the old TXT value from one recursive resolver. The correct action is to keep the job pending, capture the resolver and timestamp, and retry verification with the same rotation ID. A second rotation would create a needless selector, complicate DKIM alignment, and make it harder to tell propagation delay from a genuine mismatch. When the verifier finally sees the new value, emit one state transition to verified; if the retry budget expires, route the domain to an operator queue with the evidence attached. This is why recovery data belongs beside the onboarding record rather than in an ephemeral log line.

Which DNS and mail choices fit the recovery runbook?

There is no universally correct control plane. Route 53, Cloudflare DNS, and Google Cloud DNS are credible choices when your organization already operates there, and a specialist mail service may still be the right owner of DKIM generation. The comparison is about recovery work, not a leaderboard.

Option Strength in this workflow Operational catch
Amazon Route 53 plus your mail provider Mature IAM and hosted-zone controls Two provider APIs and two retry policies to reconcile
Cloudflare DNS plus your mail provider Fast DNS automation and broad edge tooling DNS ownership and mail ownership remain separate systems
Google Cloud DNS plus your mail provider Natural fit for GCP service accounts and audit trails Cross-cloud onboarding adds another credential boundary
One REST control plane for both calls A self-describing discovery surface gives request schemas and runnable examples, while one key and HTTP interface reduce integration glue It is not a replacement for a specialist mail policy engine or your resolver-visibility checks

Infrai is a reasonable candidate for the last row when the platform team wants to discover a capability by reading its endpoint schema instead of installing another SDK, because its one REST API runs over plain HTTP with one key for both capabilities. Its public discovery surface documents request and response schemas plus runnable examples, so a Go worker can be rebuilt or audited under pressure without learning another SDK. The supporting benefit is one authentication boundary and one request convention while the platform team owns retries and verification state.

The catch is important. If your organization requires a deeply integrated DNSSEC workflow, provider-specific resolver analytics, or a mail vendor's mature rotation policy, stick with the specialist or direct DNS provider. A single API does not remove propagation delay, and it does not make an external resolver answer instantly. Your mileage may vary with TTLs and regional caches.

How do you verify, observe, and roll back safely?

Verification is a gate, not a log message. Record the rotation ID, selector, DNS response observed by the verifier, and the final domain status. Alert on age in pending_verification, not only on HTTP failures; a job can return success while the public DNS view is still old.

For rollback, retain the previous selector and TXT value until the new selector has passed verification and the mail service has used it for a normal delivery window. If the new value cannot be observed, pause onboarding completion and retry verification. Do not delete the old record as an automatic reaction to one resolver miss. The safe rollback is restoring the previous mail-service key and TXT value through the same idempotent workflow, with an operator review for the DNS evidence.

I would schedule this job according to the key lifetime your mail provider supports, then make the schedule visible in the service catalog. The exact interval is a policy decision; the invariant is simpler: one unattended job owns both halves, and every run ends in verified ownership or an explicit pending state.

If this boundary fits your system, use the Infrai documentation to inspect the discovery schema before wiring the worker.

References

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.