# Invitations

Invite people who have no account yet, and let them accept from the email link.

An invitation reaches an email address whether or not it has an account. The
person gets an email, follows the link into your app, signs in or registers,
and accepts. Until then the invitation is pending, and a pending invitation
holds a seat.

This is the same flow the BuildBase console uses for its own organization
invitations, on your app's side: fourteen days of validity, a five-minute
resend cooldown, and the pending list under the invite form.

The timings are the server's, not the SDK's. Every pending invitation carries
`resendAvailableAt`, the instant the cooldown ends, so the Resend button
counts down to the same moment the endpoint stops answering 429 even if we
change the cooldown later. Nothing in your bundle has to know what five
minutes means.

> **Note:**
  **Requires `@buildbase/sdk`{' '}
  0.0.71
   or later**, and a BuildBase server from{' '}
  release 22
  . On an older server the invite form answers 404.


## The admin's side

The members screen does it all: invite by email with a role, see the pending
invitations under the form, resend one, revoke one. Nothing to build.

```tsx
import { useSaaSAuth } from '@buildbase/sdk/react';

function TeamButton() {
  const { openWorkspaceSettings } = useSaaSAuth();
  return (
    <button onClick={() => openWorkspaceSettings('users')}>Manage team</button>
  );
}
```

From code, the same actions live on `useSaaSWorkspaces()`:

```tsx
import { useSaaSWorkspaces } from '@buildbase/sdk/react';

const { inviteMember, listInvitations, resendInvitation, revokeInvitation } =
  useSaaSWorkspaces();

// Invite someone who may not have an account yet
await inviteMember(workspaceId, 'teammate@example.com', 'editor');
```

`addUser` is unchanged. It adds an existing account at once and answers 404
when the address has none, with a message saying to send an invitation
instead; `inviteMember` is the way to reach a stranger.

## The invitee's side

The email link lands on your app's auth redirect URL, the one the app returns
to after login, with the invitation token in the URL. The SDK reads it, shows
who invited them to what and as which role, and offers sign-in when they are
signed out. Once signed in they accept or decline in place. You do not have to
build any of this.

A brand-new invitee does not have to verify their email first. Following the
link is the proof: the token was sent to that address and to nowhere else, and
the SDK passes it back when it accepts, so the account is marked verified on
the way in. The same standard a magic link sets. Accepting from the in-app
list carries no token, so there a verified email is still required, and a
stolen invitation id proves nothing by itself.

## When public sign-up is off

An invitation works even when you have switched off **Allow public sign-up**
under **Authentication › Sign-up**. That is what makes the setting
invite-only rather than closed: the person the invitation was sent to can
still create an account, and nobody else can.

The link is what lets them in. The hosted sign-up page shows its form, and
the server creates the account, only for a visitor holding the token of a
pending, unexpired invitation, and only for the address it was sent to. The
same goes for a first sign-in with a social provider that starts from the
link. A forwarded link does not open sign-up for anyone else, and your plan's
end-user limit still applies. See
[Closing public sign-up](/authentication/overview#closing-public-sign-up).

Carrying the token to those pages needs SDK **0.0.75** or later. On an earlier
version the invitation is parked but never sent on, so an invited person sees
the invite-only notice rather than the form.

The hosted pages keep the token for the browser tab, because moving from login
to register and the round trip through a social provider both drop the query
string. So once a tab has opened an invitation link, the register form keeps
showing there even on a URL without one: seeing the form is not the same as
being allowed to register, and the server still refuses an address the
invitation was not sent to.

For an invite-only app (workspace settings with `autoCreateFirst: false`) a
new user has no workspace yet. The workspace switcher's empty state shows their
pending invitations instead of "No workspaces available". To put them on your
own screen:

```tsx
import {
  PendingInvitations,
  WhenPendingInvitations,
} from '@buildbase/sdk/react';

function NoWorkspaceScreen() {
  return (
    <WhenPendingInvitations
      fallbackComponent={<p>Ask your team for an invitation.</p>}
    >
      <PendingInvitations onAccepted={(ws) => console.log('joined', ws.name)} />
    </WhenPendingInvitations>
  );
}
```

`useMyInvitations()` is the hook behind both, with `accept` and `decline`.

## Where the link lands

The server builds the link from the auth client the inviter's session signed
in through: the first allowed redirect URL of that client, which is where the
app lands after login anyway. To land somewhere else, pass `landingUrl`; it
must be one of that client's allowed redirect URLs, or the server answers 400.

A session minted from an API token has no auth client, so the server factory's
`invitations.create(workspaceId, email, role, landingUrl)` needs `landingUrl`
there.

## Seats

A pending invitation is billed as a seat, from the moment the email is sent
until it is accepted (the member keeps the seat), declined, revoked or expired.
The members screen counts pending invitations in its seat block and says so.
The seat limit is checked before an invitation is created, so an admin cannot
queue twenty invitations against three seats and discover it one acceptance
at a time. Revoking a pending invitation releases its seat at once.

## Modes

| Workspace mode                           | What invitations do                                                                                                                                                                               |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `personal` (one workspace per user)      | Nothing: the preset sets `canInviteMembers: false`, so every invite surface hides and the server refuses with 403 `WORKSPACE_INVITES_DISABLED`. A B2C app pays nothing for this feature existing. |
| `platform`                               | The full flow. Every user gets a workspace on first load, invited or not, and can be invited into others.                                                                                         |
| `platform` with `autoCreateFirst: false` | The only way in. A new user sees their pending invitations in the switcher, or in `` where you place it.                                                                    |

One setting decides this: `canInviteMembers`. `false` turns invitations off
everywhere, and `'admin-only'` leaves them on and lets the role check decide.
Selecting `personal` mode sets it to `false` for you, which is why a B2C app
gets nothing it has to switch off by hand.

The mode itself is never read, on the server or in the SDK. It is a preset that
seeds the settings and can be changed underneath them afterwards, so reading
both would mean two sources deciding one thing and disagreeing the moment
either moves. Change `canInviteMembers` and every surface follows.

When invitations are off, only the invite surfaces go: the members screen's
invite form and every Resend button, and the invitee's side, where
`useMyInvitations()` makes no request and reports an empty list, so
``, `<WhenPendingInvitations>` and the switcher section
render nothing. Everything else on the members screen stays. The members
already in a workspace are still listed, their roles can still be changed and
they can still be removed, because switching invitations off stops people
being added and nothing more. Rows sent before the switch went off stay listed
with Revoke, and the invitee can still decline: such a row is billing a seat
until somebody resolves it.

A waiting invitation does not change what a user's first load does. Whether
they get a workspace of their own is `autoCreateFirst`, as it is for everyone
else, so with it on an invited user has their own workspace and the invited
one beside it once they accept.

## Errors

Every invitation call throws a plain `Error` with `status` and `code`, plus the
fields a screen needs: `currentUserCount` and `limit` on a seat limit
(`SEAT_LIMIT_REACHED`), `retryAfterSeconds` on a resend cooldown
(`INVITATION_RATE_LIMITED`), the scrambled `invitedEmail` on a mismatch
(`INVITATION_EMAIL_MISMATCH`). `EMAIL_NOT_VERIFIED`, `INVITATION_EXPIRED` and
`INVITATION_ALREADY_HANDLED` are the others you will meet. Check with
`isInvitationError(err)`. `EMAIL_NOT_VERIFIED` only comes back for an accept
with no link token, which means accepting from the list.

## Events and webhooks

Five system events fire on your webhooks and workflow triggers:
`workspace.member_invited`, `workspace.invitation_accepted`,
`workspace.invitation_declined`, `workspace.invitation_revoked` and
`workspace.invitation_expired`. Accepting also fires `workspace.member_added`,
because a member was added, exactly as a direct add does.

## Self-hosted

Three things are worth knowing before you invite anyone from a self-hosted
image.

**Invitations need a configured organization email sender.** There is no
platform fallback: with no usable sender the create route refuses with 503
`NO_SENDER_CONFIGURED` before any row is written, so nobody is billed a seat
for a mail that was never sent. Set one up under Emails first.

**Nothing extra has to run.** Pending invitations expire on a ten-minute sweep,
and the self-hosted image runs its server in `full` mode, which carries the
crons alongside the API. The split deployment with separate worker replicas is
a managed-hosting shape; you do not need it for invitations to expire on time.

**The per-workspace send limit cannot be turned off.** Sixty invitations an
hour per workspace is enforced in every image, and `DISABLE_RATE_LIMIT` is
pinned off in a locked build, so it holds whatever the environment says. The
daily ceiling of two hundred invitation emails per organization applies too,
on its own budget so a large onboarding cannot starve your users' sign-in
emails.

## Next Steps

- [Managing Members](/workspaces/managing-members) - Roles, seats, and the members screen.
- [Settings Screens](/workspaces/settings-screens) - All pre-built screens.
