Self-serve sender domains for multi-tenant SaaS: the state machine and the gaps

Let customers send notifications from their own domain: the register-poll-verify loop, per-record DNS checks in your UI, and the two gaps you have to close yourself.

Design it as a state machine with one row per tenant domain and a poller that owns the transitions. Infrai’s email surface gives you the four calls that machine needs — register and re-check with POST /v1/email/domain/verify, read status with GET /v1/email/domain/get/{domain}, enumerate with GET /v1/email/domain/list, remove on offboarding with DELETE /v1/email/domain/delete/{domain} — and returns the exact DNS records to render in your customer’s setup screen.

Two things it does not give you are the reason this page exists: the domain namespace is account-wide rather than tenant-scoped, and the whole capability sits behind a paid plan. Both are load-bearing for your design, so take them first.

Gap one: nothing scopes a domain to a tenant

GET /v1/email/domain/list returns every domain the account holds, flat. There’s no tenant_id on the record and no filter parameter, which means the mapping from customer to domain lives in your database and the ownership check is yours to enforce.

That’s more important than it sounds.

If your onboarding endpoint takes a domain string from a tenant and passes it through, a customer can register mail.a-competitor.example and see the DNS records for it, or — worse — collide with a domain another tenant on your platform already set up. The fix isn’t clever: a unique constraint on domain in your own table, checked inside the same transaction that calls the API, plus a DNS-based ownership proof before you’ll even show the records. Note also that domain/list puts its payload under data.records while email/list, event/list and suppression/list all use data.items, so a shared unwrapping helper will quietly return nothing here.

Gap two: this is a paid capability

On a standard key, registration doesn’t get as far as a DNS lookup:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/email/domain/verify" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"domain":"mail.tenant-acme.example"}'
{
  "ok": false,
  "error": {
    "code": "PRO_REQUIRED",
    "http_status": 402,
    "message": "custom sender domains are Pro-only; standard accounts have 0 custom sender domains",
    "retryable": false
  }
}

We reproduced that on 2026-07-26, and the same 402 fires on POST /v1/email/send with a custom from before any DNS is inspected. So “customers bring their own domain” is a Pro-plan feature by construction — worth knowing before it appears on a roadmap slide, and worth surfacing as a clear upgrade path in your own product rather than as an opaque 402 in a log.

The states, and what each one shows the customer

StateWhat your poller doesWhat the tenant sees
requestedcall verify, store domain_idthe DNS records, with copy buttons
pending_dnsre-call verify on a backoffper-record ticks, not one spinner
verifiedstop polling, allow the from”sending from your domain”
failedstop, surface which record failedthe one record that’s wrong
removingcall delete, then drop the rownothing

The per-record detail is what turns a support ticket into self-service, and the API hands it to you directly:

curl -sS "https://api.infrai.cc/v1/email/domain/get/mail.example.com" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "verification": {
      "domain": "mail.example.com",
      "domain_id": "dom_Arov0eH2udgqOcYTGu0MtvvB",
      "status": "pending_dns",
      "dns_records": [
        { "type": "TXT", "name": "mail.example.com", "value": "v=spf1 include:_spf.infrai.cc ~all", "purpose": "spf", "ttl_recommended": 3600 },
        { "type": "TXT", "name": "cf._domainkey.mail.example.com", "value": "v=DKIM1;k=rsa;p=MIIBIj...AB", "purpose": "dkim", "ttl_recommended": 3600 },
        { "type": "CNAME", "name": "track.mail.example.com", "value": "tracking.infrai.cc", "purpose": "tracking", "ttl_recommended": 3600 },
        { "type": "TXT", "name": "_dmarc.mail.example.com", "value": "v=DMARC1;p=none;rua=mailto:dmarc@infrai.cc", "purpose": "dmarc", "ttl_recommended": 3600 }
      ],
      "warm_up_state": "not_started",
      "daily_limit_current": 50000,
      "daily_limit_target": 500000
    },
    "reputation": null
  }
}

Render checks — a verified domain returns per-record verdicts like spf_dns, dkim_dns, tracking_cname, dmarc_dns and mail_loopback — as a checklist rather than a single status pill. A customer whose DNS provider silently appends the apex to a CNAME can then see exactly which row is red instead of filing a ticket that says “it doesn’t work”.

And note reputation: null on an unverified domain. Anything in your UI that reads a bounce rate has to tolerate that.

The onboarding service, in TypeScript

// tenant-domains.ts — Node 22, no dependencies beyond your own store.
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const headers = {
  authorization: `Bearer ${KEY}`,
  "content-type": "application/json",
};

export type DomainState = "requested" | "pending_dns" | "verified" | "failed";

export interface DnsRecord {
  type: string;
  name: string;
  value: string;
  purpose: string;
  ttl_recommended: number;
}

export interface TenantDomain {
  tenantId: string;
  domain: string;
  domainId?: string;
  state: DomainState;
  records: DnsRecord[];
  checks: Record<string, string>;
}

async function callApi(path: string, init: RequestInit = {}): Promise<any> {
  const res = await fetch(`${API}${path}`, { headers, ...init });
  const payload = await res.json().catch(() => ({}));
  if (res.status === 402) {
    throw new Error("PRO_REQUIRED: custom sender domains need a paid plan");
  }
  if (!res.ok || payload.ok === false) {
    throw new Error(payload?.error?.code ?? `HTTP_${res.status}`);
  }
  return payload.data;
}

/** Register or re-check. Safe to call repeatedly; that is how verification advances. */
export async function checkDomain(tenantId: string, domain: string): Promise<TenantDomain> {
  const normalized = domain.trim().toLowerCase();
  if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(normalized)) {
    throw new Error(`refusing to register malformed domain: ${domain}`);
  }
  const data = await callApi("/v1/email/domain/verify", {
    method: "POST",
    body: JSON.stringify({ domain: normalized }),
  });
  const status = String(data.status ?? "pending_dns");
  return {
    tenantId,
    domain: normalized,
    domainId: data.domain_id,
    state: status === "verified" ? "verified" : "pending_dns",
    records: (data.dns_records ?? []) as DnsRecord[],
    checks: (data.checks ?? {}) as Record<string, string>,
  };
}

export async function offboard(domain: string): Promise<void> {
  await callApi(`/v1/email/domain/delete/${encodeURIComponent(domain)}`, { method: "DELETE" });
}

export function nextPollDelayMs(attempt: number): number {
  const schedule = [30_000, 60_000, 300_000, 900_000, 3_600_000];
  return schedule[Math.min(attempt, schedule.length - 1)];
}

The regex is not decoration. A tenant-supplied string reaches a mutating API call here, and the platform’s registration routes are permissive — POST /v1/email/domain/rotate_dkim/{domain} in particular behaves as an upsert, so calling it with a domain the account has never seen creates the record rather than returning a 404. Never wire a tenant field straight into that route.

Polling deserves its own note. DNS records come back with a recommended TTL of 3600 seconds, so a customer who fixes a typo may wait an hour for the world to agree. Backing off from 30 seconds to an hour, and stopping after roughly 72 hours with a “we couldn’t verify” email, costs nothing — verification calls are free and rate-limited — and keeps you from an infinite poller per abandoned tenant.

Three ways to build this, honestly compared

ApproachIsolationOperational loadWhere it hurts
One account, many tenant domains (this page)logical, in your databaselowestyou own tenant scoping and ownership proof
Per-tenant subaccounts, e.g. SendGrid subusersprovider-enforcedone account object per tenantmore moving parts, plan cost per subuser
Tenant brings their own provider keytotalhighestyou support every provider your customers pick

Subusers are the honest recommendation if regulated isolation is a requirement — SendGrid’s model gives each tenant its own sender identity, IP assignment and reputation, and that’s not something a logical mapping can imitate. Postmark is the pick at the other end: if your tenants are few and high-value and someone will hand-hold each domain setup, its message streams and support are worth more than any API you’d build around them. For most B2B SaaS in between, the per-tenant blast radius that actually matters is the suppression list and the bounce rate, and a shared account with strict ownership checks is a smaller system to run.

The drawback of the shared-account approach is real and you should write it down: one tenant’s bad list hurts the sending reputation of every domain on the account.

What it costs, per tenant

Domain registration, status reads, listing, deletion and DKIM rotation are all free and rate-limited, so the onboarding flow itself costs nothing however many times a customer retries it. Only sends are metered — $0.000115 per email, verified 2026-07-26 and marked approximate — and the per-call metadata.cost_usd on each response is what makes per-tenant attribution a query rather than a spreadsheet. Read today’s rate rather than trusting this paragraph, since these drift downward:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c 'import json,sys
for c in json.load(sys.stdin)["capabilities"]:
    if c["id"].startswith("email.domain") or c["id"] == "email.send":
        print(c["id"], c["billing"].get("price_usd", "free"))'

Check what the account currently holds at any point with a single free call:

curl -sS "https://api.infrai.cc/v1/email/domain/list" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The argument for building tenant domains here isn’t the rate. It’s that the poller’s schedule, its retry queue, the errors it raises and the per-tenant usage report your finance team wants all sit on the same key — one bill, one usage view, and no second vendor relationship the day a customer asks who processes their mail.

References

Browse more email developer guides