# 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](/sign-in-setup/enterprise-sso) 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.

> **Note:**
  [Enterprise SSO](/authentication/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 shows | What it means                                                                                                   |
| ----------------- | --------------------------------------------------------------------------------------------------------------- |
| Pending           | On the company's WorkOS organization, not verified yet. It routes nobody                                        |
| Verified          | WorkOS found the record. People on the domain sign in through the company, and everything below applies to them |
| Not verified      | WorkOS looked and did not find the record. The domain shows what WorkOS reported, and routes nobody             |

A company with [several identity providers](/authentication/enterprise-sso#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](/authentication/enterprise-sso-it-admin#verify-your-company-s-domains)). 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](/authentication/enterprise-provisioning#how-changes-arrive)).

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.

```bash
# 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:

| Setting            | `domainJoin` | What happens                                                                                           |
| ------------------ | ------------ | ------------------------------------------------------------------------------------------------------ |
| Off (the default)  | `off`        | They stay outside the company. Only single sign-on and the directory make someone its own              |
| Join automatically | `join`       | They become one of its people, and join the bound workspaces set to join automatically                 |
| Ask to join        | `request`    | They ask to join, and someone who manages its people approves or declines ([below](#requests-to-join)) |

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](#required-sso-reaches-the-whole-domain)), 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:

| Setting               | `outsiders.mode` | New outsiders                                                                           | Outsiders already there                                                                                                                    |
| --------------------- | ---------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Allowed (the default) | `allowed`        | Invited and added at any role                                                           | Nothing changes                                                                                                                            |
| Limited               | `limited`        | Invited, 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                           |
| Blocked               | `blocked`        | Cannot be invited or added                                                              | Refused 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](/authentication/enterprise-sso#single-sign-on-inside-your-app):

- **`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

| Route                                                                   | Console (org API key) | Your app (the member's company role) | Company API (scope) |
| ----------------------------------------------------------------------- | --------------------- | ------------------------------------ | ------------------- |
| `POST /domains`, `/domains/<domain>/verify`, `DELETE /domains/<domain>` | Yes                   | No                                   | No                  |
| `POST /domains/sync`                                                    | Yes                   | Setup at Can manage                  | No                  |
| `GET /join-requests`                                                    | Yes                   | People at Can view                   | `people:read`       |
| `POST /join-requests/<request id>/approve`, `/decline`                  | Yes                   | People at Can manage                 | `people:write`      |
| `GET /unclaimed`                                                        | Yes                   | People at Can view                   | `people:read`       |
| `POST /claim`                                                           | Yes                   | People at Can manage                 | `people:write`      |
| `GET /outsiders`                                                        | Yes                   | People at Can view                   | `people: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](/authentication/enterprise-company-api). 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.

```bash
# 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](/webhooks/overview) and a [workflow trigger](/workflows/triggers-and-actions):

| Event                              | When                                                                                           | Payload                                                                                                     |
| ---------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `enterprise.person_joined`         | Someone became one of the company's people by their email domain                               | `enterpriseId`, `name`, `userId`, `email`, `joinedBy` (`domain`, `request` or `claim`), `workspaces` joined |
| `enterprise.join_requested`        | Someone on its domains asked to join                                                           | `enterpriseId`, `name`, `requestId`, `userId`, `email`                                                      |
| `enterprise.join_request_declined` | A request to join was declined                                                                 | The same                                                                                                    |
| `enterprise.domain_removed`        | A verified domain stopped counting: removed in the console, or deleted or unverified in WorkOS | `enterpriseId`, `name`, `domain`, `removedBy` (`console` or `workos`)                                       |
| `enterprise.domain_verified`       | WorkOS verified one of its domains                                                             | `enterpriseId`, `domain`, `via: "workos"`, and `actorId` when someone's **Verify** or **Add** did it        |

In the company's [audit trail](/authentication/enterprise-sso#the-enterprise-s-audit-trail), under the Activity filter **Joining**:

| Entry                                                           | Written when                                                                                                                     |
| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `enterprise_person_joined`                                      | Someone joined by their domain, with `email`, `joinedBy` and how many `workspaces`                                               |
| `enterprise_join_requested`, `enterprise_join_request_declined` | Someone asked to join, or was declined, with `email` and `requestId`                                                             |
| `enterprise_outsider_refused`                                   | An 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.
