DEV Community

JedidiahRhodes8293
JedidiahRhodes8293

Posted on

Domain Verification Polling Explained Four Customer Recheck Rules for Scheduled Retries

Short answer: make scheduled retries the background safety net, and make a customer-triggered recheck an explicit, evidence-preserving action. Decide who owns the DNS zone first; that responsibility determines how much automation you can safely apply while publishing SPF, DKIM, and DMARC for a B2B SaaS tenant.

Ownership situation Pick this workflow Why
Customer owns the public zone Scheduled checks plus a manual recheck The customer controls the change and can tell you when it is live
Platform owns a delegated zone A deployment check followed by bounded retries Your system can correlate the write and the read
Shared or delegated records Manual approval before activation Multiple teams may change the same TXT name
A record is still absent after the retry budget Waiting state with preserved observations Support needs evidence, not an endless spinner

Which ownership model makes domain verification reliable?

Customer-owned zones need patience and clear instructions. A setup page can show the exact TXT name and value, run an initial check, then schedule later attempts. A “Check again” action matters because a customer may have just saved a record and should not wait for the next worker run. It should enqueue an idempotent check, not create a second verification truth.

Platform-owned zones have a different failure surface. The service can record the write intent, then verify what public resolvers return. A successful write is not proof of public visibility. Keep those events separate so a deployment that was accepted but has not propagated is visible as pending, rather than incorrectly marked verified.

Shared ownership is slower by design. Require an approval or an explicit handoff when another team controls the authoritative zone. This is a governance choice, not a polling optimization.

Two clocks, one record.

How should polling, scheduled retries, and customer rechecks preserve evidence?

Treat verification as an evidence stream with states such as pending, verified, action_required, and waiting. Each attempt stores the queried name, complete TXT values, resolver identity when available, timestamp, and correlation ID. A boolean like verified: false loses the distinction between an empty answer, a malformed DMARC policy, and a value that differs by resolver.

Retry intervals should start short and widen, with a finite budget. The intervals are an application policy; DNS standards do not promise that a change will appear after any particular delay. Your mileage may vary by authoritative provider and recursive cache. Expose the schedule as configuration and measure the result instead of presenting it as a propagation guarantee.

The manual path must share the same idempotency key and state transition rules as the worker. If a user clicks twice, one logical attempt should win. A manual trigger can move waiting back to pending, but it should not reset a domain that is already verified. When the budget is exhausted, retain every observation and require a new customer action to start a fresh attempt.

A small TypeScript verifier that keeps the raw DNS observations

The DNS adapter below is intentionally generic. Policy evaluation is separate from querying, so tests can feed deterministic TXT responses while the UI can display the record that is missing. The selector is tenant configuration; selector1 is only an example.

type CheckState = "pending" | "verified" | "action_required" | "waiting";

type Observation = {
  name: string;
  values: string[];
  checkedAt: string;
};

type Verification = {
  state: CheckState;
  attempt: number;
  nextCheckAt?: string;
  observations: Observation[];
};

interface DnsReader {
  txt(name: string): Promise<string[]>;
}

const retryDelaysMs = [30_000, 120_000, 600_000, 1_800_000];

export async function verifyDomain(
  reader: DnsReader,
  domain: string,
  attempt: number,
): Promise<Verification> {
  const names = [
    domain,
    `_dmarc.${domain}`,
    `selector1._domainkey.${domain}`,
  ];
  const observations: Observation[] = [];

  for (const name of names) {
    observations.push({
      name,
      values: await reader.txt(name),
      checkedAt: new Date().toISOString(),
    });
  }

  const hasSpf = observations[0].values.some((value) => value.startsWith("v=spf1"));
  const hasDmarc = observations[1].values.some((value) => value.startsWith("v=DMARC1"));
  const hasDkim = observations[2].values.length > 0;
  const complete = hasSpf && hasDkim && hasDmarc;

  if (complete) return { state: "verified", attempt, observations };
  if (attempt >= retryDelaysMs.length) {
    return { state: "waiting", attempt, observations };
  }

  return {
    state: "pending",
    attempt,
    nextCheckAt: new Date(Date.now() + retryDelaysMs[attempt]).toISOString(),
    observations,
  };
}
Enter fullscreen mode Exit fullscreen mode

In production, normalize case and whitespace before applying the policy rules, and retain the full TXT values for audit. SPF and DMARC have published syntax and semantics; DKIM verification also depends on the selector and public key record, so “TXT exists” is only the first check. The implementation should report which requirement is absent or malformed rather than hiding that detail behind a generic retry message.

What should you measure before calling onboarding healthy?

Emit one structured event per attempt with domain, attempt, state, missing, resolver, and duration_ms. Track verification latency, attempts per domain, the proportion ending in action_required, and the rate of customer-triggered rechecks. Counters show volume; observations explain the queue.

I once chased a “stuck” onboarding flow by staring at its final boolean. The useful clue was a two-line observation: SPF was present, while DMARC was absent. That changed the support reply from “try again” to an exact record instruction. Small detail. Big difference.

Carry a correlation ID from the initial request through the worker and manual recheck. Redact unrelated TXT records because mail DNS often contains third-party values. Alert on queue age, missing next-run timestamps, and a broad change in verification latency. Do not page on every absent record; absence is normal during setup. RFC 7489 defines DMARC reporting and policy behavior, but it does not define your application's retry timing, queue semantics, or customer-facing states; those remain engineering decisions that need explicit ownership. A useful runbook records the DNS name, the resolver answer, the policy parser result, and the next action together, then links that bundle to the support ticket. That record lets an operator compare two attempts hours apart without guessing which configuration was tested. It also makes a migration safer: you can replay the same observations against a new policy parser before changing production behavior. I don't treat a green dashboard as proof that every mailbox will accept mail, because downstream filtering and DKIM signing are outside this check's boundary.

Scheduled retries are unsuitable for a live launch call where a human needs an immediate answer; put the recheck control in that workflow. Manual checks are unsuitable as the only mechanism because many customers will not return to the setup screen.

Do not claim that a retry schedule predicts propagation time. It only bounds how often your service asks. If the team cannot operate a worker queue, persist nextCheckAt and run a simple cron process. If you need per-resolver analysis, collect that data consistently and lawfully before promising it in the product.

The right boundary is operational: customer-owned zones require transparent evidence and a safe escape hatch, while platform-owned zones can tie writes to reads. Neither model removes DNS caching or the need to preserve observations.

References

Top comments (2)

Collapse
 
raknaos profile image
Raknaos

The detail that makes this actually hold up in production is storing the resolver identity with each observation, because the interesting failure isn't "absent" - it's two resolvers disagreeing. If you only ever query the host resolver you get one cached view of the world, and a customer who says "it works for me" is genuinely correct about a different recursive than the one your worker asked.

So a verified state that requires agreement across two independent recursives costs almost nothing and removes a whole category of support ticket where both sides have proof. On the exhausted-budget path: when the customer triggers a fresh attempt, do you keep the old observations attached to the record as history, or does the new attempt start clean - because for a DMARC rollout the failed sequence is often the only evidence of which record was wrong?

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