Better Enrollment

The invite page

One page and one redeem call handle every invite kind.

Every invitation link points at the same page, carrying only ?token=. The invitee never needs to know what kind of invite they hold.

const { data: invite } = await authClient.invite.get({ token });

Rendering by nextAction

invite.nextActionMeaningWhat to render
SIGN_UPInvite-only, no sessionThe fields listed in requiredFields
SIGN_INOpen mode, no sessionYour sign-in form; keep the token in the URL
CONFIRMOpen mode, session presentA confirm button, plus any fields listed
nullExpired, revoked, or consumedA clear terminal message

Two of the four are basic and never collect fields: SIGN_IN hands off to your sign-in form and null is a message. The other two are forms, and both can carry additional fields: SIGN_UP collects them while creating the account, CONFIRM while activating on an existing one.

requiredFields lists exactly what to collect: always password and name for sign-up, plus email for public invites, plus organizationName and organizationSlug for org-create (CONFIRM has no built-in fields). Additional fields configured for the current step appear too: required ones in requiredFields, optional ones in optionalFields, and invite.additionalFields carries { type, required } for each so your page knows which input to render:

{
  "nextAction": "SIGN_UP",
  "requiredFields": ["password", "name", "department"],
  "optionalFields": ["referral"],
  "additionalFields": {
    "department": { "type": "string", "required": true },
    "referral": { "type": "string", "required": false }
  }
}

What your page will see

Most invitees have no account yet, so live invites return nextAction: "SIGN_UP": your page is a sign-up form driven by requiredFields. An invitee who already holds an established account (an org inviting an existing user, or someone redeeming a second invite after signing up through the first) gets "SIGN_IN" (no session) or "CONFIRM" (signed in), exactly like open mode, so render all three plus null. The server decides from the account's live state; the page never has to work out which case it is in. On a passwordless app the response carries passwordless: true and requiredFields omits password, so a field-driven form needs no special casing.

Redeeming

Submit everything to one endpoint:

await authClient.invite.redeem({
  token,
  password,
  name,
  email,
  organizationName,
  organizationSlug
  // ...plus any configured additional fields, e.g. department
});
// -> { action: "ACCEPTED", organization? }

redeem routes to the right semantics for the mode and kind, so your page never branches. The response says what happened, so you can render "You joined Acme" or "Your organization is ready".

name is required whenever the redemption signs the invitee up (nextAction: "SIGN_UP"); a sign-up without a name defeats the profile-populating point of the flow, so it fails with NAME_REQUIRED. Sign-in and confirm flows act on an existing account and take no name; a CONFIRM redemption still submits any additional fields configured for that step, in the same body.

Redemption never creates a session. After ACCEPTED in the sign-up flow, your page already holds the email and password it just collected, so sign the user in with them:

await authClient.signIn.email({ email, password });

In the CONFIRM flow the user was already signed in, and nothing about their session (including the active organization) is changed; switching to a newly joined org is your call.

A deliberately thin payload

invite.get returns only kind, role, derived status, expiry, uses remaining, and a masked email such as a***@example.com. Never the inviter's identity or internal ids.

Where to go next

Last updated on

On this page