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
| Kind | Grants | Who may create it |
|---|---|---|
app (default) | Access to the app | App admins |
org-join | Membership in an existing organization | That org's members with invitation: ["create"] |
org-create | Founding and owning a new organization | App 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
| Private | Public | |
|---|---|---|
| Bound to | One email address | Nobody |
| Uses | Always exactly 1 | maxUses, or unlimited when null |
| Delivery | Emailed by your sendPrivateInvitation | A link you distribute |
| Link visible to creator | Never | Yes, returned once |
| Email verified on accept | Yes | No, 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 onceResending 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:
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