BetterIAM
Server API

domains

Verified email domains let an organization prove it owns a domain such as acme.com, so your sign-in page can find the right tenant from a work email address.

Verified email domains let an organization prove it owns a domain such as acme.com, so your sign-in page can find the right tenant from a work email address. People do not know tenant IDs, and every sign-in names a tenant, so "use your work email" needs a trustworthy mapping from domain to organization. This group creates that mapping and answers the public lookup. It is step one of enterprise onboarding.

How domain verification works

  1. An administrator claims a domain with add. The claim starts as pending and comes with a DNS TXT record to publish: the name _better-iam-challenge.{domain} with the value better-iam-verification={token}.
  2. After publishing the record, the administrator calls verify. The server looks the record up and, when the value matches, marks the domain verified.
  3. From then on, the public discover call maps email addresses at that domain to the organization.

A verified domain belongs to exactly one tenant. Several tenants may hold pending claims to the same domain, but only the first to verify owns it; the others then fail with DOMAIN_TAKEN. Consumer mailbox providers (Gmail, Outlook, iCloud, and similar) can never be claimed, because their addresses belong to individuals, not one organization. Deployment options under domains change the TXT label (recordName), replace the blocked list (blockedDomains), or inject the DNS resolver (resolveTxt), for example to use DNS over HTTPS.

Domains are normalized before use: lowercase, no trailing dot, and at least two labels with an alphabetic top-level domain. Claims are removed when the tenant is purged.

Methods5
Serveriam.api.domains
Clientclient.domains
HTTPPOST /api/iam/domains/*
MethodWhat it doesAccess
addClaims a domain for the tenant and returns the TXT record the organization must publish to prove control.Credential
deleteReleases a domain claim; a verified domain stops resolving to the tenant immediately.Credential
discoverFinds the organization that verified an email address's domain, with the sign-in rules it enforces.Public
listLists the tenant's claimed domains, newest first, with their status and the TXT record each one needs.Credential
verifyLooks up the domain's TXT record and marks the claim verified when the record matches.Credential

add

Claims a domain for the tenant and returns the TXT record the organization must publish to prove control.

POST/api/iam/domains/add
client.domains.add()Credential

Used inEnterprise onboarding

  • Permission: iam:domains:create on iam/domains/{domain}.
  • Audited as: iam:domains:create.
  • Errors: INVALID_INPUT when the value is not a domain such as example.com; DOMAIN_NOT_ALLOWED for a shared mailbox provider on the blocked list; CONFLICT when this tenant already claimed the domain; DOMAIN_TAKEN when another tenant has verified it.

The result carries dnsRecord (type, name, value); show it to the administrator exactly as returned. The claim grants nothing until it is verified.

const claimed = await iam.api.domains.add(credential, { tenantId, domain: 'acme.com' });
// Publish claimed.dnsRecord at the DNS provider, then call domains.verify.
Input

Prop

Type

Returns

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/domains/add" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "domain": "<domain>"
}'
Signature
iam.api.domains.add(
  credential: CredentialInput,
  input: { tenantId: string; domain: string },
): Promise<{
  id: string;
  tenantId: string;
  domain: string;
  status: 'pending' | 'verified';
  createdAt: number;
  createdBy: string;
  verifiedAt: number | undefined;
  lastCheckedAt: number | undefined;
  dnsRecord: { type: 'TXT'; name: string; value: string };
}>

delete

Releases a domain claim; a verified domain stops resolving to the tenant immediately.

POST/api/iam/domains/delete
client.domains.delete()Credential
  • Permission: iam:domains:delete on iam/domains/{domain}.
  • Audited as: iam:domains:delete.
  • Errors: NOT_FOUND when the claim is not in this tenant.

Releasing a verified domain frees it, so another organization can then claim and verify it.

Input

Prop

Type

Returns

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/domains/delete" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "domainId": "<domainId>"
}'
Signature
iam.api.domains.delete(
  credential: CredentialInput,
  input: { tenantId: string; domainId: string },
): Promise<{ deleted: boolean }>

discover

Finds the organization that verified an email address's domain, with the sign-in rules it enforces.

POST/api/iam/domains/discover
client.domains.discover()Public

Used inEnterprise onboarding,OAuth and OIDC sign-in

  • Permission: None: public. No credential is needed.
  • Errors: INVALID_INPUT when email has no @ or the domain is malformed; NOT_FOUND when no active organization verified the domain; WRONG_REGION (421) when another region serves the organization.

This is home-realm discovery for a login screen: the person types their email, you call discover, and you send them to the right tenant with the right method. The answer carries the tenant's tenantId, name, type, alias (slug, when set), allowedMethods (null means every method the deployment enables), and requireMfa, plus its home region and signInUrl when organization addresses or regions are configured. Pass either email or domain. In a multi-region deployment, an organization homed elsewhere answers WRONG_REGION with its sign-in URL there, so a global sign-in page can send the person on.

Unknown, pending, released, and inactive domains, and tenants under an inactive ancestor, all answer the same NOT_FOUND, so the call does not reveal which of these applies. The result is public discovery data by design; apply ingress rate limits as you would for tenants.lookup.

const org = await client.domains.discover({ email: 'alice@acme.com' });
await client.auth.signIn({ tenantId: org.tenantId, email: 'alice@acme.com', password });
Input

Prop

Type

Returns

A DomainDiscovery object:

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/domains/discover" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{}'
Signature
iam.api.domains.discover(
  input: { email?: string; domain?: string },
): Promise<DomainDiscovery>

list

Lists the tenant's claimed domains, newest first, with their status and the TXT record each one needs.

POST/api/iam/domains/list
client.domains.list()Credential
  • Permission: iam:domains:read on iam/domains/*.
  • Audited as: iam:domains:read.

Each entry shows status (pending or verified), when it was verified, and when the DNS record was last checked (lastCheckedAt), which helps an administrator see whether a failed verification was retried.

Input

Prop

Type

Returns

An array of object.

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/domains/list" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>"
}'
Signature
iam.api.domains.list(
  credential: CredentialInput,
  input: { tenantId: string },
): Promise<{
  id: string;
  tenantId: string;
  domain: string;
  status: 'pending' | 'verified';
  createdAt: number;
  createdBy: string;
  verifiedAt: number | undefined;
  lastCheckedAt: number | undefined;
  dnsRecord: { type: 'TXT'; name: string; value: string };
}[]>

verify

Looks up the domain's TXT record and marks the claim verified when the record matches.

POST/api/iam/domains/verify
client.domains.verify()Credential

Used inEnterprise onboarding

  • Permission: iam:domains:update on iam/domains/{domain}.
  • Audited as: iam:domains:update.
  • Errors: NOT_FOUND when the claim is not in this tenant; DOMAIN_TAKEN when another tenant verified the domain first.

A missing or not-yet-visible record is not an error: the call returns verified: false, records lastCheckedAt, and leaves the claim pending, so you can retry after DNS propagates. The DNS lookup runs before the database transaction opens, and a failed lookup counts as "not found". Verifying a domain that is already verified returns verified: true without another lookup.

Input

Prop

Type

Returns

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/domains/verify" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "domainId": "<domainId>"
}'
Signature
iam.api.domains.verify(
  credential: CredentialInput,
  input: { tenantId: string; domainId: string },
): Promise<{
  verified: boolean;
  domain: {
    id: string;
    tenantId: string;
    domain: string;
    status: 'pending' | 'verified';
    createdAt: number;
    createdBy: string;
    verifiedAt: number | undefined;
    lastCheckedAt: number | undefined;
    dnsRecord: { type: 'TXT'; name: string; value: string };
  };
}>

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page