Organizations
Org invites, seat limits, preset organizations, and platform controls.
Org features switch on when the organization plugin is detected. Pass the same ac and roles objects you gave the org plugin:
import { organization } from "better-auth/plugins";
import { betterEnrollment, roleGate } from "@octopi-ai/better-enrollment";
plugins: [
admin(),
organization({
ac, roles,
allowUserToCreateOrganization: roleGate(["admin", "org-creator"]),
}),
betterEnrollment({
organization: {
ac, roles,
defaultOrganizationRole: "member",
defaultSeatLimit: 10,
},
}),
],Redeeming an org invite writes the member row (and team membership when teamId is set) in the same flow that creates the account. Redemption re-validates everything, since state drifts between create and accept.
Active organization is a product decision
After an org-join or org-create invite is redeemed, the plugin does not select an active
organization. Whether to switch the user into the new org automatically at sign-in or let them
pick through an org switcher is your app's call: set it with the org plugin's setActive where it
fits your flow.
Seat limits
Seat limits resolve in order: resolveSeatLimit(org) callback, the org's seatLimit column, defaultSeatLimit, then unlimited. Seats used = members + pending invite reservations (a private invite reserves one seat, a public one reserves maxUses - useCount). Enforcement happens at creation and again inside a guarded write at redemption, so parallel accepts never overshoot.
await authClient.invite.org.setSeatLimit({ organizationId, seatLimit: 25 });
await authClient.invite.org.usage({ organizationId });
// -> { seatLimit, members, pendingReserved, remaining }Preset organizations
By default an org-create invite lets the founder name their organization on the invite page. When the inviter should decide instead (a platform onboarding a named customer, say), preset it at creation:
await auth.api.createInvite({
body: {
kind: "org-create",
email: "founder@acme.com",
organization: { name: "Acme Corp", slug: "acme" } // slug is optional
},
headers
});The founder then fills in only their own account. invite.get returns the preset as organizationName and leaves organizationName and organizationSlug out of requiredFields, and the invitation emails receive organizationName on create and resend. Redemption founds the org with the preset name and ignores any org fields in the redeem body, so the invitee cannot rename it.
The slug is the preset one, or one derived from the name ("Acme Corp" becomes acme-corp). The founder has no way to pick another, so a slug that is taken by redemption time gets a short random suffix (acme-corp-x7k2qp) instead of failing with ORG_SLUG_TAKEN. Without a preset, nothing changes: the founder enters both fields and a taken slug fails as before. organization is accepted only on org-create invites; other kinds reject it with ORG_FIELDS_NOT_ALLOWED. It works the same through createSystemInvite.
Stamping your own fields with beforeOrgCreate
organization.beforeOrgCreate runs on every org-create redemption, just before the org row is written, with or without a preset. Return an object to store its fields on the new organization (declare them as additionalFields on the org plugin), or throw an APIError to refuse the redemption with that error:
betterEnrollment({
organization: {
async beforeOrgCreate({ invite, user, organization }) {
const record = await db.onboarding.findByInvite(invite.id);
if (!record) throw new APIError("FORBIDDEN", { code: "NOT_ISSUED_BY_US" });
return { type: record.type, parentOrganizationId: record.parentId };
}
}
});organization is the { name, slug } about to be written. The plugin keeps name, slug, createdAt, seatLimit (from presetSeatLimit) and disabledAt for itself, so returning them has no effect.
When the hook throws, the invite stays pending and redeemable later: for activations and private sign-ups the hook runs before the invite is claimed, and nothing has been written yet. A public sign-up has no account until after its claim, so there the hook runs right after the account is created; on a throw that account is deleted and the use returned. If you count users in a databaseHooks.user.create hook, that one sign-up path can briefly create a user that is then removed.
Platform controls
App-admin only: disable, enable, and delete organizations. A disabled org refuses invite creation, redemption, and org plugin mutations. Its members also lose invite management: listing, resending, revoking, and deleting its invites and reading its seat usage all fail with ORG_DISABLED, while app admins can still list, revoke, and delete them. Member accounts are never touched by default; banMembers: true additionally bans every member app-wide, for fraud takedowns.
await authClient.invite.org.disable({ organizationId });
await authClient.invite.org.enable({ organizationId });
await authClient.invite.org.delete({ organizationId });Who can do what
| Action | App admin | Org owner or admin | Member with invitation:create | Plain member |
|---|---|---|---|---|
Create app / org-create invite | Yes | No | No | No |
Create org-join invite | No | Yes | Yes | No |
| List invites | All | Own org | Own org | No |
| Revoke or delete | Moderation backstop | Own org | Own org (invitation:cancel) | No |
| Seat limits, disable, delete org | Yes | No | No | No |
App admins come from the admin plugin's AC file (ac and roles passed to the plugin) or, without one, from adminRoles (default ["admin"]), adminUserIds, or your own canManageInvites(user) callback; see Permissions. Org permissions are the org plugin's own access control. Cross-org calls return FORBIDDEN, and unknown org ids look identical so they cannot be enumerated.
One actor sits outside this table: trusted server code. The server-only createSystemInvite endpoint can create any invite kind, including org-join, without a session or org membership; seat limits, disabled-org checks, and the allowOwnerInvites switch still apply. System invites store a self-declared inviter and, unless the caller passes inviter.userId, a null createdByUserId, so they remain distinguishable from member-created invites in the audit trail. When your server sends an invite on someone's behalf (for example a parent organization inviting a child organization's first owner), pass their id as inviter.userId: the invite is attributed to them and cleaned up with their other pending invites if they are banned or removed. Either way, attribution is only as trustworthy as the code that called the endpoint.
Where to go next
Last updated on