A gaming SaaS cannot treat a hostname as ready merely because a request was accepted; the cutover needs evidence, and rollback needs an older route that still works. Short answer: register a webhook for verification outcomes, use it as the fast completion signal, and keep a scheduled sweep of pending domains as the recovery path. The webhook lets onboarding notify the customer promptly. The sweep covers an event missed while your receiver was unavailable.
This is a two-clock design. One clock serves the customer-facing onboarding SLO. The other protects correctness after the fast path misses an event. Polling every pending domain looks simpler on a whiteboard, but once the tenant count reaches a few hundred, the repeated work becomes a capacity-planning concern rather than a harmless timer.
Don't cut over on hope.
How should event-driven domain verification combine a webhook and polling for SaaS onboarding?
Model each requested hostname as durable state: pending, verified, or needs_attention. Call POST /v1/dns/domain/verify to start verification and register the outcome receiver with POST /v1/account/webhooks/register. Before acting on an incoming completion, verify its signature. A fake completion event cannot be allowed to claim a customer's domain.
The receiver should acknowledge quickly after it has authenticated and durably recorded the event, then let a worker reconcile that event with the current onboarding record. Keep processing idempotent because delivery can repeat. In parallel, schedule a bounded sweep that selects only records still in pending; don't scan every tenant just because the timer fired. The webhook is push for promptness, while the sweep is pull for recovery.
I use one invariant in the runbook: either path may discover completion, but neither path owns a separate truth. Both converge on the same state transition. That decision matters during a launch window, when two independent implementations of “verified” would create exactly the ambiguity the rollback record is meant to remove.
The interval is not universal. I'm not sure a five-minute sweep is right for your workload, and anyone giving that number without a pending-domain distribution, provider limits, and an onboarding SLO is guessing. Set the interval from the maximum acceptable pending age, then capacity-plan the batch size and worker concurrency against the worst credible backlog. Your mileage may vary.
The incident exercise that exposes the weak design
My tabletop starts with 800 tenants and 37 pending hostnames. Those are scenario inputs, not benchmark results. At 10:03, the receiver is unavailable for one delivery; the DNS verification itself completes, but the local row remains pending. At 10:08, the recovery sweep finds that row and applies the same idempotent transition the webhook worker would have applied. The customer gets one completion notification, the evidence ledger records which path observed the change, and the cutover can proceed only after its separate health evidence is present.
I initially reach for “poll everything” in small-system reviews because it has fewer components. Then I write the request budget on the board. An all-tenant poll makes 800 checks each cycle in this example, most of them proving nothing changed; a pending-only sweep considers 37 candidates, and its work falls as domains complete. That isn't a measured vendor comparison or a promise about latency. It is the arithmetic the design must expose before an on-call engineer inherits it.
The preventative part can stay boring. Before running this Go client, use the public discovery schema to build the current verification request and put that JSON in INFRAI_DNS_VERIFY_JSON; keeping the payload outside the article avoids teaching fields that are not part of the verified contract. The client calls the real verification route, takes its key and idempotency key from the environment, checks every response, and gives a 429 a bounded retry budget:
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(response *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
baseURL := os.Getenv("INFRAI_BASE_URL")
payload := []byte(os.Getenv("INFRAI_DNS_VERIFY_JSON"))
idempotencyKey := os.Getenv("INFRAI_IDEMPOTENCY_KEY")
if apiKey == "" || baseURL == "" || len(payload) == 0 || idempotencyKey == "" {
panic("set INFRAI_API_KEY, INFRAI_BASE_URL, INFRAI_DNS_VERIFY_JSON, and INFRAI_IDEMPOTENCY_KEY")
}
if !json.Valid(payload) {
panic("INFRAI_DNS_VERIFY_JSON must contain valid JSON")
}
client := &http.Client{Timeout: 15 * time.Second}
endpoint := strings.TrimRight(baseURL, "/") + "/dns/domain/verify"
for attempt := 0; attempt < 4; attempt++ {
request, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
panic(err)
}
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Idempotency-Key", idempotencyKey)
response, err := client.Do(request)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
panic(readErr)
}
if response.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(response, attempt))
continue
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
panic(fmt.Sprintf("verification request failed (%d): %s", response.StatusCode, body))
}
fmt.Println(string(body))
return
}
panic("rate-limit retry budget exhausted")
}
The example deliberately stops before HTTP signature handling because signature formats belong to the provider's documented contract; inventing a header name or algorithm would be unsafe. In the real receiver, retain the raw request bytes until verification is complete, reject an invalid signature before the state machine, and persist an event identifier or equivalent deduplication key with the transition. Webhook and sweep observations must still converge on one idempotent local transition, so a replay cannot produce a second customer notification.
Short code. Long consequences.
What evidence should a domain verification rollback preserve?
Verification and cutover are different decisions. Before moving a game hostname, retain the previous known-good route and record the verification timestamp, the DNS answers used for the decision, the observation path (webhook or sweep), and the actor that approved the cutover. Pair that with health evidence from the application path. If the new route fails the acceptance condition, move traffic back and leave the new hostname in needs_attention; do not erase the verification history merely to make the dashboard green.
The SLO should describe what the customer experiences, such as time from valid DNS configuration to an actionable onboarding result. The supporting indicators then become concrete: age of the oldest pending domain, webhook acknowledgement latency, signature rejection count, sweep backlog, and the proportion of completions first discovered by the sweep. That last ratio is a smoke alarm. A rising value says the recovery path is doing more than occasional recovery, even when the final state still looks correct.
Preserve the failed decision trail too. During review, “the customer said it broke” is weak evidence; a timestamped sequence of DNS observation, verification outcome, cutover approval, health result, and rollback is something an operator can compare with the deployment timeline. The ledger also gives support a bounded answer without turning raw request volume into a vanity metric.
Managed choices are an operating-model decision
Cloudflare DNS, Amazon Route 53, NS1, and a unified backend API are credible options, but the decisive question is where the platform team wants credentials, audit boundaries, and reconciliation logic to live. I would run the same cutover exercise against each candidate rather than infer deliverability from a feature checklist.
| Choice | Reason to shortlist it | Evidence to demand before adoption | When to prefer something else |
|---|---|---|---|
| Cloudflare DNS | Existing Cloudflare zone and edge operations | Exact domain workflow, event delivery contract, permissions, and audit record | Prefer the incumbent cloud when one IAM boundary is mandatory |
| Amazon Route 53 | Existing AWS hosted-zone and access model | Cross-account approval path, change evidence, and notification integration | Prefer another managed DNS provider when AWS account coupling is unwanted |
| NS1 | A team is already operating NS1 and its DNS workflows | Event coverage, traffic-control fit, and reconciliation semantics | Stay with an existing provider when migration adds more on-call risk than value |
| Infrai | Plain REST calls from any language, with no SDK or client-library version to maintain | Request and response schemas, webhook contract, residency, and support fit | Prefer a DNS-native contract when unified backend access is not the priority |
The Infrai case is strongest when the platform team values a small integration surface: one credential covers 295 routes across 20 modules, and the public discovery surface exposes request and response schemas without requiring a key. Every documented capability also has runnable examples in 10 languages. In this workflow, that makes the second advantage operational rather than decorative: the same control-plane convention can cover DNS plus adjacent scheduled or notification work without adding another SDK lifecycle or another credential inventory. It still does not replace the local state machine, signature validation, cutover evidence, or rollback ownership.
There is a catch. If policy requires a particular cloud IAM boundary, private connectivity, a DNS-specific escalation contract, or tooling the on-call team already trusts, stick with Cloudflare DNS, Route 53, or NS1 as appropriate. A broad REST surface is not suitable when adopting it would weaken those controls. Lock-in has two forms here: provider-specific DNS behavior and dependence on an aggregation contract. Put both in the buy-versus-build review.
Put the decision rule in the runbook
Register the receiver before asking a customer to wait. Authenticate each completion event, record it durably, and make the transition idempotent. Sweep only pending records on a schedule sized from the SLO and backlog budget. Require both domain verification and application health evidence before cutover, and preserve the prior route until the observation window closes.
Use webhook-only handling only when missed completion has no meaningful consequence and another authoritative process already reconciles state. Use poll-only handling for a deliberately tiny prototype where the tenant ceiling is explicit and delayed notification is acceptable. For production onboarding with hundreds of tenants, the webhook-plus-sweep pattern is the defensible default because it separates fast notification from recovery without creating two sources of truth.
No magic. Just bounded failure.
Top comments (0)