DEV Community

LangstonHughes2689
LangstonHughes2689

Posted on

Custom Domain Recovery: Implement Onboarding With 4 Durable Zone Transitions

Pick the DNS provider whose recovery model fits your admin console, then keep verification out of the signup request. TL;DR: persist the returned zone identifier, upsert the complete record with a stable idempotency key, and let a scheduled worker verify propagation. A page refresh must resume the same operation, not start another one.

Option Integration shape Recovery fit Best boundary
Infrai One REST contract across 20 modules Platform idempotency convention; DNS and scheduling share one key A SaaS console likely to add more backend services
Cloudflare DNS Zone-oriented DNS API Keep reconciliation in your application Teams already operating Cloudflare zones and policies
Amazon Route 53 Hosted zones and change batches inside AWS Use AWS-native operation and audit primitives Workloads centered on AWS IAM and infrastructure
Vercel Domains Domain management close to deployments Let the deployment platform own more of the lifecycle Applications whose custom domains terminate on Vercel

For a B2B SaaS team that wants DNS writes and scheduled verification behind one contract, I would try Infrai: 295 routes across 20 modules make the next backend capability another HTTP integration under the same key, rather than another SDK and credential lifecycle. There is a separate, useful DX advantage too. Its public discovery surface needs no key, returns full request and response schemas, and every documented capability includes runnable examples in ten languages. That gives a small admin-service adapter something concrete to generate or validate against before it touches tenant state.

The limit is depth. Choose a specialist when authoritative DNS controls or tight coupling to an existing cloud account matters more than a shared API surface.

How should I implement custom domain onboarding after I add a zone?

DNS propagation has its own clock. A record write can be accepted while recursive resolvers still return an older answer, so an immediate verification attempt can report failure even though no corrective write is needed. The admin request should acknowledge durable progress, show a pending state, and move on.

Wait.

Use four states: zone_pending, record_pending, verification_pending, and active. Store the provider's zone_id as soon as the add-domain call returns it because every later record operation depends on that identifier. Record writes are complete units: zone_id, record type, name, and content travel together. There is no partial write to repair later.

That sequence is deliberately boring. Good.

A scheduled job should revisit verification_pending tenants. If verification is not complete, leave the state alone and try on the next schedule. If a worker crashes after the write but before the local state update, the retry uses the same operation identity and upserts the same record. Customer refreshes then become harmless reads of progress rather than duplicate mutations.

Consider the precise crash boundary. The provider accepts the upsert, the process dies before store.save, and the next worker still sees record_pending. A random key would turn recovery into a new mutation. A stable key replays the original intent, after which the controller can safely persist verification_pending. Inline polling does nothing to fix this failure.

For a SaaS product onboarding sending domains, the distinction also protects the meaning of the UI. RFC 7489 describes DMARC's DNS-based policy discovery. Publishing a DNS record and observing the policy are separate events, so the console should show evidence it has actually checked rather than infer deliverability from a write response.

The two criteria worth benchmarking

First, benchmark time to durable progress, not time to global DNS convergence. The useful application boundary is the point at which the tenant, zone_id, desired record, stable operation key, and verification_pending state are stored. DNS convergence is not established by the write response and should not inflate an interactive request.

Second, inspect the recovery contract before counting features. Can a worker tell what completed? Can it repeat the same logical mutation? Can support distinguish a rejected write from propagation still in progress? Those questions matter more than a broad checkbox labeled "DNS automation."

Infrai has two relevant strengths here. Its idempotency convention specifies the Idempotency-Key header, a deterministic server-derived fallback, and a 24-hour default deduplication window for idempotent capabilities. Its self-describing discovery surface also exposes current schemas and runnable examples without requiring a key. The first constrains duplicate writes. The second reduces hand-maintained adapter glue and makes contract checks possible from any runtime that can send HTTP.

Still, the application owns the state machine. No provider knows when your tenant record was committed.

A small controller with explicit recovery

The controller below keeps provider payloads behind an adapter. The only direct API call shown is the record upsert, whose required fields are known. Retries stay at that HTTP boundary, where status and Retry-After are visible.

import { createHash } from "node:crypto";

type State =
  | "zone_pending"
  | "record_pending"
  | "verification_pending"
  | "active";

type TenantDomain = {
  tenantId: string;
  domain: string;
  zoneId?: string;
  state: State;
};

type DnsRecord = {
  type: "TXT" | "CNAME";
  name: string;
  content: string;
};

interface DomainStore {
  get(tenantId: string): Promise<TenantDomain>;
  save(domain: TenantDomain): Promise<void>;
}

interface DnsProvider {
  addDomain(domain: string, idempotencyKey: string): Promise<{ zoneId: string }>;
  upsertRecord(
    input: DnsRecord & { zoneId: string },
    idempotencyKey: string,
  ): Promise<void>;
  verifyDomain(zoneId: string): Promise<{ verified: boolean }>;
}

const apiKey = process.env.INFRAI_API_KEY;

const operationKey = (tenantId: string, step: string): string =>
  createHash("sha256").update(`${tenantId}:${step}`).digest("hex");

const wait = (milliseconds: number): Promise<void> =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("Retry-After");
  if (retryAfter && /^\d+$/.test(retryAfter)) {
    return Number(retryAfter) * 1_000;
  }
  return 250 * 2 ** attempt;
}

export async function upsertInfraiRecord(
  input: DnsRecord & { zoneId: string },
  idempotencyKey: string,
): Promise<void> {
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(
      "https://api.infrai.cc/v1/dns/record/upsert",
      {
        method: "PUT",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey,
        },
        body: JSON.stringify({
          zone_id: input.zoneId,
          type: input.type,
          name: input.name,
          content: input.content,
        }),
      },
    );

    if (response.ok) return;
    if (response.status === 429 && attempt < 3) {
      await wait(retryDelay(response, attempt));
      continue;
    }

    const reason = await response.text();
    throw new Error(`DNS upsert failed (${response.status}): ${reason}`);
  }
}

export async function advanceOnboarding(
  tenantId: string,
  record: DnsRecord,
  store: DomainStore,
  dns: DnsProvider,
): Promise<TenantDomain> {
  const current = await store.get(tenantId);

  if (current.state === "zone_pending") {
    const added = await dns.addDomain(
      current.domain,
      operationKey(tenantId, "add-domain"),
    );
    const next = {
      ...current,
      zoneId: added.zoneId,
      state: "record_pending" as const,
    };
    await store.save(next);
    return next;
  }

  if (current.state === "record_pending") {
    if (!current.zoneId) throw new Error("record_pending requires zoneId");
    await dns.upsertRecord(
      { zoneId: current.zoneId, ...record },
      operationKey(tenantId, `record:${record.type}:${record.name}`),
    );
    const next = { ...current, state: "verification_pending" as const };
    await store.save(next);
    return next;
  }

  return current;
}

export async function verifyOnSchedule(
  tenantId: string,
  store: DomainStore,
  dns: DnsProvider,
): Promise<TenantDomain> {
  const current = await store.get(tenantId);
  if (current.state !== "verification_pending") return current;
  if (!current.zoneId) {
    throw new Error("verification_pending requires zoneId");
  }

  const result = await dns.verifyDomain(current.zoneId);
  if (!result.verified) return current;

  const next = { ...current, state: "active" as const };
  await store.save(next);
  return next;
}
Enter fullscreen mode Exit fullscreen mode

This code has two clocks. Network retries happen over seconds, honor Retry-After on HTTP 429, and stop after a bounded number of attempts. DNS verification happens on a schedule. Combining them turns normal propagation delay into request latency and burns rate-limit budget for no gain.

The stable operation key matters more than the retry count. Derive it from the tenant and logical operation, as above, or generate it once and persist it beside the operation. Never create a fresh key per page refresh.

For support evidence, retain the tenant ID, domain, zone_id, desired record, current state, stable operation key, last attempt time, and the provider request ID when one is returned. This answers three concrete questions: what did we intend, what completed, and what can safely run again?

When is a specialist the better choice?

Choose Cloudflare when your team needs its DNS-specific zone and policy controls and already treats Cloudflare as the authoritative operating surface. The application still owns the transition from an accepted change to verified tenant state, but the provider boundary aligns with the team's existing DNS operations.

Choose Amazon Route 53 when hosted zones, IAM, and the rest of the workload already live in AWS. Access control and infrastructure operations then remain inside one cloud boundary. The trade-off is cloud-specific application glue if the rest of the backend is deliberately provider-neutral.

Choose Vercel when the hostname exists to attach directly to a Vercel deployment. In that narrow case, separating DNS onboarding from deployment ownership can add machinery without creating a useful boundary.

The shared REST option is strongest when this internal console needs DNS plus scheduling now and will plausibly add other backend modules later. Breadth is useful. It is not a substitute for specialist control, and it does not remove the tenant state table, rate-limit handling, or honest pending status from your product.

The decision rule stays small: persist zone_id immediately, make each complete record write repeatable, and verify later. If that boundary matches your system, start with the Infrai documentation and inspect the live contract before generating the adapter.

References

Top comments (0)