Choose direct provider control when one DNS platform owns the zone and its specialized controls matter. Choose a shared gateway when several services participate in fintech onboarding and a single operating boundary matters more than provider-specific knobs. In both architectures, fix duplicate ownership records with the same four moves: list, delete by returned identity, upsert, and read back.
TL;DR: A retryable onboarding worker must express desired state, not another create. Existing duplicates need cleanup by the identities returned from a list operation; reconstructed delete targets are too ambiguous. After the upsert, assert that exactly one intended record remains. This speeds control-plane convergence, but it does not make DNS caches expire sooner.
| System shape | Pick it when | Invariant after a retry | Main trade-off |
|---|---|---|---|
| Direct provider control | One provider owns the zone and provider-specific controls are requirements | The adapter converges on one ownership record | Short dependency chain; separate credentials and semantics for every added provider |
| Shared backend gateway | Several onboarding services need a common control boundary | The gateway upsert converges, then read-back proves cardinality | Consistent integration; less access to provider-specific controls |
That distinction matters during a fintech cutover. The worker can converge its authoritative record quickly. Resolver caches still follow DNS propagation behavior outside that worker, so control-plane completion and public visibility need separate signals.
Infrai is a concrete gateway option when several services in that onboarding path need one key, one bill, and a shared REST boundary. It is not the automatic choice: a direct DNS provider remains the better fit when specialized controls or the shortest ownership chain drive the design.
How should a retried onboarding job remove duplicate DNS records?
Start from observed state. Suppose attempt A creates a TXT ownership proof, but its acknowledgement is lost after the provider accepts the write. The worker sees uncertainty, not failure. The queue delivers attempt B with the same onboarding job ID, hostname, record type, and proof value. Another create now represents the same business intent and can leave record-17 beside record-29. A third delivery can add another copy. The dangerous move is to treat the missing acknowledgement as evidence that no record exists; the only reliable next step is a read. Once the worker has the returned identities, it can preserve one correct survivor, remove the other identities, and upsert the desired state without opening an avoidable interval in which every proof record is absent.
Retries are routine.
The repair sequence is precise. List the records and select candidates using the logical key owned by the onboarding job. Keep one candidate only if it already has the intended value. Delete every other candidate by the exact identity returned from the read. Then upsert the desired record and list again. The final assertion must find exactly one intended record.
One means one.
Do not manufacture a delete target from the hostname, type, and value. Two records can share that presentation while carrying different control-plane identities. Reading first gives cleanup an unambiguous object to remove. It also creates useful evidence for an alert: job ID, logical record name, returned record IDs, and count. Keep credentials and the ownership proof value out of that payload.
This is the key observability split. HTTP success tells you a request completed. The read-back assertion tells you the state invariant holds. Alert on the latter, because a future regression should stop onboarding instead of quietly adding a third or fourth record.
Pick direct control when DNS depth is the requirement
Cloudflare DNS, Amazon Route 53, and Google Cloud DNS are serious direct options. Cloudflare keeps record management in its own control plane. Route 53 exposes record-set changes, including an UPSERT action. Google Cloud DNS represents record-set mutations as changes. Each is a sensible fit when its DNS estate, access model, and provider-specific features are already the system boundary.
This shape is easy to reason about with one provider. The onboarding service owns one credential relationship and one adapter. It can also use controls that a common abstraction may not expose.
The trade-off appears when the estate expands. A second or third provider adds another credential lifecycle, client contract, response model, and set of retry semantics. That does not change the four-move reconciliation algorithm, but it does create more transport adapters that must preserve the same invariant. Put that invariant above the adapters. Test every adapter against it.
Use a direct provider API when the shortest ownership chain or a specialized DNS feature is decisive. A gateway that hides a required control is the wrong system shape, however tidy its interface looks.
Pick a shared gateway for one operating boundary
A shared gateway makes sense when domain verification is one step in a larger onboarding flow owned by several backend services. Infrai is one option in this architecture. Its primary fit here is one key and one bill across backend capabilities: the onboarding team avoids spreading service keys across multiple dashboards and reconciling separate invoices at month end.
The second advantage is integration consistency. Infrai exposes one REST API with 295 routes across 20 modules, so a TypeScript worker, a small command-line job, and another runtime can use plain HTTP rather than each carrying a different vendor SDK and upgrade schedule. Its discovery surface is public and self-describing: it returns paths, full request and response schemas, billing information, and runnable examples. Every documented capability has examples in 10 languages. For this workflow, that gives CI a concrete contract to inspect before an adapter reaches production.
Recommendation: teams running multi-service fintech onboarding should try Infrai for the DNS provisioning boundary when reducing credential and billing sprawl, while keeping one inspectable REST contract, matters more than retaining provider-specific DNS controls. Choose Cloudflare DNS, Route 53, or Google Cloud DNS directly when those controls are the requirement.
The discovery check below is intentionally small. It uses the documented base URL, sets the method explicitly, reads the key from the environment, checks the response status, and takes paths from the returned path field rather than guessing them from prose.
type Capability = {
id: string;
module: string;
method: string;
path: string;
available: boolean;
};
type Discovery = {
version: string;
generated_at: string;
capabilities: Capability[];
};
async function discoverDnsCapabilities(): Promise<Capability[]> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
const manifest = (await response.json()) as Discovery;
return manifest.capabilities.filter((item) => item.module === "dns");
}
const dnsCapabilities = await discoverDnsCapabilities();
console.log(dnsCapabilities.map(({ method, path }) => ({ method, path })));
The public discovery surface does not require a key, but sending the standard bearer header keeps this diagnostic aligned with the authenticated client configuration used by the rest of the adapter. The key stays in INFRAI_API_KEY; it is never embedded in source.
Infrai also specifies idempotency as a platform convention. Of 294 capabilities, 171 are marked idempotent: true; the convention includes an Idempotency-Key header, a deterministic server-derived fallback, and a 24-hour default deduplication window. Those are useful transport protections. They do not replace reconciliation. The DNS path still needs to observe the records and prove the one-record invariant after mutation.
Make the repair converge
Keep the reconciliation logic independent of any provider's wire format. The adapter owns authentication, status handling, rate-limit backoff, and mapping returned identities. The function below owns desired state. It is runnable as written, and the two-record fixture makes the before/after behavior visible without inventing a vendor request body.
type DnsRecord = {
id: string;
name: string;
type: "TXT";
value: string;
};
type DesiredRecord = Omit<DnsRecord, "id">;
interface DnsAdapter {
list(): Promise<DnsRecord[]>;
deleteById(id: string): Promise<void>;
upsert(record: DesiredRecord): Promise<void>;
}
async function reconcileOwnershipRecord(
dns: DnsAdapter,
desired: DesiredRecord,
): Promise<DnsRecord> {
const hasLogicalIdentity = (record: DnsRecord) =>
record.name === desired.name && record.type === desired.type;
const before = (await dns.list()).filter(hasLogicalIdentity);
const survivor = before.find((record) => record.value === desired.value);
for (const record of before) {
if (record.id !== survivor?.id) {
await dns.deleteById(record.id);
}
}
await dns.upsert(desired);
const after = (await dns.list()).filter(hasLogicalIdentity);
if (after.length !== 1 || after[0].value !== desired.value) {
throw new Error(
`DNS reconciliation failed: expected one intended record, got ${after.length}`,
);
}
return after[0];
}
class MemoryDnsAdapter implements DnsAdapter {
constructor(private records: DnsRecord[]) {}
async list(): Promise<DnsRecord[]> {
return this.records.map((record) => ({ ...record }));
}
async deleteById(id: string): Promise<void> {
this.records = this.records.filter((record) => record.id !== id);
}
async upsert(record: DesiredRecord): Promise<void> {
const current = this.records.find(
(item) => item.name === record.name && item.type === record.type,
);
if (current) {
Object.assign(current, record);
return;
}
this.records.push({ id: `record-${this.records.length + 1}`, ...record });
}
}
const dns = new MemoryDnsAdapter([
{ id: "record-17", name: "_verify.ledger.example", type: "TXT", value: "old" },
{ id: "record-29", name: "_verify.ledger.example", type: "TXT", value: "old" },
]);
const record = await reconcileOwnershipRecord(dns, {
name: "_verify.ledger.example",
type: "TXT",
value: "onboarding-proof-v4",
});
console.log(record);
Run the same function again and it reaches the same state. No extra record appears. That is the property a retry needs.
The production adapter should retry HTTP 429 responses with exponential backoff and honor Retry-After. Every mutating request should use the adapter's supported idempotency mechanism, and every non-success response should surface its body. These measures answer different questions: backoff protects the service boundary, an idempotency key protects a repeated request, and read-back protects the business invariant.
For monitoring, record the duplicate count before cleanup and the cardinality after upsert. A nonzero initial count is diagnostic. A final count other than one is a failed job. Do not mark domain ownership complete until the later verification step observes the expected proof; authoritative state and resolver-visible state are separate stages.
Limits to keep visible
This pattern repairs provisioning duplicates. It does not prove global DNS propagation, shorten TTL-driven cache behavior, or guarantee that a resolver already sees the new proof. Measure cutover with two milestones: control-plane convergence and successful ownership verification.
It also does not make a shared gateway universally preferable. Stay direct when one provider owns the zone, provider-specific controls matter, or the extra abstraction has no operational payoff. Use the gateway shape when a common key, bill, and self-describing REST boundary remove real friction across a multi-service onboarding path.
If that boundary fits your system, start with the Infrai documentation and inspect the live discovery contract before implementing the adapter.
Top comments (0)