When a media company moves watch.example to a new stack, the dangerous moment is not adding a TXT record. It is declaring the hostname ready while resolvers still disagree. My rule for 2026 onboarding systems is simple: let an event-driven signal wake the workflow, but make a bounded polling check the authority for completion, and keep a reversible cutover state until DNS observations converge.
How should SaaS onboarding handle domain verification completion in Node.js?
Short answer: accept webhooks as hints, poll verification state until a terminal result, and only then advance the tenant. A webhook can be delayed, duplicated, or lost; a poll can be stale. Combining both gives fast reaction without making either transport your source of truth.
The onboarding record needs more than verified: true. Store the hostname, the exact challenge value, the record type, the last observation time, and a state such as awaiting_dns, checking, verified, expired, or failed. Keep a cutover_generation (a monotonically increasing integer) so a late event from an old attempt cannot reopen a newer attempt.
DNS has no single global “done” instant. Authoritative servers publish a record, recursive resolvers cache it, and different users can observe different answers until TTLs expire. DMARC (RFC 7489) adds another reason to avoid a binary UI: policy and reporting records are published in DNS, but their operational effect is evaluated by receivers over time. Verification should therefore mean “the required observation policy passed,” not “one lookup returned the expected string.”
Ship slowly.
The three-phase state machine for a hostname cutover
I use three phases because they map to actions an operator can reverse.
In prepare, create the challenge and ask the customer to publish it. Record the intended DNS name exactly, including whether the provider expects _acme-challenge.media.example or a token under the bare hostname. Normalize case for comparison, but preserve the original value for audit. A common failure is checking media.example after the customer correctly added _verify.media.example; the two strings look close in a ticket and are completely different DNS names.
In observe, accept a verification webhook if the integration offers one, then enqueue an immediate status read. The handler should be idempotent: deduplicate by event ID when available, otherwise by (tenant, hostname, challenge, generation). The worker polls with backoff and a deadline, for example 15 seconds, 45 seconds, 2 minutes, then every 5 minutes for 30 minutes. Those numbers are policy, not a promise about propagation speed; your mileage may vary across resolvers and negative-cache conditions.
In commit, mark the generation verified only after the read meets all checks: record type, owner name, value, and any required propagation threshold. Emit an internal domain.verification.completed event from your database transaction. The cutover controller can then lower traffic, switch the hostname, and watch health checks. If checks fail or the deadline expires, move to expired or failed with the diagnostic context intact. Do not silently retry forever.
Here is a small Node.js worker using a generic verifier interface. The interface is deliberately provider-neutral; adapt its getStatus and webhook adapter to the service you selected.
import asyncio
import random
TERMINAL = {"verified", "expired", "failed"}
async def wait_for_completion(verifier, tenant, hostname, generation):
delays = [15, 45, 120, 300, 300, 300]
for delay in delays:
status = await verifier.get_status(
tenant=tenant, hostname=hostname, generation=generation
)
if status["state"] in TERMINAL:
return status
await asyncio.sleep(delay + random.uniform(0, 3))
return {"state": "expired", "reason": "verification deadline reached"}
The snippet is Python because the polling policy is easier to read without hiding it behind framework code; the same state machine fits a Node.js queue worker. Keep network timeouts shorter than the poll interval, and persist the last cursor or observation timestamp so a restarted worker does not hammer the verifier.
Webhook or polling: what actually changes operationally?
A webhook minimizes detection latency. It is useful when a customer is staring at an onboarding screen and expects progress within seconds. It also introduces delivery work: signature validation, replay protection, retries, dead-letter handling, and a reconciliation job. Treat the callback as an invalidated cache entry, not as proof.
Polling is predictable and easy to replay. It costs requests and can lag behind a real change, especially when thousands of tenants reach the same five-minute interval. Add jitter, cap concurrency, and stop polling terminal states. A status endpoint that returns pending should not be interpreted as failure; retain the reason and next attempt time.
The practical design is hybrid. A webhook schedules a read now; a periodic sweeper finds records whose next_check_at is overdue. Both paths call the same transition function guarded by the generation number. That makes duplicate callbacks harmless and gives you recovery when a callback never arrives.
DNS edge cases that break “verified” dashboards
CNAME flattening, split-horizon DNS, and DNS providers that append a zone name can all produce a record that looks right in one tool and wrong from the public Internet. Query authoritative nameservers during diagnostics, then test through at least two recursive resolvers. Capture the response code, answer set, and TTL. An NXDOMAIN observed immediately after publication may be negative-cached; it is a reason to wait, not evidence that the customer typed the record incorrectly.
Wildcards deserve their own test. A wildcard answer can satisfy a casual lookup while the exact owner name is absent. Likewise, TXT values can be split into quoted chunks; compare the DNS presentation after canonicalizing whitespace according to your verifier's rules, rather than comparing a copied console string.
I once debugged a 40-minute “stuck” onboarding where the record was present at the authoritative server. The worker was reading a recursive resolver that still held the old negative answer. The useful fix was not a faster retry. It was recording which resolver answered, honoring its TTL, and showing the operator the next check time. In a production investigation I would also retain the query name, type, response code, answer section, authority section, and request timestamp, because a later support ticket may arrive after the cache has refreshed and the original evidence will otherwise be gone. That evidence lets you separate a customer typo from propagation, a resolver policy issue, or a cutover generation that was superseded while the request was in flight. It also makes a rollback reviewable: you can show which observation authorized the switch and which observation caused the system to hold traffic on the old origin.
Keep evidence.
A reversible rollout for media hostnames
Start with one low-traffic tenant and a hostname that can be abandoned without changing the customer’s primary domain. Keep the old origin serving while the new origin passes TLS, application health, and domain verification checks. During the observation window, compare request error rate and mail authentication reports; DMARC aggregate reports can reveal alignment changes after a sender or hostname move (see RFC 7489).
Use a feature flag keyed by cutover_generation. A rollback then flips traffic to the previous generation while leaving the verified DNS record untouched. After the window closes, mark the old generation retired and stop its poller. This is faster to reason about than deleting records during an incident.
The catch is that a hybrid design is not suitable when you cannot operate a durable queue, signature verification, and a reconciliation worker. For a tiny internal tool, polling alone may be the better choice. Stick with a managed workflow that owns those delivery mechanics when your team cannot staff them; the trade-off is slower or less customizable cutovers. Choose based on the failure you can recover from, not on the word “real-time.”
Top comments (0)