Short answer: enforce a tenant's domain limit in the application transaction, reserve a slot before DNS work starts, and count every request that still consumes verification capacity. For a fintech product, three slots is a reasonable starting policy, but the number matters less than making the reservation and deliverability evidence auditable.
The data flow is deliberately boring: normalize the submitted name, lock the tenant row, reserve one slot, then let an asynchronous worker inspect DNS and record evidence. A DNS provider can tell you what is visible in a zone. It cannot consistently enforce a contract that spans tenants, retries, pending checks, and account changes.
Ship the reservation first.
That ordering matters in a fintech onboarding flow because the expensive part is rarely the insert. It is the work after the insert: resolver retries, SPF and DKIM inspection, DMARC evidence, support questions, and a cutover that may be scheduled hours later. A tenant can submit two names in parallel from separate browser tabs, or submit a name again after a timeout while the first request is still committed. If the application waits for DNS before reserving capacity, both attempts look harmless until the workers begin. By then the settings screen can promise three domains while four jobs are active. Keeping the reservation in the same transaction as the count makes the contract deterministic, and recording each observation separately keeps the eventual decision explainable.
Where should a Node.js service enforce a tenant domain cap?
Treat the cap as an application invariant. A domain in pending still occupies a slot because it still needs resolver work, support attention, and a possible cutover. A verified domain occupies one too. Rejected requests do not. Released records remain in history, but stop counting after their retention window.
Normalization happens before the count: trim whitespace, lowercase the name, and remove one terminal dot. A unique constraint on (tenant_id, normalized_domain) handles the less obvious duplicate, such as Pay.Example followed by pay.example. Without that constraint, two clicks can consume two slots before either request reaches a worker.
The reservation and the count need one database transaction. In PostgreSQL, locking the tenant row gives concurrent requests a single ordering point. The DNS adapter stays outside that lock; network calls inside a transaction make latency and failure recovery harder to reason about.
type DomainState = "pending" | "verified" | "rejected" | "released";
async function reserveDomain(db: Db, tenantId: string, rawDomain: string) {
const normalized = rawDomain.trim().toLowerCase().replace(/\.$/, "");
return db.transaction(async (tx) => {
const tenant = await tx.one(
"select domain_limit from tenants where id = $1 for update",
[tenantId],
);
const used = await tx.one(
"select count(*)::int as count from domains where tenant_id = $1 and state in ('pending','verified')",
[tenantId],
);
if (used.count >= tenant.domain_limit) {
throw new Error("tenant_domain_limit_reached");
}
return tx.one(
"insert into domains (tenant_id, domain, state) values ($1, $2, 'pending') returning id, domain, state",
[tenantId, normalized],
);
});
}
The important boundary is visible in the code: a reservation exists before a TXT lookup, and the lookup cannot create capacity by itself.
What evidence should make a slot usable?
A successful DNS lookup is not the same as deliverability. The worker should capture the observed record, resolver context, and timestamp, then run the checks your onboarding policy defines. For a fintech sender, that normally includes SPF and DKIM signals plus an accepted DMARC policy. DMARC's purpose and reporting model are specified in RFC 7489; aggregate reports can expose alignment failures that a single test message misses.
Keep evidence attached to the domain attempt, not only in logs. A support engineer needs to answer, months later, which value was observed and when. Store a new observation for each retry rather than overwriting the previous one. That makes a transient DNS cache result distinguishable from a customer removing a record.
A worker should be idempotent. Reprocessing tenant_42 / pay.example updates its evidence and state; it does not insert another reservation. Before a scheduled cutover, re-check that the reservation still belongs to the tenant and is still eligible. A tenant downgrade can happen while the job is sitting in a queue.
Small detail, big consequence: two pending checks leave only one opening in a three-slot account, even when neither check has passed.
How do retries, drift, and tenant changes affect the count?
Retries must preserve identity. Use the domain record's stable identifier as the job key, and make state transitions conditional so an old job cannot move a released record back to verified. A unique key prevents duplicate reservations; a state transition rule prevents stale workers from changing the meaning of that reservation.
A reconciliation job compares the ledger with fresh DNS observations. It can mark evidence stale or open an incident, but it should not silently increase a tenant's capacity or mutate customer DNS. In a fintech audit, the ledger says what the product authorized, while the observation says what public DNS currently shows. They are related facts, not interchangeable ones.
| State | Counts toward cap | Can cut over | Required next action |
|---|---|---|---|
pending |
Yes | No | Run verification and record evidence |
verified |
Yes | Yes, if policy checks pass | Recheck before scheduled changes |
rejected |
No | No | Preserve reason; allow a new attempt |
released |
No after retention | No | Keep history for audit and support |
The table is intentionally conservative. If a product wants unlimited staged candidates, that is a different contract and needs a separate staging quota; mixing staged candidates with active domains makes the advertised limit ambiguous.
How do you choose three slots without making a permanent ceiling?
Start with a default of three when verification capacity and deliverability evidence are the limiting resources. The default is a policy lever, not a claim about every customer. Expose it as tenant configuration, record who changed it, and require a reviewed change for regulated accounts. Measure pending age, rejection reasons, stale evidence, and active domains per tenant. Those signals tell you whether the cap is protecting operations or blocking legitimate expansion.
The limitation is straightforward: a fixed cap cannot represent every organization. A fintech with many legal entities may need a higher, reviewed limit; a self-serve account may need fewer slots and a clearer release path. Raising the number increases SPF, DKIM, DMARC, monitoring, and incident surface area. It also increases the chance that a customer forgets which domain is responsible for a production sender.
My operational rule is short: normalize before counting, reserve under a row lock, count pending and verified states, attach every attempt to evidence, keep provider calls outside the transaction, re-check ownership before cutover, and reconcile asynchronously. If one step is missing, the number in the settings screen is only a suggestion.
Top comments (0)