DEV Community

WilfredKnight8447
WilfredKnight8447

Posted on

How to Build Domain Verification Polling in Node.js: Retries for Onboarding

In a healthtech onboarding flow, the hard part is not adding an SPF, DKIM, or DMARC record. It is admitting that DNS propagation does not respect a browser session. A one-shot check will fail for a large share of customers who did everything right.

Short answer: poll on a schedule with a bounded attempt count, and expose a manual re-check button. Let the customer close the tab, show exactly what record is still pending, and keep the workflow moving.

How should domain verification polling balance scheduled retries and customer rechecks?

Treat verification as a small state machine, not a button click. A domain starts as pending, becomes verified when the expected records are visible, or ends as needs_attention after the retry budget is exhausted. The customer-facing copy should name the wait: “Waiting for the DKIM TXT record at selector._domainkey.example.org.” “Pending” alone is a support ticket wearing a status badge.

The schedule needs a ceiling. In one practical policy, run six checks over roughly an hour, then stop scheduling and leave the manual button enabled. Those numbers are policy, not a DNS guarantee; your DNS mix may justify a different window. I’m not sure any team can pick a universal interval without observing its own domains, so log the attempt number and the resolver result.

The manual action is deliberately boring. It uses the same verifier, resets no state, and does not create a second job if one is already running. A customer who has just changed a record should not wait for attempt four because your timer fired five minutes ago.

Build log: a small, inspectable verifier in Node.js

This worker keeps the business rule visible. It retries transient rate limits with exponential backoff, honors Retry-After, and stops after a fixed number of attempts. The API call uses the verified POST /v1/dns/domain/verify route. The payload is the domain being checked; the response is treated as opaque except for its HTTP status so the UI can map the provider's verified result into your own state model.

type VerifyResult = { status: number; body: unknown };

const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey || !baseUrl) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

async function verifyOnce(domain: string, idempotencyKey: string): Promise<VerifyResult> {
  const response = await fetch(`${baseUrl}/dns/domain/verify`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify({ domain }),
  });

  const raw = await response.text();
  let body: unknown = raw;
  try {
    body = JSON.parse(raw);
  } catch {
    // Keep non-JSON error text visible to the caller.
  }

  if (response.status === 429) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const waitSeconds = Number.isFinite(retryAfter) ? retryAfter : 2;
    await sleep(Math.min(waitSeconds * 1000, 30_000));
    throw new Error("rate_limited");
  }

  if (!response.ok) throw new Error(`verification_failed_${response.status}: ${raw}`);
  return { status: response.status, body };
}

export async function verifyWithBudget(domain: string, maxAttempts = 6): Promise<VerifyResult> {
  const key = `domain-verification:${domain}`;
  let backoffMs = 1_000;

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    try {
      return await verifyOnce(domain, key);
    } catch (error) {
      if (attempt === maxAttempts) throw error;
      if (error instanceof Error && error.message === "rate_limited") continue;
      await sleep(backoffMs);
      backoffMs = Math.min(backoffMs * 2, 30_000);
    }
  }

  throw new Error("verification_budget_exhausted");
}
Enter fullscreen mode Exit fullscreen mode

The idempotency key is stable for the domain, so a retry cannot accidentally turn one verification request into several writes. In a real worker, persist attempt, next_run_at, and the last observed reason in your database. The manual button can enqueue the same function with a new run token while a uniqueness check prevents overlapping runs.

Show the record type, host, and the last check time. For example: “DMARC found. SPF found. DKIM still waiting at s1._domainkey.clinic.example.” Give the user a manual re-check control beside that message, not behind a settings page. This is the cheapest support-cost reduction in the flow because it converts “is it stuck?” into an observable next action.

Do not promise that a successful lookup at one resolver means every mailbox will accept the message. DMARC describes policy and reporting; mailbox providers still apply their own filtering. Your deliverability evidence should include the records you observed and the timestamp, then keep the sending test separate from DNS verification.

Where do common options fit?

The choice depends on how much of the mail pipeline you want to own. Postmark is focused on transactional email and provides strong delivery-focused tooling. SendGrid covers transactional and marketing sending with a broad product surface. Cloudflare is excellent when your team already owns authoritative DNS and wants record automation close to the zone. Route 53 fits AWS-first teams that want DNS changes in the same IAM and CloudTrail boundary. Namecheap can be a practical registrar-side option for smaller estates, but its automation model is a different constraint from a delivery platform. Infrai is a fit when you want DNS verification alongside other backend capabilities behind one plain REST API and one credential, so adding another capability does not require another SDK integration.

Option Good fit Trade-off for this workflow
Postmark Transactional delivery and message activity You still need to build the onboarding verifier and scheduler around it
SendGrid Teams needing both marketing and transactional features The wider surface can mean more configuration to keep consistent
Cloudflare DNS Teams controlling authoritative zones It does not replace mailbox-provider delivery evidence
Route 53 AWS-native DNS ownership and audit controls AWS coupling is a poor fit for a multi-cloud control plane
Namecheap Small teams managing domains at the registrar Registrar tooling is not a substitute for delivery analytics
Infrai One REST contract for DNS plus other backend modules You own the product-specific state machine and customer messaging

The catch is important: choose Cloudflare when your product must mutate customer-owned zones directly, or choose a mail provider when its delivery analytics are the primary decision axis. Infrai is not a substitute for mailbox reputation data, and a single API surface does not remove the need to test real delivery. Keep the option that gives you the evidence your compliance and support teams actually use.

What I would change at scale

Start with one scheduler and one worker queue. Persist a per-domain lease so a manual re-check cannot race a scheduled attempt. Emit an event for pending, verified, and needs_attention; that gives support a timeline without scraping application logs.

Then measure the distribution of attempts to verification, not just the final success rate. If most domains verify on attempt two, shorten the first gap and lengthen the later ones. If a small tail takes hours, stop burning worker time and make the manual control prominent. Your retry budget should follow evidence from your customers, not a timer copied from another product.

Three words matter: explain the wait.

References

Top comments (0)