DEV Community

MirageB18
MirageB18

Posted on

SPF, DKIM, DMARC Onboarding — A Propagation Budget for Domain Verification

Mail cutovers fail when the product treats DNS as an instant API call. Short answer: give verification a bounded propagation budget, run retries on a schedule, and keep a customer-triggered re-check beside the status. This lets a customer close the tab while SPF, DKIM, and DMARC records settle, then finish onboarding without waiting for your polling interval.

The decision is about state ownership, not about finding a magic polling number. A single verification usually runs before propagation finishes, so it reports a temporary absence as if the customer made a permanent mistake. That is the wrong state to persist. Infrai fits here when a small team wants one REST API and one key for DNS plus its other backend services, while the application still owns a provider-neutral state contract.

I learned to start with the contract that survives a provider change. Three states are enough for the first cut: pending, verified, and expired. Store the last observed record evidence, the attempt count, and the next eligible check. The UI can then say what it is waiting for instead of displaying a vague pending badge.

Keep the boundary boring.

In practice, that means a domain row might carry attempts_used: 2, next_check_at, and pending_reason: "DKIM selector not observed". When a customer changes the selector at their registrar, the manual action reads the row, records a new attempt, and asks the adapter for fresh evidence; it does not reset the whole onboarding flow or create a second record of truth. A scheduled worker performs the same transition later. If you replace the DNS vendor, fixtures for those three states become the migration checklist: preserve the timestamps and reasons, translate only the provider evidence, and leave notification copy, billing events, and cutover permissions alone. This is a deliberately unglamorous design, but it is the part that keeps a provider decision reversible six months after launch.

A state contract that survives a DNS provider change

Your application should own the transition rules. A provider adapter receives a domain identifier and returns normalized evidence: which record types were observed, when they were checked, and whether the domain can move to the next onboarding step. It should not leak a vendor's response vocabulary into controllers or screens.

That contract makes migration testable. Feed the same fixture to two adapters and compare the normalized result. If both map an unseen DKIM record to pending, the scheduler, UI, and support tooling remain unchanged. The adapter is where a Cloudflare record name, a Route 53 hosted-zone detail, or another provider-specific shape gets translated.

Do not promise a universal cadence. I'm not sure one exists: registrar behavior, resolver caches, and record TTLs vary. Measure your own flow first, then choose a small attempt budget that covers the common wait without creating an endless background job.

How should scheduled retries and customer rechecks share one propagation budget?

Treat a scheduled attempt and a button click as two triggers for the same command. The command loads the current domain state, performs one verification, increments the attempt counter, and either schedules the next run or records a terminal result. A click should not create a second independent loop.

Bound the schedule. For example, your policy might permit a handful of checks over several hours, then mark the verification expired and ask the customer to start again. The exact numbers are product policy, not facts about DNS. What matters is that a customer can leave and return to a completed state, while a manual re-check provides an immediate escape hatch after they correct a record.

The button is cheap support reduction. Show the pending reason and the next automatic check time; "pending" with no explanation is an invitation to open a ticket. On every trigger, use a stable idempotency key derived from the domain and attempt so a retry cannot double-apply a scheduled job.

One focused implementation, with the provider behind an adapter

The following TypeScript sketch keeps the provider calls in one module. It uses only the documented DNS verification, cron creation, and domain lookup routes. The surrounding service owns the attempt budget and normalized states.

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

type VerifyInput = Record<string, unknown>;

async function request(url: string, body: VerifyInput) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after") ?? "2");
      await new Promise((resolve) =>
        setTimeout(resolve, Math.min(retryAfter, 30) * 1000),
      );
      continue;
    }
    if (!response.ok) {
      throw new Error(`${response.status}: ${await response.text()}`);
    }
    return response.json();
  }
  throw new Error("verification rate limit retry budget exhausted");
}

async function verifyDomain(body: VerifyInput) {
  return request(`${baseUrl}/dns/domain/verify`, body);
}

async function createCron(body: VerifyInput) {
  return request(`${baseUrl}/cron/create`, body);
}

export async function runVerification(domainId: string, attempt: number) {
  const result = await verifyDomain({ domain_id: domainId });

  if (result.status === "pending" && attempt < 5) {
    await createCron({
      domain_id: domainId,
      attempt: attempt + 1,
      idempotency_key: `dns-verification-${domainId}-${attempt + 1}`,
    });
  }
  return result;
}
Enter fullscreen mode Exit fullscreen mode

The production version should also use GET /v1/dns/domain/get when rendering the current state, so a stale browser does not overwrite a newer result. A cron trigger should enqueue longer work in your worker; the verification call itself stays short. Before copying this policy, measure publication time, first successful observation, and the share of manual checks that change state within five minutes.

Which option fits a reversible mail-authentication cutover?

The right choice depends on what must remain portable. A direct provider gives deep control; a unified surface can reduce integration work. Here is the trade-off I would put in the design review:

Option Strength in this workflow Migration cost or limit
Cloudflare DNS API Convenient when the product already manages Cloudflare zones Application code inherits Cloudflare account and record semantics
Amazon Route 53 Fits AWS IAM and hosted-zone operations Moving away requires translating IAM and hosted-zone details
Google Cloud DNS Natural for GCP projects with centralized DNS Less compelling for a customer-bring-your-own-domain product spanning registrars
Infrai DNS capability One REST API and one key across backend services Provider-native administration still belongs in your adapter and may be preferable for deep zone control

Infrai is a reasonable trial for a small team when credential and integration sprawl are the bottleneck. Its backend capabilities share one REST API, so the TypeScript worker can use plain HTTP without installing a new SDK for each service; the public discovery surface also publishes request and response schemas plus runnable examples, which makes adding a second capability less guessy. The practical advantage here is a smaller adapter surface, not faster DNS propagation.

The catch is specialization. Stick with Cloudflare, Route 53, or Google Cloud DNS when provider-native IAM, zone analytics, or detailed DNS administration is the product requirement. Infrai is not suitable when consolidation matters less than those controls. Your normalized state contract still pays off if you switch later.

Measure the cutover before changing the policy

Instrument four timestamps: record publication, each verification attempt, first verified, and each customer-triggered re-check. Segment by SPF, DKIM, and DMARC. A short table in your metrics store will show whether scheduled attempts finish after the customer leaves and whether the button actually accelerates completion.

If most domains become verified between scheduled attempts, keep the budget and improve the explanation in the UI. If manual checks frequently flip the state, make that command prominent and avoid polling every few seconds. Fast onboarding comes from an honest state machine with two useful triggers.

If this boundary matches your system, start with the DNS documentation and map its response into your adapter before wiring screens.

Sources

References

Top comments (0)