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.

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.

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 }
});

The accepter still gets a session immediately; they verify by clicking the emailed link like any other signup.

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.

Last updated on

On this page