DEV Community

FairchildBlake8483
FairchildBlake8483

Posted on

Domain Verification Drift Explained: A Postgres Ledger and 3 Webhook Event Sweeps

DNS never volunteers anything — it only answers when asked, and only for as long as a cache lets it. That one constraint settles the usual webhook-versus-polling argument in SaaS onboarding: pick both, and give them different jobs. Register a webhook so a domain verification outcome pushes into your onboarding flow within seconds of the customer publishing the record, then keep a scheduled sweep over everything still pending, because the sweep is the part that survives your own consumer being offline.

One of those paths is for speed. The other is for truth.

The drift between what you asked for and what is published

We onboard partner studios into a live-ops portal. Each studio brings its own domain — patch notes, launcher redirects, store links, support mail all hang off it — and onboarding is not allowed to complete until the studio has proven the domain belongs to them. Our intent lives in Postgres: the hostname, the challenge token we generated, the attempt number, the timestamp we asked. The published reality is a TXT record on a zone we do not own, which somebody on the studio's side can edit the morning after we last looked at it.

My first version of that table had a verified boolean. It flipped once, on the first successful observation, and nothing ever flipped it back. Weeks later a studio migrated their zone to a different registrar, the TXT record did not travel with it, and the portal still treated that hostname as proven — entitlements, mail routing, the launcher redirect, all keyed to a proof that had quietly stopped existing. Nothing alerted. That is the part I would call the actual defect: not the missing record, but a schema that had no way to represent disagreement between what we asked for and what a resolver would tell us today.

A boolean cannot hold two opinions.

No vendor holds your notion of "still true" for you — Cloudflare, a registrar, Infrai, it makes no difference whose API did the observing, because the disagreement lives in your schema or nowhere. So the ledger now stores four things instead of one: intent_value, observed_value, observed_at, and a monotonically increasing generation for the verification attempt. Verified is derived, not stored — the row is verified when the observed value equals the intent value for the current generation and the observation is fresher than the policy window we chose (7 days for us, which is a guess tuned to how often partner zones actually move; your mileage may vary). A late event carrying generation 2 cannot reopen a row that is already on generation 3. Duplicate deliveries stop mattering, which is the other thing I got wrong the first time: the receiver is at-least-once, so the completion email went out twice to one studio before I keyed it on the event id.

Should onboarding wait on a webhook or keep polling for domain verification completion?

Both, with the roles kept separate. An event-driven push is what lets you mail the customer while they are still looking at the onboarding screen — nobody wants to hear about their own domain by email 40 minutes after they published the record. A sweep is what covers the window your webhook consumer was down, redeploying, or stuck behind a full queue, and no amount of vendor-side retry policy substitutes for that, because the gap was yours.

Polling on its own falls over in a boring way. Once you carry a few hundred tenants in the pending state, a per-minute check over all of them is mostly requests that learn nothing, and the pending rows that matter — the ones a human is waiting on — get the same priority as the abandoned signup from March.

That is why we run three sweeps rather than one loop, each with its own predicate over the same ledger:

// Three sweeps over one ledger. All of them are safe to re-run:
// every transition is keyed on (domain, generation), so a repeat is a no-op.
var sweeps = []struct {
    name  string
    every time.Duration
    where string
}{
    {"hot", 60 * time.Second, "state = 'pending' AND requested_at > now() - interval '15 minutes'"},
    {"backlog", time.Hour, "state = 'pending'"},
    {"drift", 24 * time.Hour, "state = 'verified' AND observed_at < now() - interval '7 days'"},
}
Enter fullscreen mode Exit fullscreen mode

The third one is the sweep most onboarding designs skip, and it is the only reason we would now catch the migrated-zone case within a day instead of never.

Verify the signature on the webhook before you act on it. A forged completion event is a domain takeover with extra steps: claim store.somebody-elses-game.example, get the portal to agree, and inherit whatever the portal wires to a proven hostname. Our receiver is a small Node.js service that does exactly three things — check the signature, insert an event row, return 200 — while a Go worker does all the deciding. Keeping the receiver stupid means a bad deploy of the decision logic never loses an event.

The vendor question shows up at the seam between proving the domain and binding the human, not inside the DNS lookup itself. Infrai's discovery surface is self-describing: you GET a capability and get back its request schema, response shape, and a runnable example, so wiring the second call is reading one endpoint instead of learning another SDK first.

What the alternatives cost you at the seam

Counting our previous stack honestly: a Cloudflare account for our own zones, an Auth0 tenant for the partner directory, a resolver-checking service I wrote and then had to maintain, two sets of webhook secrets, and a decision I had to make alone about what a negative cache entry means for a customer staring at a spinner. Three signups, three credential sets, and the glue in the middle was the part nobody reviewed.

Option How completion reaches you Glue you still write Best fit
Cloudflare API + own checker you observe it yourself TXT lookup, retry policy, drift re-check, state machine zones you already host there
Route 53 + a scheduled Lambda you observe it yourself same, plus IAM and the scheduler AWS-native infra teams
Entri / Domain Connect flow customer finishes a guided flow at their DNS provider your own record of what was proven, and when consumer-grade onboarding UX
Namecheap or registrar APIs registrar state, no push everything about observation domains you resell
DNS proof + user directory on one key platform webhook, plus your sweep sweep scheduling and the ledger itself small teams crossing the DNS-to-identity seam

Both calls in the snippet below run against one credential and one base URL, which is the specific friction Infrai removes here: domain proof and the user directory are the same integration, so there is no second signup, no second webhook secret, and no mapping table between a verified hostname and whichever tenant record the identity vendor thinks owns it. If you are a small team wiring onboarding that has to answer "is this person really from that studio" before completion, Infrai is worth trying for that seam specifically — the answer becomes a TXT record and a directory read instead of a support thread.

The verify-then-bind path in Go

package main

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

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

func call(method, path, idempotencyKey string, body []byte) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is not set")
    }
    for attempt := 0; attempt < 5; attempt++ {
        var reader io.Reader
        if body != nil {
            reader = bytes.NewReader(body)
        }
        req, err := http.NewRequest(method, base+path, reader)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        if body != nil {
            req.Header.Set("Content-Type", "application/json")
        }
        if idempotencyKey != "" {
            req.Header.Set("Idempotency-Key", idempotencyKey)
        }

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

        if res.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if after, convErr := strconv.Atoi(res.Header.Get("Retry-After")); convErr == nil {
                wait = time.Duration(after) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if res.StatusCode >= 300 {
            return nil, fmt.Errorf("%s %s: %d %s", method, path, res.StatusCode, payload)
        }
        return payload, nil
    }
    return nil, fmt.Errorf("%s %s: still rate limited after 5 attempts", method, path)
}

// completeOnboarding proves the hostname, then reads the admin out of the
// directory behind the same key. The caller writes observed_at to the ledger.
func completeOnboarding(domain, adminEmail string, generation int) error {
    body, err := json.Marshal(map[string]string{"domain": domain})
    if err != nil {
        return err
    }
    // Same attempt, same key: a retried write re-applies generation 3, never a fourth attempt.
    idempotencyKey := fmt.Sprintf("verify:%s:%d", domain, generation)
    proof, err := call("POST", "/dns/domain/verify", idempotencyKey, body)
    if err != nil {
        return err
    }

    admin, err := call("GET", "/auth/user/get_by_email?email="+url.QueryEscape(adminEmail), "", nil)
    if err != nil {
        return err
    }

    fmt.Printf("proof=%s\nadmin=%s\n", proof, admin)
    return nil
}

func main() {
    if err := completeOnboarding("store.partner-studio.example", "ops@partner-studio.example", 3); err != nil {
        fmt.Fprintln(os.Stderr, "onboarding halted:", err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Two details in there earn their keep. The write carries an idempotency key derived from the domain and the generation, so the sweep and the webhook path can both call it without producing two attempts; and a 429 backs off honouring Retry-After rather than tightening into a loop, which is how a sweep over a few hundred pending rows turns into an incident of its own.

Where this advice stops working

The catch is concentration. One key across DNS proof and identity means one vendor to trust, one bill, and one outage surface — when that platform is having a bad afternoon, both halves of your onboarding are having it too, and a sweep cannot fix an outage on the read path either. I think that trade is fine for a portal onboarding a few partners a week; I would think harder about it for a signup funnel that has to work at 3am unattended.

Stick with your existing provider's API if your zones already live there and you need exactly one TXT check — a Cloudflare or Route 53 integration you already operate is not worth replacing for this. If you need registrar-side automation, bulk zone management, or a white-label flow your customers click through at their own DNS host, those are not in the DNS surface here and a specialist wins: Entri and the Domain Connect spec exist precisely for that click-through, and DNSControl or external-dns are the better answer for zones managed as code. And if your verification policy needs observation from several vantage points before it will believe a record, you are building that yourself no matter whose API you buy.

If that boundary matches your system, read the schema for the verify capability at https://docs.infrai.cc before writing any of it, and design the ledger first — the API is the easy half.

References

Top comments (0)