Skip to content
BuildBase

Domains and joining

How a customer company's verified domains decide who is its own - joining by email domain, requests to join, claiming accounts and required SSO.

A company you sell to rarely arrives with a clean slate. Many of its people already have accounts in your app, made with a password or a Google sign-in long before anyone in IT heard of you, and the contractors it works with sit in the same workspaces as its staff. Once the company's email domains are verified, those domains answer both questions: who is the company's, and who is not.

This builds on Enterprise SSO for the company: set that up first, if you have not.

This page covers how a domain gets verified (WorkOS does it), who joins the company by their email, the accounts the company can claim, what people outside it may do in its workspaces, and how required single sign-on reaches everyone on the domain.

Before you start

Enterprise SSO set up for the company. Joining, claiming and the outsider rules key on its verified domains, so they do nothing until at least one is verified.

Verified domains

WorkOS verifies every company domain. BuildBase publishes no DNS record of its own and reads nobody's DNS: it adds the domain to the company's WorkOS organization, and WorkOS's verdict is what counts.

The console showsWhat it means
PendingOn the company's WorkOS organization, not verified yet. It routes nobody
VerifiedWorkOS found the record. People on the domain sign in through the company, and everything below applies to them
Not verifiedWorkOS looked and did not find the record. The domain shows what WorkOS reported, and routes nobody

A company with several identity providers sends each verified domain, exact host, to its own, or to the default when it has none. A domain is never signed in by an identity provider it is not sent to, and one whose identity providers are all inactive or deleted has nobody to sign it in until it is sent to another.

From the enterprise's Email domains section:

  1. Add domain creates it on the company's WorkOS organization. A domain the organization already holds, because the IT admin added it in the Admin Portal, is taken as it is.
  2. The company's IT admin publishes the record WorkOS's Admin Portal shows them (what they see). Open the portal on the domain step with Verify in WorkOS (a five-minute link, for a call), or send an IT-admin invitation with Domain verification ticked. From @buildbase/sdk 1.0.0, the company's own admins can also open it with Verify in WorkOS in your app.
  3. Verify asks WorkOS to check. WorkOS answers while it is still checking, so the usual answer is "WorkOS is checking". Its organization_domain event then moves the domain to Verified or Not verified. Refresh from WorkOS reads WorkOS's answer on demand, and a daily read catches any event that went missing.

How fast WorkOS's verdict lands depends on your WorkOS webhook. With one registered from the console, the event wakes your server within seconds. Without one, your server reads WorkOS's events every six hours. Refresh from WorkOS never waits for either. An endpoint registered before it carried the domain events needs Register again (the webhook).

The rules:

  • A domain counts only while WorkOS verifies it. If WorkOS stops verifying one, or the IT admin deletes it in the Admin Portal, its people stop signing in through the company at the next read, and enterprise.domain_removed says so.
  • A domain the IT admin adds and verifies in the Admin Portal appears on its own, at the next event, refresh or daily read.
  • Removing a domain removes it in WorkOS too, so the portal and your app agree. If WorkOS cannot be reached, the domain is removed here and the console asks you to remove it in the WorkOS dashboard as well.
  • A domain another of your enterprises holds is never taken. Adding it is refused with 409 domain_taken; a read from WorkOS that finds it on this company's organization leaves it out and lists it under refused.
  • Some domains are refused before WorkOS is asked: consumer mail domains such as gmail.com (400 domain_consumer), IP addresses (domain_ip_literal), public suffixes (domain_public_suffix) and anything that is not a domain name (domain_invalid). A domain WorkOS itself refuses is 400 domain_refused_by_workos with WorkOS's sentence, and WorkOS out of reach is 502 workos_error.
  • A subdomain is a domain of its own.
# Console routes: an org API key with the enterprises resource
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"domain":"acme.com"}' \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/domains"

# Ask WorkOS to check it
curl -X POST -H "Authorization: Bearer $API_KEY" \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/domains/acme.com/verify"

# Read WorkOS's verdict back now
curl -X POST -H "Authorization: Bearer $API_KEY" \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/domains/sync"

Verify answers a status: verified, checking (WorkOS has the request and is looking), failed with an error to show, or pending with an error when WorkOS could not be asked at all. Sync answers { added, removed, refused }: the domains that count now and did not, the ones that stopped counting, and the ones another enterprise holds. DELETE .../domains/<domain> answers { workosSynced, leftVerified }; with no verified domain left, the connection goes inactive until another is verified.

Joining by email domain

The access policy's Someone on a verified domain signs in (domainJoin) decides what happens to someone with an account on one of the company's verified domains who is not yet one of its people:

SettingdomainJoinWhat happens
Off (the default)offThey stay outside the company. Only single sign-on and the directory make someone its own
Join automaticallyjoinThey become one of its people, and join the bound workspaces set to join automatically
Ask to joinrequestThey ask to join, and someone who manages its people approves or declines (below)

It is applied when such a person signs in by any door but SSO (password, magic link, social or passkey), and when they verify their email. Only a verified email counts: an address someone typed and never confirmed joins nothing. An SSO sign-in does not need it, because it links the account on its own. An account linked to another company is never touched, a blocked one never joins, and nothing here ever fails a sign-in.

Someone who joins this way is one of the company's people from that sign-in on: the session it makes already carries the company, and the access policy, the trail, company roles and groups apply to them as to someone SSO made. Their account records how (provisionedBy: "domain").

With Require single sign-on on, this setting rarely comes into play. Everyone on the domain is sent to SSO at the other doors (below), and their first SSO sign-in makes them the company's anyway. While it is on, nothing but SSO makes them its own: verifying an email neither joins the company nor asks to.

Requests to join

With Ask to join, a sign-in on the domain records a request instead:

  • one waiting request per person and company;
  • someone declined is not asked about again for 30 days;
  • a company holds at most 1,000 waiting requests. Past that, people on the domain still sign in, and no request is recorded.

The requests are listed in the enterprise's Joining section in the console and on the company API, waiting ones first, then the last ones decided; the company's own admins get them in your app (from SDK 1.0.0). Approve makes the person one of the company's and joins them to the bound workspaces set to join automatically, and the answer says how many (workspaces). Decline leaves them as they are. A request someone else already decided answers 409 already_decided. Approving checks the person again first: someone linked to a company since they asked, blocked, or no longer on a verified domain answers 409 cannot_join, and the request stays waiting until it is declined.

Claiming accounts already on the domains

The accounts that matter most are usually the ones made before the company became your customer. Someone at Acme who signed up with a password last spring is on acme.com and outside every rule Acme sets. The Joining section lists them as accounts on the domains that are not the company's yet: newest first, 50 a page, searchable by email or name, with the total. Only an account whose email was verified is listed or brought in, as at a sign-in.

Bring them in up to 100 a call, picked or all of them: with all of them, remaining says how many are left, so call again until it reads 0. Each one:

  • becomes one of the company's people;
  • joins the bound workspaces set to join automatically;
  • is signed out of every session and every connected app or agent, so their next sign-in meets the company's policy and, when it requires one, its SSO.

Nobody is emailed. To tell them, hook a workflow to enterprise.person_joined with joinedBy equal to claim. An account linked to another company is never touched; the answer counts it as skipped, beside claimed and remaining.

People outside the company

An outsider is a member of a workspace bound to the company who is neither one of its people nor on one of its verified domains: a contractor, an agency, your own support staff. The access policy's People outside the company in its workspaces (outsiders) decides what they may do there:

Settingoutsiders.modeNew outsidersOutsiders already there
Allowed (the default)allowedInvited and added at any roleNothing changes
LimitedlimitedInvited, added and given roles up to maxRole only: editor or viewer (the default)Anyone above maxRole is lowered to it when the policy is saved, and the company's IP allowlist applies to them
BlockedblockedCannot be invited or addedRefused in its workspaces, which also leave their workspace list. Their membership is kept, so changing the policy back lets them in again

The workspace's owner is never refused or lowered: they hold its billing. A custom workspace role is judged by what it may do in that workspace: it is within the cap only when the cap role holds every permission it holds. A custom role starts from editor's permissions, so it is within an editor cap unless it was given more, and is refused under a viewer cap, since it can record usage and viewer cannot.

Where it is enforced:

  • Inviting, adding and changing a role in a bound workspace: 403 outsiders_blocked, or 403 outsider_role_limit carrying maxRole.
  • Accepting an invitation checks again, because the policy may have changed after it went out: a blocked outsider is refused, and a limited one joins at maxRole.
  • Every request to a bound workspace: a blocked outsider gets 403 outside_company, and a limited one outside the company's IP allowlist gets 403 ip_not_allowed. Each refusal is in the company's trail as enterprise_outsider_refused, once an hour per person, workspace and reason.

A change reaches every server within a minute: each keeps the policy, and which workspaces are bound to whom, for 60 seconds. A role the policy lowers is a workspace.member_role_changed event with source: "policy", and in the trail with via: "policy".

The Joining section also lists the outsiders: the person, the workspace, their role and when they joined, with the owner marked as exempt. GET .../outsiders answers { outsiders, total }, at most 200 rows. It reads up to 5,000 memberships of the bound workspaces; past that it adds truncated: true, and total counts the outsiders among those it read.

Required SSO reaches the whole domain

Require single sign-on is not limited to people SSO has linked. It reaches every account whose email is on one of the company's verified domains: someone who signed up with a password before the company arrived is refused at the other doors with sso_required, like everyone else, and their first SSO sign-in links the account. The refusal is in the trail as enterprise_sso_required_refusal with byDomain: true.

Break-glass still covers linked people only, since the list names the company's own people.

In your app

Apps on @buildbase/sdk 1.0.0 or later show two more sections on the Single sign-on screen:

  • EnterpriseDomains (useEnterpriseDomains): the company's domains with WorkOS's verdict on each, Verify in WorkOS, which opens the Admin Portal on the domain step for the IT admin, and Refresh from WorkOS. When WorkOS sends the IT admin back from the domain step, the screen reads the domains back on its own.
  • EnterpriseJoining (useEnterpriseJoinRequests, useEnterpriseUnclaimed, useEnterpriseOutsiders): requests to join with Approve and Decline, the accounts on the company's domains with a way to bring them in, and the people outside the company in its workspaces.

The access policy section gains the joining and outsider settings in the same release. Hide either section with ui.settings.enterprise.domains or ui.settings.enterprise.joining set to false.

On an older SDK the routes below work with the member's session. The Single sign-on screen's status (GET /api/v1/public/workspaces/<workspace id>/enterprise) answers domains, each { domain, status, source, verifiedAt, lastError }, and the setup link takes intent: "domain_verification".

Over the API

RouteConsole (org API key)Your app (the member's company role)Company API (scope)
POST /domains, /domains/<domain>/verify, DELETE /domains/<domain>YesNoNo
POST /domains/syncYesSetup at Can manageNo
GET /join-requestsYesPeople at Can viewpeople:read
POST /join-requests/<request id>/approve, /declineYesPeople at Can managepeople:write
GET /unclaimedYesPeople at Can viewpeople:read
POST /claimYesPeople at Can managepeople:write
GET /outsidersYesPeople at Can viewpeople:read

The routes sit under /api/auth/enterprise/enterprises/<enterprise id> in the console, /api/v1/public/workspaces/<workspace id>/enterprise in your app, and /api/v1/company for a company API key. In the console, each of these but the domain routes needs an org API key with the users resource as well as the enterprises one, as the enterprise's people list does. While Let the company's admins change this in the app is off, deciding requests and claiming accounts from your app or with a company key are refused with 403 managed_by_app, and your team does it in the console.

GET /unclaimed takes search and page, and answers { people, total, domains }. POST /claim takes { "userIds": [...] } or { "all": true }. GET /join-requests answers { requests, pending, max }.

domainJoin and outsiders are fields of the access policy, so they ride the policy routes: PUT .../policy in the console and your app, and policy:read and policy:write for a company key.

# Ask to join, and hold outsiders to viewer (console route)
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"domainJoin":"request","outsiders":{"mode":"limited","maxRole":"viewer"}}' \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/policy"

# The company's own tool brings in every account on its domains, 100 a call
curl -X POST -H "Authorization: Bearer $COMPANY_KEY" -H "Content-Type: application/json" \
  -d '{"all":true}' \
  "https://<your server>/api/v1/company/claim"

Events and the trail

Each of these is a webhook event and a workflow trigger:

EventWhenPayload
enterprise.person_joinedSomeone became one of the company's people by their email domainenterpriseId, name, userId, email, joinedBy (domain, request or claim), workspaces joined
enterprise.join_requestedSomeone on its domains asked to joinenterpriseId, name, requestId, userId, email
enterprise.join_request_declinedA request to join was declinedThe same
enterprise.domain_removedA verified domain stopped counting: removed in the console, or deleted or unverified in WorkOSenterpriseId, name, domain, removedBy (console or workos)
enterprise.domain_verifiedWorkOS verified one of its domainsenterpriseId, domain, via: "workos", and actorId when someone's Verify or Add did it

In the company's audit trail, under the Activity filter Joining:

EntryWritten when
enterprise_person_joinedSomeone joined by their domain, with email, joinedBy and how many workspaces
enterprise_join_requested, enterprise_join_request_declinedSomeone asked to join, or was declined, with email and requestId
enterprise_outsider_refusedAn outsider was refused in a bound workspace, with the workspace, email and reason (outside_company or ip_not_allowed)

A join by domain is a workspace.member_added event with source: "domain", and a claim's with source: "domain_claim", which emails nobody. The domain entries themselves (enterprise_domain_added, enterprise_domain_verified, enterprise_domain_removed with removedBy) stay under setup changes.