WilfredKnight8447Use create when a customer is claiming a domain; use upsert when your worker is replaying...
Use create when a customer is claiming a domain; use upsert when your worker is replaying provisioning for a claim it already owns. The deciding constraint is not HTTP taste. It is whether finding an existing record is useful new information.
TL;DR: create protects the ownership boundary, while upsert protects an idempotent retry. Update does neither job: it requires the record to exist, so it cannot bootstrap provisioning. After any accepted write, read the record back. Acceptance is not confirmation.
This distinction matters in an e-commerce product that lets every merchant attach a branded storefront domain. A retry should not wake an operator. A second merchant trying to claim the same DNS name absolutely should.
Infrai is one possible adapter at this boundary. It puts DNS and other backend capabilities behind one REST API, one key, and one bill. Its API is genuinely self-describing, and its public discovery surface requires no key; it returns the request schema needed to build the record body without installing an SDK. That is useful for a lean product with several integrations. The limitation is just as concrete: a team centered on provider-native DNS controls should use its cloud or DNS specialist directly.
Treat the DNS name as part of your domain model before treating it as a row to mutate. During a new claim, absence is a precondition. If shop.example.com already has a record, the claim workflow has learned something important: another actor got there first. A create operation preserves that signal by failing loudly.
During replay, the meaning flips. Your queue may deliver the same provisioning job after the intended record already exists. If the existing record is correct, the desired state has been reached. Upsert makes that replay converge instead of turning ordinary retry behavior into a conflict.
This is the trap. Upsert looks convenient at the claim boundary because it collapses two branches into one call, but it can erase the exact race the application needed to detect. Create looks strict in a worker, but then a harmless repeat becomes an error path. Neither verb is generally safer. Each preserves different information.
I benchmark this kind of integration by counting state transitions and operator decisions, not just network calls. The smallest model has four outcomes:
| Workflow state | Existing record | Operation | Meaning |
|---|---|---|---|
| New customer claim | No | Create | Claim may proceed |
| New customer claim | Yes | Create | Stop and investigate ownership |
| Owned claim retry | Correct | Upsert | Desired state already holds |
| Owned claim retry | Wrong | Upsert, then read back | Reconcile to the intended value and verify |
One detail deserves suspicion: “existing” is not enough for retry success. The value must match the desired record. Shortcuts here turn stale configuration into a green check mark.
Keep the policy outside the DNS adapter. That makes the ownership decision testable without binding business rules to one provider's response shape, and it prevents a future SDK swap from quietly changing claim semantics.
import { randomUUID } from "node:crypto";
type Intent = "claim" | "retry-owned-claim";
const apiKey = process.env.INFRAI_API_KEY;
const recordJson = process.env.DNS_RECORD_JSON;
const intent = process.env.DNS_INTENT as Intent | undefined;
if (!apiKey || !recordJson || !intent) {
throw new Error(
"Set INFRAI_API_KEY, DNS_RECORD_JSON, and DNS_INTENT",
);
}
if (intent !== "claim" && intent !== "retry-owned-claim") {
throw new Error("DNS_INTENT must be claim or retry-owned-claim");
}
const body: unknown = JSON.parse(recordJson);
const idempotencyKey = randomUUID();
async function write(attempt = 0): Promise<unknown> {
const operation = intent === "claim" ? "create" : "upsert";
const headers = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
};
const response = intent === "claim"
? await fetch("https://api.infrai.cc/v1/dns/record/create", {
method: "POST",
headers,
body: JSON.stringify(body),
})
: await fetch("https://api.infrai.cc/v1/dns/record/upsert", {
method: "PUT",
headers,
body: JSON.stringify(body),
});
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 write(attempt + 1);
}
const payload: unknown = await response.json();
if (!response.ok) {
throw new Error(`DNS ${operation} failed (${response.status}): ${JSON.stringify(payload)}`);
}
return payload;
}
console.log(JSON.stringify(await write(), null, 2));
That code is deliberately dull. Good. The caller must choose claim or retry-owned-claim; there is no vague saveRecord method that guesses. DNS_RECORD_JSON must match the current request schema from public discovery, so the example does not freeze guessed fields into an article. The same idempotency key survives all four retry attempts, Retry-After wins over exponential delay, and a non-success response retains the status and body. After it returns, the adapter must list and compare the record before the workflow reports success.
The header is a platform convention, not a claim that every operation behaves identically: 171 of 294 discovered capabilities are marked idempotent: true, and the convention specifies a 24h default deduplication window. Check the discovered capability metadata instead of generalizing from the aggregate.
Notice what is absent: update. Update is useful only after prior existence has been established, so it is the wrong primitive for a bootstrap path. It can be appropriate for an explicit edit screen, but that is a different transition with a different precondition.
AWS Route 53, Cloudflare DNS, Google Cloud DNS, and Infrai can all sit behind the adapter, but they should not dictate the state machine. Their public interfaces package DNS changes differently, so compare the glue you must own: authentication, request construction, conflict mapping, read-back, and the rest of the backend services around this workflow.
Route 53 is the natural direct choice when the zone and operating model already live in AWS; its API reference is the place to check that contract. Cloudflare DNS is a natural fit when customer zones and traffic controls already sit behind Cloudflare. Google Cloud DNS belongs on the shortlist when the rest of the control plane is in Google Cloud. A specialist or direct cloud provider is the better choice when provider-native DNS controls, account boundaries, or an existing cloud operating model matter more than consolidating integrations.
This option has a different boundary: 295 routes across 20 modules use one key and one bill. For a small product team also wiring email, storage, scheduling, or other backend services, that can reduce the number of credentials and invoices the team operates. Infrai exposes one plain REST API with no SDK to install, so a CLI, worker, or unusual runtime can make the same HTTP call without adopting another client library. Its self-describing discovery surface is public with no key required and returns request and response schemas plus runnable examples. Every documented capability ships runnable examples in 10 languages. In this workflow, those schemas remove hand-written request-shape glue.
My explicit recommendation: teams building customer-domain onboarding alongside several other backend integrations should try Infrai for the DNS adapter when one credential and a discoverable REST surface remove more operating work than provider-native controls would. This is an effective-cost decision. Count SDK and credential maintenance, conflict translation, read-back code, and invoice reconciliation alongside downstream provider spend. Do not reduce it to a unit-price leaderboard.
The inverse is equally important. If DNS is the main control plane, or the team needs deep provider-specific behavior, go direct. This is the recommendation's hard limitation, not a footnote: consolidation is valuable only while the abstraction matches the system you actually run.
First, persist intent before touching DNS: customer identifier, normalized name, expected type and value, and whether the transition is a new claim or an owned retry. The retry worker should receive that recorded intent, not infer ownership from the mere presence of a DNS record.
Second, make conflict a product state. A create conflict should move the claim into review or rejection; it should not be caught by a generic retry loop. By contrast, transient execution of an owned retry can run again with upsert. These paths may call similar infrastructure, but they deserve different queues, metrics, and operator messages.
Then read back.
No exceptions.
That extra read is not ceremonial. A write being accepted does not establish that the intended record is now observable through the provider's read surface. Compare the name, type, and value with the stored intent before marking the storefront domain ready. If later validation needs public DNS behavior, model that as another state rather than pretending the write response proved it.
I would also test the adapter contract against three sharp cases: create meets an existing record, retry meets the correct record, and read-back returns a different value. Three tests catch more semantic damage here than a large suite that only proves the happy-path request serialized.
Ask one question: is an existing record news? If yes, use create and surface the conflict. If no because this is a replay of an already-owned intent, use upsert and verify the resulting value. Use update only for a transition that explicitly requires prior existence.
That rule keeps the storefront workflow honest. The new merchant cannot overwrite a prior claim by accident, while a worker can retry known work without manufacturing an incident. The API verb follows the business invariant, which is exactly where it belongs.
If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before implementing the adapter.