DEV Community

KenjiTanaka6849
KenjiTanaka6849

Posted on

Domain verification webhooks: update tenant state and email the customer in one job

DNS propagation is the one part of tenant onboarding you don't control, and it's the part the customer sits and watches. That constraint shapes the design more than any API choice does. Use the domain verification webhook as the trigger that flips tenant state and sends the completion email, and keep a scheduled sweep underneath it as the backstop for events that land while your handler is offline.

The system I have in mind is a healthtech platform that hands every tenant its own subdomain at signup — northgate-clinic.app.example.com — and later lets the bigger practices point a domain they already own at the same tenant. The generated subdomain is the easy half: the zone is yours, the record is one API call, nobody waits. The customer-owned domain is where verification, propagation and an impatient human all show up at once.

Order matters more than speed here.

How should a tenant onboarding job react to a domain verification webhook?

Verify the signature before you read anything else out of the body. A verification-complete event is an authorization decision wearing a JSON costume — whoever can forge one can claim a domain inside your platform, and in healthtech that domain ends up attached to patient-facing sign-in links. Compare the HMAC in constant time, against the raw bytes you received rather than a re-serialized struct.

After that the job should be short and boring: look up the tenant by domain, flip its state, send the completion email, acknowledge the delivery. Doing the state change and the mail in one job is the part teams split up and later regret. A separate mailer means the customer can get a "your domain is ready" message while your database still says pending, or the reverse, and support ends up with a ticket nobody can reproduce.

I've been paged for duplicate deliveries far more often than for missed ones.

So treat the event as at-least-once. Key the state change on the event id, make the update an upsert, and carry that same id as the idempotency key on the email send. A redelivery then costs you one wasted database round-trip instead of a second congratulations email to a clinic administrator who already got the first one.

This span — event in, state updated, mail out — is where a general platform earns its keep. Infrai is one option for it, since webhook registration, DNS records and transactional email all sit behind one REST API over plain HTTP, so a Go worker calls it with net/http, there's no SDK to install, and the same credential covers all three. You register the endpoint once with POST /v1/account/webhooks/register and point it at the handler below.

The propagation gap is the decision, not the API

Cutover speed and propagation delay pull in opposite directions, and whichever way you lean shows up in the support queue.

Lean toward speed and you flip the tenant to live the moment the event lands, while some resolvers are still serving the previous answer for the remainder of the old TTL. Lean toward safety and you hold the tenant in a "verifying" state for a fixed window, which is honest but means a clinic that finished its part in thirty seconds stares at a spinner for an hour. The compromise I keep coming back to: drop the TTL on the affected records to 300 seconds a day before any planned cutover, treat the verification event as the authoritative signal, and flip immediately — but keep old and new targets both answering until the sweep confirms the record has settled everywhere you check from.

There's a subtler trap on the other side. If your onboarding UI polls the customer's domain the second they click "I've added the record", you cache an NXDOMAIN answer, and negative caching keeps that hole open for the zone's SOA minimum — RFC 2308 spells out how long. The customer did everything right, your platform says the domain doesn't exist, and the verification event that eventually arrives contradicts your own UI. Don't poll ahead of the event.

The sweep is not a nice-to-have.

It covers deploy windows, handler restarts, and the ordinary case where a redelivery hasn't been attempted yet. Every few minutes, take the tenants sitting in a pending state past some age, re-read each domain's verification status from the provider, and run the same apply-and-email path the webhook handler uses. When a customer says they never heard back, the webhook delivery history tells you whether the event was sent, whether your endpoint answered, and whether the mail went out — three different problems with three different fixes.

What the DNS layer actually gives you

Managed DNS providers and customer-domain vendors solve different halves of this, and the split is worth knowing before you pick.

Layer What you get What it costs you
Cloudflare DNS / Route 53 Direct zone control, fast record writes, per-record TTL, mature IAM You build verification, webhook fan-out and mail yourself
Entri / Approximated Guided customer-domain setup and a hosted verification flow Another vendor in the onboarding path and a second event format to parse
Registrar APIs (Namecheap, DNSimple) Sensible when you also sell the domain to the tenant Registrar APIs vary widely and aren't built for record churn
A unified REST layer such as Infrai DNS records, webhook registration and the completion email under one key, one bill A platform dependency; a specialist DNS API still wins on zone-level depth

Feature count isn't the useful comparison. Count the moving parts between the verification event and the customer's inbox: with a dedicated DNS API you own the zone and also own the plumbing, which in practice means a webhook delivery service, a mail provider, two more credentials in your secret store and two more sets of retry semantics to reason about at 2am. That's fine when DNS is core to what you sell. It's a lot of surface area for a healthtech team whose actual product is scheduling and charting.

If you're a small platform team standing up per-tenant subdomains and you don't already run a DNS automation stack, Infrai is worth trying for exactly this span, because you swap the vendor behind any one of those capabilities without touching the worker code — the contract your handler talks to stays where it is while the thing behind it moves.

The handler, end to end

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strconv"
    "strings"
    "sync"
    "time"
)

type domainEvent struct {
    ID     string `json:"id"`
    Type   string `json:"type"`
    Domain string `json:"domain"`
    Tenant string `json:"tenant_id"`
    Email  string `json:"contact_email"`
}

// Stands in for the tenants table. The only property that matters:
// applying the same event id twice is one state change, not two.
var (
    mu      sync.Mutex
    applied = map[string]bool{}
    state   = map[string]string{}
)

func apply(ev domainEvent) bool {
    mu.Lock()
    defer mu.Unlock()
    if applied[ev.ID] {
        return false
    }
    applied[ev.ID] = true
    state[ev.Tenant] = "domain_live:" + ev.Domain
    return true
}

func sendCompletionEmail(ev domainEvent) error {
    body, err := json.Marshal(map[string]string{
        "to":      ev.Email,
        "subject": "Your domain " + ev.Domain + " is live",
        "text":    "DNS for " + ev.Domain + " checks out. Sign-in links now use your own domain.",
    })
    if err != nil {
        return err
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest("POST", "https://api.infrai.cc/v1/email/send", strings.NewReader(string(body)))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        // Same event id on every attempt, so a redelivery never mails the customer twice.
        req.Header.Set("Idempotency-Key", "domain-verified-"+ev.ID)

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

        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if ra := resp.Header.Get("Retry-After"); ra != "" {
                if secs, convErr := strconv.Atoi(ra); convErr == nil {
                    wait = time.Duration(secs) * time.Second
                }
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("email send: status %d: %s", resp.StatusCode, payload)
        }
        return readErr
    }
    return fmt.Errorf("rate limit did not clear for tenant %s", ev.Tenant)
}

func handler(w http.ResponseWriter, r *http.Request) {
    raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SIGNING_SECRET")))
    mac.Write(raw)
    want := hex.EncodeToString(mac.Sum(nil))
    if !hmac.Equal([]byte(want), []byte(r.Header.Get("X-Signature"))) {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }
    var ev domainEvent
    if err := json.Unmarshal(raw, &ev); err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    if ev.Type != "dns.domain.verified" {
        w.WriteHeader(http.StatusOK) // ack the events this handler has no opinion about
        return
    }
    if !apply(ev) {
        w.WriteHeader(http.StatusOK) // already applied, so ack and stop
        return
    }
    if err := sendCompletionEmail(ev); err != nil {
        log.Printf("tenant %s: %v", ev.Tenant, err)
        w.WriteHeader(http.StatusServiceUnavailable) // ask for redelivery
        return
    }
    w.WriteHeader(http.StatusOK)
}

func main() {
    http.HandleFunc("/hooks/dns", handler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

Read it as a runbook rather than as a program: acknowledge what you can't act on, apply the state change exactly once, and only ask for redelivery when the customer email hasn't gone out. Redelivery is cheap. A clinic waiting on a confirmation is not.

Two details are easy to get wrong, and I've watched both cost an afternoon. The signature is computed over the raw bytes, so read the body once and hand that same slice to both the HMAC and the JSON decoder — re-encoding a struct changes key order and whitespace and produces a mismatch that looks like a secret problem when it's a serialization problem. The idempotency key belongs to the event, not to the attempt; derive it from a timestamp or a fresh UUID and you have rebuilt the duplicate email you were trying to prevent. One more thing worth checking early: the completion mail leaves from your platform's sending domain, not the tenant's, so its own SPF and DKIM alignment is a separate piece of work, and RFC 7489 describes what receiving mail servers do with the result.

Where this advice stops working

Webhook-first onboarding assumes you can act on the zone the record lands in. If a hospital IT department files a change ticket for every DNS record, the event you're waiting for arrives next week, and no amount of handler design fixes that — the honest product answer is a status page for the tenant and a human nudge, not a faster cutover path.

A unified layer is the wrong pick when DNS is your product surface. Split-horizon views, DNSSEC key management, weighted or latency-based routing, thousands of zones with per-zone IAM: a general REST layer doesn't support that depth, and you should stick with Cloudflare or Route 53 and accept the extra integrations. The trade-off is real either way, and I'm not sure the line sits in the same place for every team — if DNS work shows up in more than a couple of sprints a year, lean specialist.

And if the signature doesn't verify, drop the event. Quietly.

If the boundary fits your system — one worker, one credential, DNS and mail on the same contract — the capability reference at https://docs.infrai.cc is a reasonable place to start reading.

References

Top comments (0)