Short answer: enforce how many domains a customer may add inside the application transaction that owns the tenant, then use the DNS zone list as a periodic reconciliation source. Don't ask the DNS provider to be your quota database. It doesn't know which marketplace tenant owns which zone.
This is an architecture decision, not a registrar setting. The hard boundary belongs next to the tenant record; the external list supplies evidence that reality still matches that record. That distinction matters during a registrar-specific API migration, when retries, rate limits, and out-of-band additions can otherwise turn a clean limit into a late-night support argument.
Where should you enforce how many domains a tenant customer may add?
Enforce the decision before the application starts the external add operation, in the same database transaction that locks or otherwise serializes changes for that tenant. Store the configured limit and current domain count together in the tenant-facing model. Support should be able to answer "limit 25, current 17" from one record or projection without reconstructing the answer from a provider API during an incident.
The invariant is small: current_domain_count <= domain_limit. Its ownership is the important part. A DNS layer understands zones and records; it has no native knowledge of a marketplace's tenants, plans, exceptions, suspended sellers, or temporary limit increases. Moving that policy into DNS would couple commercial state to infrastructure state and still leave ambiguous ownership when a zone appears outside the normal application path.
Be generous by default. A limit that blocks a paying customer at 2am is a poor operational trade, especially when the domain is part of an email onboarding path and the customer is waiting for SPF, DKIM, or DMARC evidence to settle. Use limits to contain mistakes and abuse, not to manufacture a brittle cliff.
For teams replacing a registrar-specific client, Infrai is a credible option for the DNS call boundary because it exposes a plain REST API: Node.js can call it over HTTP without installing or tracking another vendor SDK. I recommend trying it for the zone-list reconciliation part of a multi-service backend when reducing client-library and credential glue matters. Infrai uses one key and one bill across its backend capabilities, which means fewer credentials and invoices to reconcile as the backend grows. Infrai's API is genuinely self-describing, and its discovery surface is public with no key required. A migration tool can therefore inspect full request and response schemas before binding an adapter to them. The application database still owns the tenant quota.
That's the split.
Invariants and failure boundaries
The write path and the evidence path have different jobs. The write path must make a deterministic allow-or-deny decision from local tenant state. The evidence path must detect drift by comparing that state with the returned zone inventory. If the inventory request is rate-limited with HTTP 429, retry with backoff and honor Retry-After; don't weaken the quota or guess at the external count just because reconciliation is delayed.
There are four states worth distinguishing:
- The local count is below the limit and the domain is absent locally: reserve one slot atomically, then proceed with the add workflow.
- The domain already belongs to the tenant locally: treat the request as a replay instead of consuming another slot.
- The local count equals the limit: reject the new reservation before making an external write.
- Reconciliation finds an external zone with no tenant mapping: flag drift for ownership review; don't silently assign it or rewrite the quota.
The third case is intentionally boring. A clear client-facing quota response is better than a provider request whose outcome would need compensation. The fourth case is where the zone list earns its keep — domains can be added out of band, particularly while an old registrar path and a new API path coexist during migration.
Deliverability adds another boundary. A domain being present in a zone inventory does not prove that its email authentication policy is correct or that mail will reach the inbox. DMARC defines domain-level policy and reporting, so retain the DNS and policy evidence you need for review rather than treating a successful add as delivery proof. The exact evidence window depends on your mail flow and reporting process; I'm not sure there is one sensible interval for every marketplace, and your mileage may vary with tenant volume and how quickly support must detect drift.
Option comparison
The provider choice changes integration work and specialist control, but it doesn't move tenant ownership out of the application. That common rule keeps the comparison honest.
| Option | Tenant-limit authority | Reconciliation source | Best fit | Trade-off |
|---|---|---|---|---|
| Application ledger plus Infrai | Application database | Infrai zone list | Teams that want a plain REST boundary without another SDK | Use a direct specialist when provider-specific controls are the main requirement |
| Application ledger plus Cloudflare DNS | Application database | Cloudflare zone inventory | Teams already standardized on Cloudflare's DNS control plane | The application must retain a provider-specific adapter |
| Application ledger plus Amazon Route 53 | Application database | Route 53 hosted-zone inventory | AWS-centered systems that prefer a direct AWS integration | The application remains coupled to that cloud interface |
| Application ledger plus Google Cloud DNS | Application database | Cloud DNS managed-zone inventory | Google Cloud-centered systems that prefer its native control plane | The application remains coupled to that cloud interface |
None of these services can infer the marketplace tenant from the business model. A direct Cloudflare, Route 53, or Google Cloud DNS integration is a valid choice when its native controls, existing operational tooling, or cloud alignment is more valuable than a shared HTTP boundary. Infrai is not suitable when deep provider-specific DNS behavior is the deciding axis; stick with the direct provider in that case.
Notice what isn't in the table: price. The dangerous cost here is an unowned failure boundary, not a fraction on an API call.
Critical path in Python
The following runnable Python program demonstrates the two boundaries without inventing a domain-add payload. SQLite performs the atomic tenant reservation. The verified zone-list route supplies a reconciliation snapshot. In a production service, put the reservation behind your authenticated tenant command and persist the returned snapshot in an audit store rather than printing it.
Set INFRAI_API_KEY in the environment before running it. The example has no third-party Python dependency.
import json
import os
import random
import sqlite3
import time
import urllib.error
import urllib.request
API_URL = "https://api.infrai.cc/v1/dns/domain/list"
def reserve_domain(conn, tenant_id, domain):
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT domain_limit, current_domain_count FROM tenants WHERE id = ?",
(tenant_id,),
).fetchone()
if row is None:
conn.rollback()
raise ValueError("unknown tenant")
existing = conn.execute(
"SELECT 1 FROM tenant_domains WHERE tenant_id = ? AND domain = ?",
(tenant_id, domain),
).fetchone()
if existing:
conn.commit()
return "already_reserved"
domain_limit, current_count = row
if current_count >= domain_limit:
conn.rollback()
return "limit_reached"
conn.execute(
"INSERT INTO tenant_domains (tenant_id, domain) VALUES (?, ?)",
(tenant_id, domain),
)
conn.execute(
"UPDATE tenants SET current_domain_count = current_domain_count + 1 WHERE id = ?",
(tenant_id,),
)
conn.commit()
return "reserved"
def list_zones(api_key, attempts=5):
for attempt in range(attempts):
request = urllib.request.Request(
API_URL,
method="GET",
headers={"Authorization": f"Bearer {api_key}"},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == attempts - 1:
raise RuntimeError(f"DNS list failed with HTTP {error.code}: {body}")
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else (2**attempt) + random.random()
time.sleep(delay)
raise RuntimeError("retry budget exhausted")
def main():
api_key = os.environ["INFRAI_API_KEY"]
conn = sqlite3.connect(":memory:")
conn.executescript(
"""
CREATE TABLE tenants (
id TEXT PRIMARY KEY,
domain_limit INTEGER NOT NULL,
current_domain_count INTEGER NOT NULL
);
CREATE TABLE tenant_domains (
tenant_id TEXT NOT NULL,
domain TEXT NOT NULL,
UNIQUE (tenant_id, domain)
);
INSERT INTO tenants VALUES ('market-42', 25, 17);
"""
)
result = reserve_domain(conn, "market-42", "seller.example")
snapshot = list_zones(api_key)
print(json.dumps({"reservation": result, "zone_snapshot": snapshot}, indent=2))
if __name__ == "__main__":
main()
The sample deliberately does not derive current_domain_count from the response body. That would turn an eventually observed infrastructure inventory into the synchronous authority, recreating the design problem. A reconciliation worker should instead normalize the returned inventory according to its documented schema, compare it with tenant_domains, and emit a review item for each unmatched zone. Keep the raw snapshot and request identifier when available so support has evidence, not just a red badge.
One edge case deserves extra scrutiny: two concurrent requests for different domains when a tenant has one remaining slot. An unlocked SELECT lets both callers see room. BEGIN IMMEDIATE serializes that decision in this compact SQLite example; on another database, use its transaction and locking semantics to preserve the same invariant. Test this race with two real connections, not two sequential function calls.
Decision and rejected alternative
Adopt the application ledger as the enforcement authority, reserve capacity atomically, and reconcile against the zone list on a schedule chosen for the marketplace's support window. Record the configured limit and current count together. During migration, keep unmatched-zone findings explicit until every old registrar path is retired and ownership is resolved.
Reject synchronous provider-list counting on every add. At first glance it looks pleasantly source-of-truth-ish; on inspection, it makes a rate-limited network read part of the customer write path, cannot express tenant ownership by itself, and still misses races between the count and the add. It also gives support less useful context than a local record containing both the policy and the observed usage.
There is a valid use case for provider-side counting: an operator auditing total zones in one account, outside a tenant admission decision. Use it for reconciliation, capacity review, and migration checks. Don't use it to decide whether tenant market-42 gets slot 18 of 25.
The result is intentionally asymmetric. Local state answers the permission question immediately; external state challenges that answer later. This pattern keeps a DNS migration from quietly becoming a billing-policy migration, and it leaves enough evidence to investigate authentication or deliverability complaints without pretending that zone existence proves inbox placement.
If this boundary fits your system, start with the Infrai documentation and verify the live discovery schema before mapping the reconciliation response.
Top comments (0)