{"_id":"@aleix10kst/better-auth-invite","_rev":"4-018477639229c93c13814b5c0c3a1cac","name":"@aleix10kst/better-auth-invite","dist-tags":{"latest":"0.3.0"},"versions":{"0.1.0":{"name":"@aleix10kst/better-auth-invite","version":"0.1.0","keywords":["better-auth","better-auth-plugin","invite","invitation","auth"],"author":{"name":"Aleix Canet","email":"acanet94@gmail.com"},"license":"MIT","_id":"@aleix10kst/better-auth-invite@0.1.0","maintainers":[{"name":"aleix10kst","email":"acanet94@gmail.com"}],"homepage":"https://github.com/aleix10kst/better-auth-invite-plugin#readme","bugs":{"url":"https://github.com/aleix10kst/better-auth-invite-plugin/issues"},"dist":{"shasum":"d2471922c5fd30f9f1dc9dfaa4a403cb26b340eb","tarball":"https://registry.npmjs.org/@aleix10kst/better-auth-invite/-/better-auth-invite-0.1.0.tgz","fileCount":15,"integrity":"sha512-XZHN+Nwqwr1c6YGdvbtaeb19DRghwprPP8OLvEjSQwdDWqKGxmxBrrMxVrSOm9fi7Fy666A5V2Z3P+2PFk5aOg==","signatures":[{"sig":"MEUCIDosYFsvBR3LEOd0BjwTMBrOi48fHD09r+wkMTQ1XgKAAiEA2eSXnf1LDTXhAAqZpTA4TzQxfRrl7nQH8un3dFFiyjU=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":264674},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./client":{"import":{"types":"./dist/client.d.ts","default":"./dist/client.js"},"require":{"types":"./dist/client.d.cts","default":"./dist/client.cjs"}}},"gitHead":"5b87e94cd46216a37d78d26af29d54d3fe44d289","scripts":{"test":"vitest run","build":"tsup","typecheck":"tsc --noEmit","prepublishOnly":"bun run typecheck && bun run test && bun run build"},"_npmUser":{"name":"aleix10kst","email":"acanet94@gmail.com"},"repository":{"url":"git+https://github.com/aleix10kst/better-auth-invite-plugin.git","type":"git"},"_npmVersion":"11.6.2","description":"Invite users to your app by email — an invite system plugin for Better Auth with expiring single-use tokens","directories":{},"sideEffects":false,"_nodeVersion":"24.13.0","publishConfig":{"access":"public"},"typesVersions":{"*":{"*":["./dist/index.d.ts"],"client":["./dist/client.d.ts"]}},"_hasShrinkwrap":false,"devDependencies":{"zod":"^4.0.0","tsup":"^8.5.0","vitest":"^3.2.4","typescript":"^5.9.2","better-auth":"^1.6.26"},"peerDependencies":{"zod":"^3.25.0 || ^4.0.0","better-auth":"^1.6.17"},"_npmOperationalInternal":{"tmp":"tmp/better-auth-invite_0.1.0_1787324568161_0.03935465629287238","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@aleix10kst/better-auth-invite","version":"0.1.1","keywords":["better-auth","better-auth-plugin","invite","invitation","auth"],"author":{"name":"Aleix Canet","email":"acanet94@gmail.com"},"license":"MIT","_id":"@aleix10kst/better-auth-invite@0.1.1","maintainers":[{"name":"aleix10kst","email":"acanet94@gmail.com"}],"homepage":"https://github.com/aleix10kst/better-auth-invite-plugin#readme","bugs":{"url":"https://github.com/aleix10kst/better-auth-invite-plugin/issues"},"dist":{"shasum":"15f2a36fdef7de43dd0ce2ac707372e90a63d6ca","tarball":"https://registry.npmjs.org/@aleix10kst/better-auth-invite/-/better-auth-invite-0.1.1.tgz","fileCount":15,"integrity":"sha512-VxG71GDKlFkdLLFYWtKeT+K3aY9TBgdx4DyYjgu0uNi55DLLXno2au1cNPylF8TyEQq1vk2W+R53EhH0IS58+Q==","signatures":[{"sig":"MEUCIQCIDJD8m+Ld4fld5fRbVxd83ZgbmbrxfJ07jkf6rf8VvQIgaDMCg58PbvdWfMgrrgAM2cupRdd4jnROxvETS+jTuXk=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@aleix10kst%2fbetter-auth-invite@0.1.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":264712},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./client":{"import":{"types":"./dist/client.d.ts","default":"./dist/client.js"},"require":{"types":"./dist/client.d.cts","default":"./dist/client.cjs"}},"./package.json":"./package.json"},"gitHead":"d9febb14de3d9cc843e634d2749a9a558f603446","scripts":{"test":"vitest run","build":"tsup","typecheck":"tsc --noEmit","prepublishOnly":"bun run typecheck && bun run test && bun run build"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:2c10ce97-2a71-4943-bbf8-7b55f65898f1"}},"repository":{"url":"git+https://github.com/aleix10kst/better-auth-invite-plugin.git","type":"git"},"_npmVersion":"12.0.2","description":"Invite users to your app by email — an invite system plugin for Better Auth with expiring single-use tokens","directories":{},"sideEffects":false,"_nodeVersion":"24.19.0","publishConfig":{"access":"public"},"typesVersions":{"*":{"*":["./dist/index.d.ts"],"client":["./dist/client.d.ts"]}},"_hasShrinkwrap":false,"devDependencies":{"zod":"^4.0.0","tsup":"^8.5.0","vitest":"^3.2.4","typescript":"^5.9.2","better-auth":"^1.6.26"},"peerDependencies":{"zod":"^3.25.0 || ^4.0.0","better-auth":"^1.6.17"},"_npmOperationalInternal":{"tmp":"tmp/better-auth-invite_0.1.1_1787332165741_0.6347051366486613","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@aleix10kst/better-auth-invite","version":"0.2.0","keywords":["better-auth","better-auth-plugin","invite","invitation","auth"],"author":{"name":"Aleix Canet","email":"acanet94@gmail.com"},"license":"MIT","_id":"@aleix10kst/better-auth-invite@0.2.0","maintainers":[{"name":"aleix10kst","email":"acanet94@gmail.com"}],"homepage":"https://github.com/aleix10kst/better-auth-invite-plugin#readme","bugs":{"url":"https://github.com/aleix10kst/better-auth-invite-plugin/issues"},"dist":{"shasum":"21cdcdd2db1b29bc2e7c5356577eb6205ebb1165","tarball":"https://registry.npmjs.org/@aleix10kst/better-auth-invite/-/better-auth-invite-0.2.0.tgz","fileCount":9,"integrity":"sha512-xahurDVNR1fbm2N5Kxk/Al0I8mtQ5lKwQ8H7Hf/FiJtGu4ScwrROrQYI+H9MjbUl3yAKsCM9Gbde8pGOYabr/g==","signatures":[{"sig":"MEQCIAL7llm1ACqVJEdq/ALM7EMSEMyF/vvl5Ne/0/XL1b8rAiBETZYk3P0AjlX2qv0P8QAwInd1i4dQvFW3/rU/6BD4Xg==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@aleix10kst%2fbetter-auth-invite@0.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":219259},"type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=20"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./client":{"types":"./dist/client.d.ts","default":"./dist/client.js"},"./package.json":"./package.json"},"gitHead":"9bfcd9398f48cec06b86379892396e7fa5d771bf","scripts":{"lint":"oxlint","test":"vitest run","build":"tsup","format":"oxfmt","typecheck":"tsc --noEmit","format:check":"oxfmt --check","prepublishOnly":"bun run lint && bun run typecheck && bun run test && bun run build"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:2c10ce97-2a71-4943-bbf8-7b55f65898f1"}},"repository":{"url":"git+https://github.com/aleix10kst/better-auth-invite-plugin.git","type":"git"},"_npmVersion":"12.0.2","description":"Invite users to your app by email — an invite system plugin for Better Auth with expiring single-use tokens","directories":{},"sideEffects":false,"_nodeVersion":"24.19.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"zod":"^4.0.0","tsup":"^8.5.0","oxfmt":"^0.64.0","kysely":"^0.29.5","oxlint":"^1.79.0","vitest":"^3.2.4","typescript":"^5.9.2","better-auth":"^1.7.1","better-sqlite3":"^12.0.0","@better-auth/core":"^1.7.1","@types/better-sqlite3":"^9.6.0"},"peerDependencies":{"zod":"^3.25.0 || ^4.0.0","better-auth":">=1.7.0 <2.0.0","@better-auth/core":">=1.7.0 <2.0.0"},"_npmOperationalInternal":{"tmp":"tmp/better-auth-invite_0.2.0_1787337711417_0.3721098852109088","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"_id":"@aleix10kst/better-auth-invite@0.3.0","bugs":{"url":"https://github.com/aleix10kst/better-auth-invite/issues"},"dist":{"shasum":"56c895d51701551a7ab5bd4380f857fc86a4f578","tarball":"https://registry.npmjs.org/@aleix10kst/better-auth-invite/-/better-auth-invite-0.3.0.tgz","fileCount":9,"integrity":"sha512-LxqmM2db6y/G4X3SmynULL7aPPar5iD3SH2Y4i6F2sVWQnGOdd2W+Yk9r8NjW/CPpiDAnc3wGx4s0rH8IOxk5Q==","signatures":[{"sig":"MEUCIDIpZhQIZb+AsPZtZ3fXBrBnZBSpzuoGXHNCQ45fGUPmAiEA1K3swsNqLZrIIe2wmvF7Lwq07pCtcZNUHYXJqBNmRCE=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIDKeB1xQvHPdoeT54njWbFzqRQSpYpSEaRfNKC011VlIAiB4DtouyhM0miyeQkya1IdNcJKuCt06h7I8vo7cFC0TQA=="}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@aleix10kst%2fbetter-auth-invite@0.3.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":231681},"name":"@aleix10kst/better-auth-invite","type":"module","types":"./dist/index.d.ts","author":{"name":"Aleix Canet","email":"acanet94@gmail.com"},"module":"./dist/index.js","engines":{"node":">=20"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./client":{"types":"./dist/client.d.ts","default":"./dist/client.js"},"./package.json":"./package.json"},"gitHead":"7dd768da75345ca5b5519300838bb3df9de862fc","license":"MIT","scripts":{"lint":"oxlint","test":"vitest run","build":"tsup","format":"oxfmt","typecheck":"tsc --noEmit","format:check":"oxfmt --check","prepublishOnly":"bun run lint && bun run typecheck && bun run test && bun run build"},"version":"0.3.0","_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"eba1f855-2975-497c-bd18-044e6842910d"}},"homepage":"https://github.com/aleix10kst/better-auth-invite#readme","keywords":["better-auth","better-auth-plugin","invite","invitation","auth"],"repository":{"url":"git+https://github.com/aleix10kst/better-auth-invite.git","type":"git"},"_npmVersion":"12.2.0","description":"Invite users to your app by email — an invite system plugin for Better Auth with expiring single-use tokens","directories":{},"maintainers":[{"name":"aleix10kst","email":"acanet94@gmail.com"}],"sideEffects":false,"_nodeVersion":"24.21.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"zod":"^4.0.0","tsup":"^8.5.0","oxfmt":"^0.64.0","kysely":"^0.29.5","oxlint":"^1.79.0","vitest":"^3.2.4","typescript":"^5.9.2","better-auth":"1.7.4","better-sqlite3":"^12.0.0","@better-auth/core":"1.7.4","@types/better-sqlite3":"^9.6.0"},"peerDependencies":{"zod":"^3.25.0 || ^4.0.0","better-auth":">=1.7.3 <2.0.0","@better-auth/core":">=1.7.3 <2.0.0"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/better-auth-invite_0.3.0_1791065366654_0.9735481912944688"}}},"time":{"created":"2026-08-21T15:02:47.989Z","modified":"2026-10-03T22:09:27.031Z","0.1.0":"2026-08-21T15:02:48.275Z","0.1.1":"2026-08-21T17:09:25.905Z","0.2.0":"2026-08-21T18:41:51.570Z","0.3.0":"2026-10-03T22:09:26.747Z"},"bugs":{"url":"https://github.com/aleix10kst/better-auth-invite/issues"},"author":{"name":"Aleix Canet","email":"acanet94@gmail.com"},"license":"MIT","homepage":"https://github.com/aleix10kst/better-auth-invite#readme","keywords":["better-auth","better-auth-plugin","invite","invitation","auth"],"repository":{"url":"git+https://github.com/aleix10kst/better-auth-invite.git","type":"git"},"description":"Invite users to your app by email — an invite system plugin for Better Auth with expiring single-use tokens","maintainers":[{"name":"aleix10kst","email":"acanet94@gmail.com"}],"readme":"# @aleix10kst/better-auth-invite\n\nA [Better Auth](https://better-auth.com) plugin that implements an invite system for your app — invite users by email with expiring single-use tokens:\n\n- Admins invite an email address; the invited person receives a single-use link.\n- Inviting an email that already belongs to a registered user fails with `USER_ALREADY_EXISTS`.\n- Re-inviting a pending email rotates the token: the old link dies, a fresh email goes out.\n- Accepting the invitation creates the user with `emailVerified: true`, sets their password, and signs them in — as one transaction, so a failure leaves nothing half-applied.\n- Tokens are stored only as SHA-256 hashes, are single-use, and expire (24h by default).\n- Optionally **invite-only**: with `requireInvite`, no one can sign up — through any flow — without a live invitation.\n- Composes with Better Auth's organization plugin: invite-only mode honours organization invitations, and one invite can create the user *and* their membership — see [Inviting to an organization](#inviting-to-an-organization).\n\n## Installation\n\n```bash\nnpm install @aleix10kst/better-auth-invite\n# or\nbun add @aleix10kst/better-auth-invite\n```\n\n**Requires better-auth 1.7.3+.** `better-auth` (`>=1.7.3 <2.0.0`), `@better-auth/core` (same range) and `zod` are peer dependencies. `@better-auth/core` provides Better Auth's transaction primitive (`runWithTransaction`/`getCurrentAdapter`); it ships as a dependency of `better-auth` itself, so installing it explicitly only matters for strict package managers such as pnpm.\n\n1.7 changed the internal `createUser` signature this plugin builds on (a provisioning-source second argument), so supporting 1.6 as well would mean casting around it — if you are still on better-auth 1.6, use `@aleix10kst/better-auth-invite@0.1.x`. **1.7.0–1.7.2 are also unsupported**: those releases briefly required linking a credential account through an issuer, and this plugin accepted an invitation by issuing one with `createLocalAccountIssuer`. 1.7.3 reverted that: accounts are identified by `(providerId, accountId)` again, same as 1.6, and this plugin now links credential accounts exactly like core's own email sign-up route does. If you already upgraded to the issuer schema on 1.7.0–1.7.2, follow Better Auth's [1.7 upgrade guide](https://better-auth.com/docs/guides/1-7-upgrade-guide) before moving to 1.7.3+ — no backfill is needed here, since this plugin always wrote `providerId`/`accountId` alongside the issuer.\n\n**This package is ESM-only** — `import` it; `require()` will not resolve. (`better-auth` is ESM-only too.)\n\n## Server setup\n\n```ts\nimport { betterAuth } from \"better-auth\";\nimport { invite } from \"@aleix10kst/better-auth-invite\";\n\nexport const auth = betterAuth({\n  // ...your config\n  // required unless you set `requirePassword: false` — accepting an\n  // invitation creates a credential (email + password) account\n  emailAndPassword: { enabled: true },\n  plugins: [\n    invite({\n      // REQUIRED: deliver the invitation email however you like\n      sendInvitationEmail: async ({ invitation, token, url, inviter }) => {\n        await sendEmail({\n          to: invitation.email,\n          subject: `${inviter.name} invited you`,\n          text: `Accept your invitation: ${url}`,\n        });\n      },\n      // REQUIRED: where the emailed link points (your app's accept page).\n      // The `/invite/accept` API endpoint is POST-only, so the link must\n      // land on a page that reads the token and calls it.\n      inviteRedirectURL: \"https://app.example.com/accept-invite\",\n      expiresIn: 60 * 60 * 24, // 24h (default)\n    }),\n  ],\n});\n```\n\nRun your usual migration flow (`npx @better-auth/cli migrate` / `generate`) — the plugin adds one table, `invite`, with indexes on `email` and `createdAt` plus compound `(status, createdAt)` and `(status, expiresAt)` indexes covering the queries `listInvites` and `purgeInvites` issue. The plugin also relies on the `user.email` unique index that Better Auth's schema declares — keep it in place (it is the backstop against a concurrent sign-up racing an invite acceptance).\n\n### Who may invite?\n\nBy default, invitation management endpoints (`send`, `cancel`, `list`, `resend`) require a signed-in user whose `role` contains `admin` (comma-separated roles supported, matching the admin plugin's convention — so it composes cleanly with `better-auth/plugins`' `admin()`). Override with `canInvite`:\n\n```ts\ninvite({\n  sendInvitationEmail,\n  inviteRedirectURL,\n  canInvite: async (user, ctx) => user.email.endsWith(\"@yourcompany.com\"),\n  // whoever passes canInvite chooses the invited user's role — when\n  // canInvite is broader than \"admins only\", restrict assignable roles:\n  allowedRoles: [\"member\", \"viewer\"],\n});\n```\n\n**`canInvite` gates role assignment too.** The invitation's `role` is written verbatim onto the created (email-verified) user, so anyone who passes `canInvite` can mint users with any role — including `\"admin\"` or composite values like `\"user,admin\"` — unless you set `allowedRoles`. If you loosen `canInvite` beyond fully trusted admins, always set `allowedRoles`. Note also that `/invite/send` reveals whether an email is already registered (`USER_ALREADY_EXISTS`); with a permissive `canInvite`, that becomes an account-enumeration oracle for everyone you grant invite rights to (bounded by the 20 req/min rate limit).\n\nNote: the base Better Auth user model has no `role` field. Add one via the admin plugin or `user.additionalFields` if you rely on the default check, or supply your own `canInvite`. The same applies to the invitation's `role`: it is copied onto the created user at accept time only if the user table actually has a `role` field.\n\n## Client setup\n\n```ts\nimport { createAuthClient } from \"better-auth/client\";\nimport { inviteClient } from \"@aleix10kst/better-auth-invite/client\";\n\nexport const authClient = createAuthClient({\n  plugins: [inviteClient()],\n});\n\n// fully typed:\nawait authClient.invite.send({ email: \"new@user.com\", role: \"member\" });\nawait authClient.invite.sendBulk({ invitations: [{ email: \"a@b.com\" }] });\nawait authClient.invite.get({ query: { token } });\nawait authClient.invite.accept({ token, password: \"chosen-password\" });\nawait authClient.invite.reject({ token });\nawait authClient.invite.list({ query: { status: \"pending\" } });\nawait authClient.invite.cancel({ invitationId });\nawait authClient.invite.resend({ invitationId });\nawait authClient.invite.purge({ statuses: [\"expired\"] });\n```\n\n## Endpoints\n\n| Endpoint | Method | Auth | Description |\n| --- | --- | --- | --- |\n| `/invite/send` | POST | session + `canInvite` | Invite an email. Body: `{ email, name?, role?, metadata?, expiresIn? }`. Fails with `USER_ALREADY_EXISTS` if the email is registered, and with `ROLE_NOT_ALLOWED` if `allowedRoles` is set and `role` is not in it. Re-inviting a pending email cancels the old invite and issues a fresh token (unless `allowReInvite: false`, then `INVITATION_ALREADY_SENT`). Returns the invitation (never the token). |\n| `/invite/send-bulk` | POST | session + `canInvite` | Body: `{ invitations: [{ email, name?, role?, metadata? }], expiresIn? }`, up to 100. Never fails as a whole: returns `{ results, sent, failed }` where each result is `{ email, status: \"sent\", invitation }` or `{ email, status: \"failed\", error, code }`. |\n| `/invite/get` | GET | public | Query: `{ token }`. Returns `{ email, name, role, metadata, status, expiresAt, inviter }` for a valid pending token (`inviter` is `{ name, image }` — never the inviter's email); `404 INVITATION_NOT_FOUND`, `410 INVITATION_EXPIRED`, `400 INVITATION_CANCELED` / `INVITATION_REJECTED` / `INVITATION_ALREADY_ACCEPTED` otherwise. |\n| `/invite/accept` | POST | public | Body: `{ token, password?, name?, ...additionalFields }`. Creates the user (`emailVerified: true`, role from the invitation, plus any `user.additionalFields` passed in the body — validated exactly like sign-up), sets the password (credential account; required unless `requirePassword: false`), marks the invite accepted, calls `onInvitationAccepted`, and — with `autoSignIn` (default) — creates a session and sets the cookie. Returns `{ token, user }`. |\n| `/invite/reject` | POST | public | Body: `{ token }`. Lets the recipient decline: the invitation becomes `rejected` and its token stops working. |\n| `/invite/cancel` | POST | session + `canInvite` | Body: `{ invitationId }`. Cancels a pending invitation, invalidating its token. |\n| `/invite/list` | GET | session + `canInvite` | Query: `{ status?, email?, limit?, offset? }`. Lists invitations (never token hashes) and returns `{ invitations, total }`. Pending invitations past expiry are reported with the virtual status `\"expired\"`; the `pending`/`expired` filters are applied in the database query, so `limit`/`offset` paginate the filtered set and `total` counts it. |\n| `/invite/resend` | POST | session + `canInvite` | Body: `{ invitationId, expiresIn? }`. Re-sends a pending invitation (expired ones included) with a fresh token and expiry; the old token stops working (acceptance is guarded on the token hash, so an in-flight accept with the old token loses). |\n\n| `/invite/purge` | POST | session + `canInvite` | Body: `{ statuses?, olderThan? }`. Deletes finished invitations — by default `expired`, `canceled` and `rejected`; pass `statuses` to include `accepted`, and `olderThan` (seconds) to keep recently-touched rows. Returns `{ deleted }`. Meant for a cron job. |\n\nServer-side, the endpoints are available as `auth.api.sendInvite`, `auth.api.sendBulkInvites`, `auth.api.getInvite`, `auth.api.acceptInvite`, `auth.api.rejectInvite`, `auth.api.cancelInvite`, `auth.api.listInvites`, `auth.api.resendInvite`, and `auth.api.purgeInvites`.\n\n## Options\n\n| Option | Type | Default | Description |\n| --- | --- | --- | --- |\n| `sendInvitationEmail` | `(data, request?) => Promise<void>` | — (required) | Called with `{ invitation, token, url, inviter }`. The only place the raw token is exposed. |\n| `inviteRedirectURL` | `string \\| (data, ctx) => string` | — (required) | App page the emailed `url` points at (token appended as `?token=`). The `/invite/accept` API endpoint is POST-only, so the link must land on a page. Pass a function — it receives `{ invitation, inviter }` — to build the URL per invitation, e.g. per tenant. Always server-side: there is deliberately no caller-supplied redirect. |\n| `expiresIn` | `number` (seconds) | `86400` (24h) | Invitation lifetime. Positive integer, at most `31536000` (1 year). Can be overridden per call via the `expiresIn` body field of `send`/`resend` (same bounds). |\n| `canInvite` | `(user, ctx) => boolean \\| Promise<boolean>` | `user.role` contains `\"admin\"` | Gate for `send`/`cancel`/`list`/`resend`. Also gates role assignment — see `allowedRoles`. |\n| `allowedRoles` | `string[]` | — (any role) | When set, `sendInvite` rejects any `role` not in the list with `ROLE_NOT_ALLOWED`. Set this whenever `canInvite` is looser than \"admins only\". |\n| `autoSignIn` | `boolean` | `true` | Create a session + set the cookie when an invitation is accepted. |\n| `allowReInvite` | `boolean` | `true` | Re-inviting a pending email cancels the old invitation and issues a fresh token. When `false`, it throws `INVITATION_ALREADY_SENT`. |\n| `requirePassword` | `boolean` | `true` | Accepting requires choosing a password (credential account; needs `emailAndPassword` enabled — the plugin fails fast at startup otherwise). Set to `false` to create the user without a credential account so they finish sign-in via any other enabled method (social provider, magic link, passkey, ...). |\n| `claimOnSignUp` | `boolean` | `true` | When the invited email signs up through another flow (OAuth/social callback, email sign-up, magic link, email OTP, ...), automatically claim the pending invitation: mark it accepted, apply its `role` and fire `onInvitationAccepted`. See [Invites and OAuth](#invites-and-oauth-sign-up). |\n| `requireInvite` | `boolean \\| { allowFirstUser?, allowedEmailDomains?, allowOrganizationInvitations?, allow? }` | `false` | Invite-only sign-up — see [below](#invite-only-sign-up). |\n| `maxMetadataSize` | `number` | `4096` | Cap on the serialized JSON length of an invitation's `metadata`; larger payloads are rejected with `METADATA_TOO_LARGE`. |\n| `onInvitationAccepted` | `({ invitation, user }, ctx) => void \\| Promise<void>` | — | Called after the user is created and the invitation marked accepted (before the session response). Use it to provision what the invite was for (org membership, seat, ...). **Runs inside the acceptance transaction**: throwing rolls the whole acceptance back. |\n| `onInvitationSent` | `({ invitation, inviter, resent }, ctx) => void \\| Promise<void>` | — | Fired after an invitation email is handed off, on send and resend. Best-effort: errors are logged, never surfaced. |\n| `onInvitationCanceled` | `({ invitation, rejected }, ctx) => void \\| Promise<void>` | — | Fired when an invitation is canceled by a manager or rejected by its recipient (`rejected: true`). Best-effort. |\n| `schema` | Better Auth schema override | — | Rename the `invite` table / columns (standard `modelName` / `fields` passthrough). |\n\n### Invite-only sign-up\n\nBy default the plugin issues invitations but does not restrict registration —\nanyone can still hit `/sign-up/email` or \"Continue with Google\". Set\n`requireInvite` to close that:\n\n```ts\ninvite({\n  sendInvitationEmail,\n  inviteRedirectURL,\n  requireInvite: true,\n  // or, with escape hatches:\n  requireInvite: {\n    allowFirstUser: true,               // default: bootstrap an empty app\n    allowedEmailDomains: [\"acme.com\"],  // staff never need an invite\n    allow: async ({ email }, ctx) => false, // last word\n  },\n});\n```\n\nThe check runs as a `user.create` **database hook**, not per-route, so it\ncovers every way a user can come into existence — email sign-up, OAuth\ncallbacks, magic link, email OTP, plugins this one has never heard of — and\nuninvited creation fails with `403 SIGN_UP_REQUIRES_INVITATION`. Two paths are\nalways exempt: this plugin's own `/invite/accept` (its invitation is already\nclaimed by the time the user row is written) and the admin plugin's\n`/admin/create-user` (an authorized admin creating a user on purpose).\n\nOrder of checks: a live pending invitation for the address → a pending\ninvitation from the organization plugin → `allowedEmailDomains` →\n`allowFirstUser` when the `user` table is empty → your `allow()`.\n\nWith Better Auth's organization plugin mounted, a pending organization\ninvitation counts as an invitation: its recipient needs an account before they\ncan accept it, so an invite-only app would otherwise lock them out. This is\ndetected automatically (the organization plugin's `invitation` table is in the\nschema); set `allowOrganizationInvitations: false` to ignore organization\ninvitations, or `true` to fail at startup if the organization plugin is\nmissing. See [Inviting to an organization](#inviting-to-an-organization).\n\n### Sending invitation emails in the background\n\n`sendInvitationEmail` is awaited by default, and a failure is a real failure:\nthe invitation is rolled back (and a resend restores the previous token) and\nthe caller gets `500 FAILED_TO_SEND_INVITATION_EMAIL`, so you never end up with\na live invitation nobody received or a dead link nobody replaced.\n\nIf your app configures Better Auth's `advanced.backgroundTasks.handler` —\n`waitUntil` on Vercel/Cloudflare, a queue, ... — delivery is dispatched through\nit instead and the response is not held open, matching how Better Auth core\nsends its own verification emails. Delivery errors are then logged rather than\nreturned, and the invitation stands (resend it).\n\n### Housekeeping\n\nInvitations accumulate. `POST /invite/purge` deletes the finished ones —\n`expired`, `canceled` and `rejected` by default — and returns how many it\nremoved:\n\n```ts\n// nightly cron\nawait auth.api.purgeInvites({\n  body: { statuses: [\"expired\", \"canceled\", \"rejected\"], olderThan: 60 * 60 * 24 * 30 },\n  headers: adminHeaders,\n});\n```\n\n### Invitation metadata\n\nAttach arbitrary JSON context to an invitation (team id, locale, plan, a personal message, ...) via the `metadata` body field of `send`. It is stored as a JSON string in the `metadata` column and returned parsed on the invitation everywhere it appears: `sendInvitationEmail`'s `data.invitation.metadata`, `/invite/get`, `/invite/list`, and `onInvitationAccepted`.\n\n```ts\nawait authClient.invite.send({\n  email: \"new@user.com\",\n  role: \"member\",\n  metadata: { teamId: \"team_1\", locale: \"en\" },\n});\n```\n\n### Additional user fields on accept\n\nIf your app defines `user.additionalFields`, the accept body forwards them to the created user exactly like sign-up does (required fields are enforced, `input: false` fields are rejected):\n\n```ts\nawait authClient.invite.accept({ token, password, username: \"picked-name\" });\n```\n\n### Invites and OAuth (sign-up)\n\nAn invitation is addressed to an email; OAuth is just another way for the\ninvitee to prove they own it. Two paths lead to a fully accepted invitation:\n\n- **Via the invite link**: the accept page calls `acceptInvite` with the\n  token. With `requirePassword: false` the user is created without a\n  credential account and later signs in with any enabled method — a social\n  sign-in on the same (verified) email links to the created user through\n  Better Auth's account linking.\n- **Directly, skipping the link** (`claimOnSignUp`, on by default): if the\n  invitee ignores the email and just hits \"Continue with Google\" (or signs\n  up with email, magic link, email OTP, ...), the plugin claims the matching\n  pending invitation as the user row is created — marks it accepted, applies\n  its `role` to the new user, and fires `onInvitationAccepted`. The invite\n  token becomes unusable (`INVITATION_ALREADY_ACCEPTED`), so a later click on\n  the emailed link cannot double-accept.\n\nClaiming hangs off a `user.create` database hook rather than a list of known\nsign-up routes, so it covers every flow that can create a user — including\nones no allowlist would have anticipated — and costs nothing on the sign-in\npath of users who already exist.\n\nClaiming is atomic (a concurrent `acceptInvite` or `resend` wins cleanly)\nand never breaks the sign-up that triggered it: claim errors are logged,\nnot thrown. Note that claiming does not verify the invited address beyond\nwhat the sign-up method itself verified — it does not set `emailVerified`.\n\n### Inviting to an organization\n\nBetter Auth's [organization plugin](https://better-auth.com/docs/plugins/organization)\nhas invitations of its own, but they only work for people who already have an\naccount: `organization.acceptInvitation` — and even `getInvitation` — require\na session whose email matches the invitation. This plugin covers the other\nhalf, the person who is not a user yet, and the two compose without either\nimporting the other:\n\n- **Invite-only apps**: with `requireInvite`, a pending organization\n  invitation lets its recipient sign up, after which they accept it as usual\n  (on by default when the organization plugin is mounted — see\n  [above](#invite-only-sign-up)).\n- **One invite that does both**: pick the invitation by whether the address is\n  registered, and let the app invitation carry the organization.\n\n```ts\nimport { APIError } from \"better-auth/api\";\n\n// server-side, e.g. a server action; `headers` carries the inviter's session\nasync function inviteToOrganization({ email, organizationId, role, headers }) {\n  try {\n    // not a user yet: an app invitation that remembers the organization\n    return await auth.api.sendInvite({\n      body: { email, metadata: { organizationId, organizationRole: role } },\n      headers,\n    });\n  } catch (error) {\n    if (!(error instanceof APIError) || error.body?.code !== \"USER_ALREADY_EXISTS\") {\n      throw error;\n    }\n    // already a user: a plain organization invitation\n    return await auth.api.createInvitation({\n      body: { email, role, organizationId },\n      headers,\n    });\n  }\n}\n```\n\nThen provision the membership when the app invitation is accepted.\n`onInvitationAccepted` runs inside the acceptance transaction, so the user and\ntheir membership are created together or not at all:\n\n```ts\ninvite({\n  sendInvitationEmail,\n  inviteRedirectURL,\n  onInvitationAccepted: async ({ invitation, user }, ctx) => {\n    const { organizationId, organizationRole } = invitation.metadata ?? {};\n    if (typeof organizationId !== \"string\") return;\n    await ctx.context.adapter.create({\n      model: \"member\",\n      data: {\n        organizationId,\n        userId: user.id,\n        role: typeof organizationRole === \"string\" ? organizationRole : \"member\",\n        createdAt: new Date(),\n      },\n    });\n  },\n});\n```\n\nThe invited person lands on your accept page, picks a password, and is a\nmember of the organization by the time they are signed in — there is no\nseparate \"accept the organization invitation\" step, because the invite token\nalready proved they own the address. (Call `organization.setActive` afterwards\nif that organization should be the active one in the session.)\n\nWriting the `member` row directly bypasses the organization plugin's\n`membershipLimit` and member hooks. If those matter, create the organization\ninvitation up front instead (`auth.api.createInvitation`, which also enforces\nthe inviter's organization permissions), store its `id` in the app\ninvitation's `metadata`, and have the accept page call\n`organization.acceptInvitation({ invitationId })` right after `invite.accept`\nhas signed the user in — `requireInvite` lets that sign-up through either way.\n\nTwo different roles are in play: the app invitation's `role` becomes\n`user.role`, while the organization role travels in `metadata` (here\n`organizationRole`) or on the organization invitation.\n\n## The accept-page flow\n\n1. Point `inviteRedirectURL` at a page in your app, e.g. `https://app.example.com/accept-invite`. The invitation email's `url` becomes `https://app.example.com/accept-invite?token=<raw token>`. (This is why the option is required: `/invite/accept` is a POST endpoint that also needs a password in the body, so a browser can't open it from an email link.)\n2. On that page, read the `token` query parameter and (optionally) show who's being invited:\n\n   ```ts\n   const { data, error } = await authClient.invite.get({ query: { token } });\n   // data: { email, name, role, metadata, status, expiresAt }\n   ```\n\n3. Ask the user for a password and accept:\n\n   ```ts\n   const { data, error } = await authClient.invite.accept({\n     token,\n     password,\n   });\n   // user is created and (by default) signed in — redirect to your app\n   ```\n\nThe invited user lands in your app and sets a password as part of acceptance — atomically, so no passwordless half-registered state ever exists. The claim, the user, their credential account, and `onInvitationAccepted` all run in one transaction (via Better Auth's own `runWithTransaction`), so if any step fails the whole acceptance is rolled back and the same link can be retried. On adapters without transaction support the plugin falls back to compensating writes: it deletes the partial user and returns the invitation to `pending`.\n\nThe password is validated against your `emailAndPassword` `minPasswordLength` / `maxPasswordLength`, exactly like sign-up.\n\nThe user is provisioned with `{ method: \"invite\" }` as its Better Auth provisioning source, so a `user.validateUserInfo` gate can single out invite acceptances:\n\n```ts\nuser: {\n  validateUserInfo: async ({ method }) =>\n    method === \"invite\" ? undefined : { error: \"invite_only\" },\n},\n```\n\n## Errors\n\nExposed on the plugin as `$ERROR_CODES` (and exported as `INVITE_ERROR_CODES`):\n\n`USER_ALREADY_EXISTS`, `INVITATION_NOT_FOUND`, `INVITATION_EXPIRED`, `INVITATION_ALREADY_ACCEPTED`, `INVITATION_CANCELED`, `INVITATION_REJECTED`, `INVITATION_ALREADY_SENT`, `NOT_AUTHORIZED_TO_INVITE`, `FAILED_TO_CREATE_USER`, `FAILED_TO_SEND_INVITATION_EMAIL`, `PASSWORD_TOO_SHORT`, `PASSWORD_TOO_LONG`, `PASSWORD_REQUIRED`, `ROLE_NOT_ALLOWED`, `METADATA_TOO_LARGE`, `SIGN_UP_REQUIRES_INVITATION`.\n\n## Security notes\n\n- **Hashed at rest**: only the SHA-256 hash of the invite token is stored. A database leak does not expose usable invite links. The raw token appears exactly once, in your `sendInvitationEmail` callback; the `send`/`resend` endpoints never return it to the caller.\n- **Role is never caller-supplied**: the created user's `role` comes from the invitation only. A `role` field in the accept body is discarded, even if your user schema declares `role` as an input-able additional field.\n- **Single-use**: acceptance atomically flips the invitation from `pending` to `accepted` (guarded update conditioned on both status and token hash), so a token can only ever create one user, even under concurrent accepts — and a token rotated by a concurrent resend loses the race too. If user creation fails mid-acceptance, the partial user is deleted and the claim released, so no orphaned verified user is left behind.\n- **Expiring**: invitations expire after `expiresIn` seconds (default 24h, capped at 1 year); expiry is enforced at read/accept time with a distinct `INVITATION_EXPIRED` (HTTP 410) error.\n- **Rotation**: re-inviting or resending invalidates the previous token immediately.\n- **Rate limited**: the plugin ships rate-limit rules (10/min for `get`/`accept`/`reject`, 20/min for `send`/`resend`, 5/min for `send-bulk`/`purge`) that plug into Better Auth's rate limiter.\n- **Bounded metadata**: invitation `metadata` is capped at `maxMetadataSize` (4096 characters of JSON by default).\n- **No user enumeration on accept**: `get`/`accept` only respond to a valid token; guessing a 32-char alphanumeric token is infeasible (~190 bits). `send`, however, intentionally reports `USER_ALREADY_EXISTS` — see the `canInvite` notes above before granting invite rights broadly.\n- **Token travels in the URL**: like Better Auth's own email-verification links, the raw token is a query parameter of the emailed link and of `GET /invite/get`, so it can end up in server/proxy access logs and browser history until it is used or expires. Tokens are single-use, high-entropy (~190 bits), and short-lived; keep `expiresIn` short and have your accept page exchange the token via the POST accept call promptly. Treat log access as sensitive.\n- **Unique email index**: correctness under a concurrent independent sign-up for the invited address relies on the `user.email` unique index from Better Auth's core schema (the plugin also re-checks inside the acceptance claim). SQL adapters get this from migrations; for MongoDB, create the unique index yourself. The in-memory adapter enforces no uniqueness and is for tests only.\n\n## Naming\n\nThe database model and routes use the short noun — table `invite`, routes `/invite/*`, endpoints `sendInvite`/`cancelInvite`/... — because the `invitation` model name is already taken by the organization plugin. Domain nouns follow the organization plugin's vocabulary: the `Invitation` type, `invitationId` body fields, `sendInvitationEmail`, and `INVITATION_*` error codes.\n\n## License\n\nMIT\n","readmeFilename":"README.md"}