---
title: Registration modes
path: concepts/registration-modes
status: published
---

# Registration modes

How do new customers become tenants on this deployment? ScaiControl gives the operator one knob — `registration_settings.mode` — with four positions. This page explains what each one does, how they relate to invitations and waitlists under the hood, and what to expect when you flip between them.

## The four modes

| Mode | Who can register? | What's open to the public? |
|---|---|---|
| `disabled` | Nobody. Existing users continue to log in. | Nothing. Sign-up and waitlist endpoints refuse. |
| `self_serve` | Anyone with an email passing the allow/block-list policy. | `POST /auth/sign-up` + the portal sign-up form. |
| `invitation` | Only recipients of an admin-issued invitation token. | Nothing public except `POST /auth/accept-invitation`. |
| `waitlist` | Anyone can request access; admin manually approves. | The waitlist form. Approval auto-issues an invitation. |

Switching modes is one HTTP call (`PUT /admin/platform/registration-settings`) or one click in the Settings tab. The Settings tab also asks for confirmation before mode changes that would leave existing records stranded (e.g. switching away from `waitlist` while pending entries are queued).

## Mental model: invitations are the backbone

Regardless of which mode is active, **every new tenant ends up routed through the same `register_tenant()` service function** — which creates the tenant ID, the first `tenant_admin` user (status `pending`), and emits `registration.completed.v1`. The modes differ only in *how the request enters the system*:

```mermaid
flowchart LR
    subgraph self_serve [Self-serve]
        S1[Public sign-up form] --> S2[POST /auth/sign-up] --> S3[register_tenant]
    end
    subgraph invitation [Invitation]
        I1[Admin issues invitation] --> I2[Email with token]
        I2 --> I3[Recipient clicks link] --> I4[POST /auth/accept-invitation] --> I5[register_tenant]
    end
    subgraph waitlist [Waitlist]
        W1[Public waitlist form] --> W2[Entry queued]
        W2 --> W3[Admin approves]
        W3 --> W4[Auto-issues invitation] --> W5[Recipient clicks link] --> W6[POST /auth/accept-invitation] --> W7[register_tenant]
    end
    S3 --> X[ScaiKey pre-create user]
    I5 --> X
    W7 --> X
    X --> Y[ScaiKey welcome+set-password email]
```

Note the `waitlist → invitation` arrow. Approving a waitlist entry **issues an invitation under the hood** — same code path, same email template, same accept URL. Less code to maintain; the recipient sees a consistent experience whether they were invited directly or pulled off the queue.

## State machines

### Invitations

```mermaid
stateDiagram-v2
    [*] --> pending
    pending --> accepted: recipient completes flow
    pending --> expired: cron flips past expires_at
    pending --> revoked: admin revokes
    accepted --> [*]
    expired --> [*]
    revoked --> [*]
```

Terminal states are absorbing. The expiry cron runs hourly at `:41`; rows past `expires_at` move to `expired` and emit `invitation.expired.v1`.

### Waitlist entries

```mermaid
stateDiagram-v2
    [*] --> pending
    pending --> approved: admin approves (issues invitation)
    pending --> rejected: admin rejects (optional reason)
    pending --> duplicate: email already known
    approved --> [*]
    rejected --> [*]
    duplicate --> [*]
```

`duplicate` is auto-assigned at join time when the email is already a `User` row or already has a pending `Invitation`. The public response shape is identical to a fresh `pending` — the submitter doesn't learn that their email is on file.

## Cross-cutting policy: email-domain allow/block

Both `allowed_email_domains` and `blocked_email_domains` are enforced at every entry point (sign-up, invitation issue, waitlist join). Blocked always wins over allowed: a domain in both lists is rejected.

- Leave both empty to allow any email.
- Set `allowed_email_domains` to a list to lock the deployment to specific organisations (e.g. `["acme.com"]` for an Acme-internal tenant pool).
- Set `blocked_email_domains` to shut down known abusive sources.

The policy is settings-driven so changing it doesn't require a deploy — the next sign-up reads the updated list.

## Cross-cutting policy: idempotency

`register_tenant()` is idempotent on email. If a user with the submitted email already exists, the function returns that user's tenant ID rather than creating a duplicate. This means:

- Retried form submissions don't create duplicate tenants.
- The same response shape is returned whether the email is new or known — useful for not leaking the deployment's user base via the public sign-up endpoint.

## Events fired

Operators wiring these to ScaiCRM should know which topics fire for which transition:

| Mode action | Events emitted |
|---|---|
| Self-serve sign-up | `registration.completed.v1` |
| Admin issues invitation | `invitation.issued.v1` |
| Recipient accepts invitation | `invitation.accepted.v1` + `registration.completed.v1` |
| Admin revokes invitation | `invitation.revoked.v1` |
| Cron expires invitation | `invitation.expired.v1` |
| Public waitlist join (new entry) | `waitlist.entry_created.v1` |
| Public waitlist join (duplicate detected) | *(no event — duplicates are silent)* |
| Admin approves waitlist entry | `waitlist.entry_approved.v1` + `invitation.issued.v1` |
| Recipient accepts (from waitlist approval) | `invitation.accepted.v1` + `registration.completed.v1` |
| Admin rejects waitlist entry | `waitlist.entry_rejected.v1` |

Every event carries enough context (tenant_id, partner_id, email, source) for a downstream CRM to create or update the corresponding lead/account without a callback into ScaiControl.

## Settings the operator controls

In the Settings tab under **Admin → Registration**:

| Setting | What it does |
|---|---|
| `mode` | The headline switch. |
| `default_partner_id` | New tenants attach to this partner unless an invitation overrides it. |
| `default_trial_pack_slug` | Auto-activate this service pack at registration time. |
| `default_role` | Role granted to the first user (defaults to `tenant_admin`). |
| `require_email_verification` | Reserved for waitlist double-opt-in (off in v1; the ScaiKey password-set email already proves ownership for sign-up + invitation). |
| `allowed_email_domains` / `blocked_email_domains` | Domain policy described above. |
| `waitlist_capture_fields` | Which extra fields the public waitlist form asks for (`company_name`, `use_case`, …). |
| `invitation_ttl_hours` | How long invitations stay valid (default 336 = 14 days). |
| `signup_rate_limit_per_hour` | Per-IP cap on public endpoints. |
| `welcome_email_template_name` / `invitation_email_template_name` | Names of templates from the billing template system (optional — falls back to a built-in default). |

## What about ScaiKey?

ScaiControl owns the tenant + first user. ScaiKey owns the credentials. The handoff (option (c) — see [Authentication](./authentication-and-rbac)) is:

1. ScaiControl creates the tenant + `User` row (status `pending`).
2. ScaiControl calls ScaiKey `/admin/users` to pre-create the credential record.
3. ScaiControl calls ScaiKey `/password/forgot?purpose=welcome` to email the recipient the "Welcome — set your password" link.
4. Recipient clicks the link, sets a password on ScaiKey.
5. First successful login flips `User.status` from `pending` to `active`.

Failures in step 2 or 3 don't roll back step 1 — the tenant exists, the operator can retry the ScaiKey legs manually from the admin UI.

## Next

- [Operating the registration module](../tutorials/operating-the-registration-module) — operator cookbook: enable each mode, issue and revoke, approve a waitlist entry, switch modes safely.
- [Authentication and RBAC](./authentication-and-rbac) — the ScaiKey handshake in detail.
- [Webhooks](./webhooks) — subscribing to the registration + invitation + waitlist event topics.
