Better Enrollment

Invites

Invite kinds, delivery types, and how to create each one.

Two choices define every invite: what it grants (kind) and how it travels (type). Everything else resolves server-side.

Kinds

KindGrantsWho may create it
app (default)Access to the appApp admins
org-joinMembership in an existing organizationThat org's members with invitation: ["create"]
org-createFounding and owning a new organizationApp admins

App admins deliberately cannot create org-join invites: an organization owns its member list, and the platform's lever is the seat limit. Org kinds require the organization plugin.

Delivery

PrivatePublic
Bound toOne email addressNobody
UsesAlways exactly 1maxUses, or unlimited when null
DeliveryEmailed by your sendPrivateInvitationA link you distribute
Link visible to creatorNeverYes, returned once
Email verified on acceptYesNo, unless autoVerifyPublicInviteEmail

No email delivery? Use a public invite with maxUses: 1. There is deliberately no option to reveal a private link, because possession of one is proof of mailbox access.

Creating invites

A private invite for a closed beta:

await auth.api.createInvite({
  body: { type: "private", email: "ada@example.com", name: "Ada", role: "user" },
  headers
});
// -> { inviteId, expiresAt }

A capped public signup link:

await auth.api.createInvite({
  body: { type: "public", role: "user", maxUses: 50 },
  headers
});
// -> { inviteId, expiresAt, token, url }

An org owner inviting a teammate (organizationRole goes to member.role):

await auth.api.createInvite({
  body: {
    kind: "org-join",
    type: "private",
    email: "dev@acme.com",
    organizationId: org.id,
    organizationRole: "developer"
  },
  headers: ownerHeaders
});

The app-level role field is admin-only. org-join invites reject it (ROLE_NOT_ALLOWED_FOR_ORG_JOIN) and always grant defaultRole: org inviters are trusted by their organization, not by the app, so letting them pick user.role would let any org owner mint app admins.

Partner onboarding, where the recipient signs up and founds their own organization in one form:

await auth.api.createInvite({
  body: {
    kind: "org-create",
    type: "private",
    email: "founder@acme.com",
    role: "user",
    presetSeatLimit: 25
  },
  headers
});

A public org-join invite is a shareable join link for one org (in a seat-limited org maxUses is required). A public org-create invite founds a separate organization per use.

Resending an invitation

When an invitation email goes missing, or a pending invite has expired, resend it. One call works for every kind and delivery type; the server rebuilds the right invite under the hood:

await authClient.invite.resend({ inviteId });
// private -> { inviteId, expiresAt }, new link emailed by your sendPrivateInvitation
// public  -> { inviteId, expiresAt, token, url }, the fresh link returned once

Resending mints a new token and a fresh expiry on the same invite row, so the previous link is invalidated the moment the call succeeds. Everything else is preserved: the invitee, roles, org and team bindings, remaining uses on a public invite, the seat reservation, and the audit history. An expired pending invite is revived with a new expiry, which is also the release valve for a locked email without deleting the invite.

Permissions mirror creation: app admins resend app and org-create invites (and act as a moderation backstop), org members with invitation: ["create"] resend their org's org-join invites. Accepted and revoked invites cannot be resent, and reviving an expired org-join invite re-checks the seat limit, since its reservation had been released.

Inviting someone who already has an account

This works out of the box for org kinds: the invite becomes an activation invite, the invitee signs in and confirms, and redemption merges roles and membership instead of creating a user. A plain app invite to an existing email is rejected with USER_ALREADY_EXISTS, since it grants nothing an existing user lacks.

The same email can hold pending private invites in several scopes at once (several orgs, or the app and an org). Whichever one the invitee redeems first is the sign-up: it claims the pre-created account, whichever invite created it, and every other invite to that email becomes an activation from then on, one click after signing in, roles merged. This also means an expired or revoked invite never gets in the way of a later one: if an app invite lapses and an org then invites the same person, their org link renders the normal sign-up form and joins them in one go. The only conflict left is inviting the exact same thing twice (same kind, same org), which returns EMAIL_ALREADY_INVITED; resend that invite instead.

Verifying public-invite emails

A private invite token traveled through the recipient's inbox, so accepting it proves mailbox ownership and the user is marked verified. A public invite proves only possession of the link: the accepter can type any email address, so the user is created with emailVerified: false.

Verification then happens through Better Auth's standard flow, which this plugin deliberately does not replace. Configure it and public-invite signups are covered automatically:

auth.ts
export const auth = betterAuth({
  emailVerification: {
    sendVerificationEmail: async ({ user, url }) => {
      await sendEmail(user.email, "Verify your email", url);
    },
    sendOnSignUp: true // also fires for public-invite signups, since they start unverified
  },
  // Optional: block sign-in until verified
  emailAndPassword: { requireEmailVerification: true }
});

Redemption never signs the accepter in, so with requireEmailVerification an unverified public accepter cannot enter at all until they click the emailed link; without it, they sign in normally and verify afterwards.

Set autoVerifyPublicInviteEmail: true only when you skip that flow entirely (internal tools, trusted environments). It marks public-invite users verified on the spot, which means anyone holding the link can register an address they do not own.

Where to go next

Last updated on

On this page