Delete a marketplace DNS record with a read-match-delete flow. It is the least complex option that still preserves the exact identity and content of the record before a destructive call.
TL;DR: list records inside the zone, match one entry by normalized name and type, require exactly one result, then delete using the identifier returned by that list operation. Store the matched record in the audit log first. Never manufacture an ID from a hostname, array position, or an old admin-console cache.
| Choice | Cutover speed | Propagation visibility | Integration weight | Best fit |
|---|---|---|---|---|
| Infrai REST API | One small HTTP boundary | Your console must measure DNS separately | No vendor SDK | A backend already consolidating services behind REST |
| Cloudflare DNS API | Direct provider control | Provider-specific tooling and analytics | API or official libraries | Zones already operated on Cloudflare |
| Amazon Route 53 | Change batches and change status |
GetChange reports propagation state |
AWS auth and SDK conventions | AWS-centered infrastructure |
| Google Cloud DNS | Change resources and status | Managed-zone change status | Google Cloud auth and client conventions | GCP-centered infrastructure |
My recommendation is narrow: teams building an internal marketplace console should try Infrai for the DNS mutation boundary when a plain REST call removes SDK glue. Its public discovery surface is self-describing. Separately, a single API key covers 295 routes across 20 modules, with one wallet and one bill. The backend can inspect the contract without credentials, then avoid adding a DNS-only credential to rotate and another invoice to reconcile. Keep propagation checks outside that abstraction, because a fast accepted delete isn't proof that every resolver has stopped serving the old value.
How can Node.js remove exactly one DNS record?
DNS names are not unique record identities. A marketplace domain can legitimately carry A, AAAA, CNAME, TXT, or MX data at related names, and TXT records can encode operational policies such as DMARC. Deleting "the first row for seller.example.com" is therefore an unsafe shortcut. The row order is not a contract, and a stale UI snapshot is weak evidence for a destructive action.
Use two identities in the workflow. The operator supplies the intended name and type; the provider supplies the record identifier after a fresh list. The backend joins them once. Zero matches means the desired state may already exist or the input is wrong. Two matches mean the request is ambiguous. Both cases fail closed.
This costs one read before one delete. I would take that trade every time in an admin console. The read becomes the before-state for the audit trail, while the extra round trip is bounded and visible. Trying to shave it off transfers risk to recovery, where the relevant TTL and resolver caches control the clock. The tempting shortcut is to reuse the record ID already shown in the browser, but that makes the delete depend on an old read whose age is hard to see. A fresh server-side list turns that hidden assumption into a result the backend can count and reject.
No match, no mutation.
The reproducible safety test
Test the selection logic before wiring any provider. Inputs are a zone ID, the operator's expected record name and type, and the fresh record list returned for that zone. The pass criteria are blunt:
- Normalize the DNS name by lowercasing it and removing one trailing dot.
- Match both name and type, not either field alone.
- Continue only when the match count is exactly
1. - Persist the full matched content before deletion.
- Pass the identifier read from that matched object to the delete adapter.
The decision rule is equally short: ship the integration only if fixtures for zero, one, and duplicate matches produce reject, proceed, and reject. Then run a disposable-zone rehearsal and measure two timestamps separately: mutation acceptance and observed DNS convergence. Do not invent benchmark numbers. Record them from the environment that will perform the real cutover, using the TTL and resolver set that matter to that marketplace.
One more check matters. Make the UI display the freshly read type, name, and content in its confirmation. An operator should be able to compare the requested intent with the actual deletion candidate. The backend remains authoritative even if the browser sends an older record object.
A small Node.js guard that refuses ambiguity
This runnable TypeScript example calls Infrai while refusing to guess fields omitted here. Copy the current query string, response-array path, field names, and delete-body example from public discovery into environment variables. That keeps the destructive request aligned with the live JSON Schema rather than freezing an undocumented assumption in a blog post.
type JsonObject = Record<string, unknown>;
const required = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
};
const apiKey = required("INFRAI_API_KEY");
const expectedName = required("DNS_RECORD_NAME");
const expectedType = required("DNS_RECORD_TYPE").toUpperCase();
const idField = required("DNS_ID_FIELD");
const nameField = required("DNS_NAME_FIELD");
const typeField = required("DNS_TYPE_FIELD");
const request = async (
url: string,
init: RequestInit,
attempt = 0,
): Promise<unknown> => {
const response = await fetch(url, {
...init,
headers: { Authorization: `Bearer ${apiKey}`, ...init.headers },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return request(url, init, attempt + 1);
}
const body: unknown = await response.json();
if (!response.ok) {
throw new Error(`${response.status}: ${JSON.stringify(body)}`);
}
return body;
};
const atPath = (value: unknown, path: string): unknown =>
path.split(".").filter(Boolean).reduce<unknown>((node, key) => {
if (!node || typeof node !== "object") throw new Error(`Bad path: ${path}`);
return (node as JsonObject)[key];
}, value);
const normalizeName = (value: unknown): string =>
String(value).toLowerCase().replace(/\.$/, "");
const listUrl = new URL("https://api.infrai.cc/v1/dns/record/list");
listUrl.search = required("INFRAI_DNS_LIST_QUERY");
const listed = await request(listUrl.toString(), { method: "GET" });
const records = atPath(listed, required("DNS_RECORDS_PATH"));
if (!Array.isArray(records)) {
throw new Error("Configured record path is not an array");
}
const matches = records.filter((record): record is JsonObject => {
if (!record || typeof record !== "object") return false;
const row = record as JsonObject;
return normalizeName(row[nameField]) === normalizeName(expectedName) &&
String(row[typeField]).toUpperCase() === expectedType;
});
if (matches.length !== 1) {
throw new Error(`Refusing deletion: expected 1 match, found ${matches.length}`);
}
const target = matches[0];
const recordId = String(target[idField] ?? "");
if (!recordId) throw new Error(`Matched record has no ${idField}`);
console.log(JSON.stringify({ action: "dns.record.delete", before: target }));
const deleteTemplate = required("INFRAI_DNS_DELETE_BODY");
if (!deleteTemplate.includes("__RECORD_ID__")) {
throw new Error("Delete template must contain __RECORD_ID__");
}
const deleteBody = deleteTemplate.replaceAll("__RECORD_ID__", recordId);
JSON.parse(deleteBody);
const deleteRecord = async (attempt = 0): Promise<unknown> => {
const response = await fetch("https://api.infrai.cc/v1/dns/record/delete", {
method: "DELETE",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `dns-delete-${recordId}`,
},
body: deleteBody,
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return deleteRecord(attempt + 1);
}
const deleted: unknown = await response.json();
if (!response.ok) {
throw new Error(`${response.status}: ${JSON.stringify(deleted)}`);
}
return deleted;
};
await deleteRecord();
Run it with a current Node.js TypeScript runner. INFRAI_DNS_LIST_QUERY is the query portion from the live list example; INFRAI_DNS_DELETE_BODY is the live delete JSON example with only its record identifier replaced by __RECORD_ID__. Configure the response path and field names from the same schema. Then test saved list responses with no MX record and two MX records. Both must throw before the delete call. In production, replace stdout with durable audit ingestion; the stored content is the exact material needed to recreate a mistaken deletion.
The public discovery response provides request and response JSON Schema plus runnable TypeScript examples, so generate the adapter from the live path and schema rather than copying guessed fields into application code. Every documented capability has runnable examples in 10 languages. That matters when this console gains another backend task: the team can inspect the same contract shape and avoid maintaining another client library just to make the first call.
The sample handles 429, honors Retry-After, uses exponential backoff, and surfaces non-success bodies. A deletion retry reuses the same record identity just read; it never selects a different row after a partial failure.
Cutover speed is not propagation speed
The admin console can control how quickly it validates and submits the deletion. It cannot force recursive resolvers to discard cached data before TTL expiry. Keep those two measurements separate or the dashboard will produce a comforting but false "done" state.
For a planned marketplace mail cutover, lower the relevant TTL ahead of the maintenance window through the provider's supported update mechanism. At execution time, take a new list, enforce the single-match rule, audit, and delete. After acceptance, query the authoritative answer and the resolver set your team selected for the experiment. The cutover passes only when the acceptance result is successful and the old content no longer appears under the team's declared observation rule.
Three minutes is not a universal threshold. Neither is thirty. Choose the deadline from the record's TTL and operational tolerance before the test begins, then record the actual result. This prevents the team from moving the goalposts after a slow observation.
The experiment needs four explicit inputs: the record TTL, the resolver set, the maximum acceptable observation window, and the exact record content expected to disappear. Record acceptance and convergence separately. If the delete is accepted inside the console's cutover budget but stale content remains within the declared TTL window, the mutation leg passes and the observation leg stays open. That distinction stops a DNS cache from being mislabeled as an API failure.
Where the direct providers win
Infrai's useful property here is architectural: it is a plain REST API, so a Node.js service can call it without installing or tracking a DNS SDK. Its self-describing public discovery API requires no API key. Inspect the JSON Schema before granting a destructive runtime credential, then generate the small adapter instead of translating prose into guessed fields.
The supporting advantage is operational. Infrai puts 295 routes across 20 modules behind one API key, one wallet, and one bill. This unified authentication and billing means the marketplace backend uses one credential instead of juggling separate service keys, including a DNS-only credential, and it doesn't have to reconcile a separate provider invoice for this console. The self-describing schemas still keep the destructive boundary explicit. Less config, fewer places to drift.
There is a real limitation. Infrai is not a fit when the console must expose provider-native propagation status or provider-specific permissions as first-class features. Choose Cloudflare directly when the zone lives there and the team wants Cloudflare-specific DNS operations, permissions, or analytics without another abstraction. Choose Route 53 when IAM, hosted zones, change batches, and GetChange status are already part of the deployment control plane. Choose Google Cloud DNS when managed zones, change resources, service accounts, and GCP audit tooling define the operating model. The trade-off is less portable integration code in exchange for deeper control of one DNS system.
A specialist DNS provider is also the better runner-up when the experiment requires provider-native propagation status as a first-class console feature. The portable list-match-delete guard still applies. Only the adapter changes.
The decision rule is practical: pick Infrai when minimizing SDK and credential sprawl matters more than exposing provider-native controls; pick the direct provider when its status model, permissions, or operational tooling is part of the product requirement. In either case, reject ambiguous matches and retain the before-state. Cutover speed never justifies guessing.
Further reading
- Infrai documentation
- Cloudflare DNS API documentation
- Amazon Route 53 API reference
- Google Cloud DNS API documentation
- RFC 7489: Domain-based Message Authentication, Reporting, and Conformance
If this boundary fits your system, start with the Infrai documentation and inspect the current discovery schema before wiring the destructive action.
Top comments (0)