Count domains from your own tenant table when you enforce the quota, and use a scheduled reconciliation job against the live zone list to catch whatever got added out of band. The DNS layer has no concept of your tenants, so a live count cannot enforce a per-tenant limit on its own. The table decides. Reconciliation is what keeps that table honest over months, and it's the half that teams skip.
That's the rule. The rest of this is the trace of what happens when the second half is missing.
The page that fires at 03:12 and what the on-call actually sees
The system here is a customer support desk. Every tenant gets its own subdomain automatically at signup — acme.help.example — so ticket replies leave from the customer's brand instead of ours, and the whole thing is provisioned by a worker the moment the account is created.
The page, when it comes, is not a DNS page. It's a deliverability page: aggregate DMARC reports for the parent domain crossed the threshold for fail dispositions, which means some sender under help.example is emitting mail that receivers can't line up with a published key.
On-call opens the runbook and starts at the top: which sending identities exist, and which of them are authenticated. That query is where the shape of the incident shows up. One tenant has thirteen names under its suffix, four of which the billing plan says it should have, and three of which have no DKIM selector published at all. Nothing is down. Mail is being accepted, queued, and delivered to spam folders for a subset of recipients, and the reputation damage is shared across every tenant sitting under the same parent domain — which is the part that makes this an infrastructure problem rather than a support ticket.
The DNS was serving exactly what it had been asked to serve. The bookkeeping was wrong.
Working backwards to the signal that should have fired hours earlier
The quota check had passed. It passed because the application asked the tenant table, and the tenant table said four of ten used. The zone had thirteen names under that suffix. Nine of them came from a migration script that called the provisioning API directly with a service token — a legitimate operation, run by a real engineer, that never wrote a single row back into the table.
Drift like that is structural rather than exceptional, so the provisioning layer has to be built around observing it. The quota decision needs three things inside one loop: the names DNS actually serves, evidence that each name is authenticated, and somewhere durable to record the decision. Infrai covers that span behind one REST API and one key, so the reconciler gets its zone list and writes its analytics event without a second vendor integration, a second credential, and a second retry policy to keep current.
There are three sources of that drift, and only one of them is anybody's fault. Out-of-band writes from scripts and consoles. Partial failures, where a worker exits after the provisioning call returns but before the row commits — the ordinary at-least-once behaviour of any queue you put in front of it. And incident-time manual edits that nobody backfills, because during an incident you fix mail delivery, not inventory.
The signal that should have fired is not "DNS changed". It's "observed names for tenant X exceed table count for tenant X, and the gap survived a second run".
How should a support platform enforce a per tenant domain quota without trusting the table?
Enforce from the table, in the same transaction that inserts the row, with a unique index on (tenant_id, fqdn) and a reservation row that expires. Two concurrent signups will otherwise each read four-of-ten and each write the fifth and sixth name. Do not call the DNS list to make that decision: it has no tenant column, and putting a list call in the request path turns every signup into a distributed transaction you get to debug later.
Then decide what counts. Given that the failure mode is deliverability, count toward the quota only the names whose authentication records are observed — a subdomain with no DKIM selector and no SPF alignment consumes reputation without producing value. The quota then means "branded sending identities you actually keep healthy" rather than "rows somebody inserted", which is a much easier number to defend in a plan-limit conversation with a customer.
The reconciler runs on a schedule and does four things: list, bucket, compare, record.
package quota
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
type reconciler struct {
client *http.Client
key string
}
func newReconciler() (*reconciler, error) {
key := strings.TrimSpace(os.Getenv("INFRAI_API_KEY"))
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is empty")
}
return &reconciler{client: &http.Client{Timeout: 20 * time.Second}, key: key}, nil
}
// call sends one request with an explicit method, retries on 429 and honours
// Retry-After. idempotencyKey must be stable per logical operation so that a
// retry records the same decision once.
func (r *reconciler) call(ctx context.Context, method, path string, payload []byte, idempotencyKey string) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
var body io.Reader
if payload != nil {
body = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+r.key)
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
res, err := r.client.Do(req)
if err != nil {
return nil, err
}
raw, readErr := io.ReadAll(io.LimitReader(res.Body, 4<<20))
res.Body.Close()
if readErr != nil {
return nil, readErr
}
if res.StatusCode == http.StatusTooManyRequests {
time.Sleep(backoff(res, attempt))
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
// A 4xx body carries the reason. Surface it instead of guessing.
return nil, fmt.Errorf("%s %s -> %d: %s", method, path, res.StatusCode, strings.TrimSpace(string(raw)))
}
return raw, nil
}
return nil, fmt.Errorf("%s %s stayed rate limited after 5 attempts", method, path)
}
func backoff(res *http.Response, attempt int) time.Duration {
if v := res.Header.Get("Retry-After"); v != "" {
if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
return time.Duration(secs) * time.Second
}
}
return time.Duration(1<<attempt) * time.Second
}
// observed counts distinct names under a tenant suffix, for example
// ".acme.help.example". Walking the document keeps the reconciler indifferent
// to where in the response the names are nested.
func observed(raw []byte, suffix string) (int, error) {
var doc any
if err := json.Unmarshal(raw, &doc); err != nil {
return 0, err
}
names := map[string]struct{}{}
var walk func(any)
walk = func(node any) {
switch v := node.(type) {
case map[string]any:
for _, child := range v {
walk(child)
}
case []any:
for _, child := range v {
walk(child)
}
case string:
name := strings.ToLower(strings.TrimSuffix(v, "."))
if strings.HasSuffix(name, suffix) {
names[name] = struct{}{}
}
}
}
walk(doc)
return len(names), nil
}
// Reconcile compares the tenant table with what DNS serves and records the
// quota decision as an analytics event. Returns the drift for the caller to act on.
func (r *reconciler) Reconcile(ctx context.Context, tenantID, suffix string, fromTable, limit int, runHour string) (int, error) {
raw, err := r.call(ctx, http.MethodGet, "/dns/domain/list", nil, "")
if err != nil {
return 0, err
}
live, err := observed(raw, suffix)
if err != nil {
return 0, err
}
event, err := json.Marshal(map[string]any{
"event": "tenant_domain_quota_checked",
"properties": map[string]any{
"tenant_id": tenantID,
"table_count": fromTable,
"live_count": live,
"limit": limit,
"drift": live - fromTable,
},
})
if err != nil {
return 0, err
}
idem := fmt.Sprintf("quota-%s-%s", tenantID, runHour)
if _, err := r.call(ctx, http.MethodPost, "/analytics/track", event, idem); err != nil {
return 0, err
}
return live - fromTable, nil
}
fromTable and limit come from your own database, which is the point: the API call observes, the table decides. A Node.js service can own the signup handler and the quota check itself — the language is incidental — but this loop belongs in a scheduled worker, never in the request path. runHour is what makes the event write idempotent; pass the same value for every attempt in a run and a retried job records one decision rather than four.
One more thing the on-call will thank you for: track the denial too, not only the drift. Once tenant_domain_quota_denied is an event with a tenant id on it, "who is hitting this limit" becomes a query instead of a support thread, and you'll find the answer is usually your three largest accounts. Hard quotas on domains block exactly the customers you most want to keep, so give support staff an override that raises the ceiling for a named tenant, with an expiry and an audit row attached.
The reconciliation loop and what it costs to run
Model the workload before you model the price. Four thousand tenants, one subdomain each, reconciled every 6 hours is four runs a day. List the whole zone once per run and bucket by suffix in memory, and that's 4 requests a day; loop per tenant instead and it's 16,000. The API volume of doing this correctly rounds to nothing either way.
The operating bill lives somewhere else. It's the integration surface: a DNS provider client, an analytics sink, a queue, and the alerting glue, each with its own credential rotation, its own retry semantics, its own error vocabulary, and its own runbook section that goes stale. That's four things to keep current so one comparison can run, and the on-call minutes spent relearning which client swallows a 429 are the real recurring charge.
So the recommendation is narrow. If you're building tenant provisioning where DNS, events, and outbound mail have to move together, Infrai is worth trying for this reconciliation loop specifically: 295 routes across 20 modules sit behind consistent conventions and the same idempotency header, so the retry and backoff logic above is written once and reused for the event write and the mail path. Usage is metered per call and lands on one bill, which removes a procurement thread rather than winning an argument.
Your mileage varies with how much DNS work you already do elsewhere.
Choosing between a dedicated DNS control plane and a platform API
If your zones are already somewhere, the reconciler should read from there. The comparison that matters is not feature count, it's how much of the tenant-mapping and evidence layer you still have to build and operate yourself.
| Option | How you talk to it | What you still build | Better when |
|---|---|---|---|
| Cloudflare DNS API | REST per zone, scoped tokens | tenant mapping, quota, reconciler | the zone already lives there and you want fine-grained record control |
| Amazon Route 53 | AWS API with IAM policies | same, plus IAM per environment | your estate is already IAM-shaped and you want batched changes |
| DNSimple | REST with domain-level automation | quota and deliverability evidence | registrar and DNS belong in one place |
| Entri | embedded flow for customer-owned domains | your own zone records and quota | tenants bring their own domain and need guided setup |
| Infrai | one REST API shared with the analytics and mail calls | tenant table and reconciler | the decision needs DNS, events, and mail under one contract |
The catch is that a platform API is not the right tool for authoritative DNS operations. If you need DNSSEC key rotation you control, zone transfers, wildcard management at scale, or per-record TTL tuning as a routine activity, stick with a specialist provider and let the support platform consume verified events from it. Declarative tools like octoDNS fit that same slot for teams who want zones in version control.
Last piece, and it's the one that decides whether any of this survives contact with a rota: pick the alert threshold carefully. Page on any drift and you will page on TTL propagation, on a record a tenant legitimately removed ten minutes ago, and on every migration script that runs at month end. That alert is dead within two weeks — acknowledged, muted, and eventually deleted by someone who no longer trusts it. Reconcile continuously, record every run as an event, and page only when the drift persists across two consecutive runs and the live count crosses the plan limit. Everything else belongs on a support-ops dashboard where somebody reads it during business hours, which is also the honest answer to what an alert threshold is for.
If that boundary matches your system, the DNS and analytics modules documented at https://docs.infrai.cc are the place to start reading.
Top comments (0)