Enterprise
Invitations
Complete invitation lifecycle — sending, accepting, expiring, and revoking organization invitations.
Invitations allow organization administrators to invite new members by email. The invitation system handles the full lifecycle: creation, email delivery, acceptance, expiration, and revocation.
How Invitations Work
1. Admin sends invitation → POST /api/auth/organization/invite
2. Server creates invitation record (status: "pending")
3. Server sends the invitation email using Banata's configured provider and template
4. Recipient receives the invitation email
5. Recipient clicks the link
6. If not signed in → redirected to sign-up, then acceptance
7. If signed in → invitation is accepted immediately
8. Member record is created with the specified role
9. Invitation status changes to "accepted"Invitation Data Model
interface Invitation {
id: string; // e.g., "inv_01HXYZ..."
email: string; // Invitee's email address
organizationId: string; // Target organization
role: string; // Role slug to assign (for example "hr_admin")
inviterId: string; // User who sent the invitation
status: "pending" | "accepted" | "expired" | "revoked";
expiresAt: string; // ISO 8601 — 7 days from creation
createdAt: string;
}Status Lifecycle
┌──── accepted (user clicked link & joined)
│
pending ─────────┤
│
├──── expired (7 days passed, not accepted)
│
└──── revoked (admin cancelled invitation)| Status | Description | Terminal? |
|---|---|---|
pending | Invitation sent, waiting for response | No |
accepted | Invitee accepted and joined the organization | Yes |
expired | Invitation passed its 7-day expiration window | Yes |
revoked | Admin cancelled the invitation before acceptance | Yes |
Configuration
Enable organizations, then configure invitation delivery in the dashboard:
// convex/banataAuth/auth.ts
function buildConfig(): BanataAuthConfig {
return {
// ...other config...
authMethods: {
organization: true,
},
emailOptions: {
fromAddress: "MyApp <noreply@myapp.com>",
},
};
}Default Delivery Model
The default invitation flow is dashboard-first:
- Configure an active provider in Emails > Providers
- Keep invitation emails enabled in Emails > Configuration
- Customize the built-in
invitationtemplate in Email Templates - Call
authClient.organization.inviteMember(...)
Banata then renders and sends the invitation email automatically.
Optional Code Override
If you want your app to own invitation delivery, add email.sendInvitationEmail:
email: {
sendInvitationEmail: async ({
email,
invitationId,
organizationName,
inviterName,
}) => {
const inviteUrl = `${process.env.SITE_URL}/invite/${invitationId}`;
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
},
body: JSON.stringify({
from: "MyApp <noreply@myapp.com>",
to: email,
subject: `You've been invited to join ${organizationName}`,
html: `<a href="${inviteUrl}">Accept Invitation</a>`,
}),
});
},
},Override Callback Parameters
The sendInvitationEmail override receives an object with:
| Field | Type | Description |
|---|---|---|
email | string | Invitee's email |
invitationId | string | Unique invitation ID |
organizationName | string | Display name of the organization |
inviterName | string | Name of the user who sent the invitation |
Key difference from other callbacks: Unlike
sendMagicLinkwhich gives you a ready-madeurl, the invitation override provides theinvitationId. You must construct the acceptance URL yourself using yourSITE_URL.
Client-Side API
Send an Invitation
import { authClient } from "@/lib/auth-client";
const { error } = await authClient.organization.inviteMember({
organizationId: "org_01HXYZ...",
email: "newuser@example.com",
role: "hr_admin", // Any Banata role slug defined in your project
});
if (error) {
// Possible errors:
// - "User is already a member" (409)
// - "Invitation already pending for this email" (409)
// - "Organization membership limit reached" (422)
// - "Forbidden" (403) — user doesn't have permission to invite
console.error(error.message);
}List Pending Invitations
const { data: invitations } = await authClient.organization.listInvitations({
organizationId: "org_01HXYZ...",
});
// invitations = [{ id, email, role, status, expiresAt, ... }, ...]Accept an Invitation
const { error } = await authClient.organization.acceptInvitation({
invitationId: "inv_01HXYZ...",
});
if (error) {
// - "Invitation expired" (410)
// - "Invitation already accepted" (409)
// - "Invitation revoked" (410)
console.error(error.message);
} else {
// User is now a member of the organization
// Set it as the active organization
await authClient.organization.setActive({
organizationId: invitation.organizationId,
});
}Revoke an Invitation
const { error } = await authClient.organization.revokeInvitation({
invitationId: "inv_01HXYZ...",
});Invitation Acceptance Page
Build a page at /invite/[id] that handles invitation acceptance:
// src/app/invite/[id]/page.tsx
"use client";
import { authClient } from "@/lib/auth-client";
import { useUser } from "@banata-auth/react";
import { useParams } from "next/navigation";
import { useState, useEffect } from "react";
export default function InvitePage() {
const { id } = useParams();
const { user, isLoading } = useUser();
const [status, setStatus] = useState<"loading" | "accepting" | "success" | "error">("loading");
const [error, setError] = useState("");
useEffect(() => {
if (isLoading) return;
if (!user) {
// Not signed in — redirect to sign-up with return URL
window.location.href = `/sign-up?redirect_url=/invite/${id}`;
return;
}
// User is signed in — accept the invitation
acceptInvitation();
}, [user, isLoading]);
async function acceptInvitation() {
setStatus("accepting");
const { error } = await authClient.organization.acceptInvitation({
invitationId: id as string,
});
if (error) {
setStatus("error");
setError(error.message ?? "Failed to accept invitation");
} else {
setStatus("success");
// Redirect to the org after a brief delay
setTimeout(() => {
window.location.href = "/dashboard";
}, 2000);
}
}
switch (status) {
case "loading":
case "accepting":
return <div>Accepting invitation...</div>;
case "success":
return (
<div>
<h1>Invitation Accepted!</h1>
<p>You've joined the organization. Redirecting...</p>
</div>
);
case "error":
return (
<div>
<h1>Invitation Error</h1>
<p>{error}</p>
<a href="/dashboard">Go to Dashboard</a>
</div>
);
}
}Server-Side Management (Admin SDK)
import { BanataAuth } from "@banata-auth/sdk";
const banata = new BanataAuth("your-api-key");
// Create an invitation
await banata.organizations.createInvitation({
organizationId: "org_01HXYZ...",
email: "user@example.com",
role: "admin",
});
// List invitations for an organization
const { data: invitations } = await banata.organizations.listInvitations({
organizationId: "org_01HXYZ...",
status: "pending", // Filter by status
});
// Revoke an invitation
await banata.organizations.revokeInvitation({
invitationId: "inv_01HXYZ...",
});Expiration
Invitations expire after 7 days (604,800 seconds), as defined in @banata-auth/shared:
export const TOKEN_LIFETIMES = {
invitation: 604800, // 7 days in seconds
};Once expired, the invitation's status changes to "expired" and cannot be accepted. The admin must send a new invitation.
Bulk Invitations
To invite multiple users at once, iterate over the emails:
const emails = [
{ email: "alice@example.com", role: "hr_admin" },
{ email: "bob@example.com", role: "payroll_viewer" },
{ email: "charlie@example.com", role: "people_ops" },
];
const results = await Promise.allSettled(
emails.map(({ email, role }) =>
authClient.organization.inviteMember({
organizationId: "org_01HXYZ...",
email,
role,
})
)
);
// Check which invitations succeeded/failed
results.forEach((result, i) => {
if (result.status === "rejected" || result.value?.error) {
console.error(`Failed to invite ${emails[i].email}`);
}
});Audit Events
| Event | When |
|---|---|
invitation.created | New invitation sent |
invitation.accepted | Invitation accepted, member added |
invitation.revoked | Invitation revoked by admin |
invitation.expired | Invitation expired without being accepted |
member.added | New member joined via invitation |
Troubleshooting
Invitation Email Not Sent
- Check Emails > Providers for an active provider
- Check Emails > Configuration and make sure invitation emails are enabled
- Check the
invitationtemplate in Email Templates - If you overrode delivery in code, check the Convex logs for errors in
sendInvitationEmail - Make sure
authMethods.organizationistrue
"Invitation Already Pending"
An invitation with the same email already exists for this organization. Either:
- Wait for it to expire (7 days)
- Revoke the existing invitation first, then send a new one
"Organization Membership Limit Reached"
The organization has reached the membershipLimit (default: 100). Increase it in your config:
organizationConfig: {
membershipLimit: 500,
},What's Next
- Roles & Permissions — Control what members can do within an organization
- Organizations — Full organization management guide
- Webhooks — Get notified about invitation events