Organizations & RBAC
Invitations
Invite members to organizations by email — create, accept, expire, and revoke invitations.
Invitations let you invite new members to your organizations by email. The invitation system handles the full lifecycle: create, deliver, accept, expire, and revoke.
How Invitations Work
When you send an invitation, here's what happens:
- You call
authClient.organization.inviteMember(...)from your app. - Banata creates an invitation record with status
"pending". - Banata renders and sends the invitation email using your configured provider and template.
- The recipient receives the email and clicks the invite link.
- If the recipient isn't signed in, they're redirected to sign up first.
- Once signed in, the invitation is accepted.
- A membership record is created with the role you specified.
- The invitation status changes to
"accepted".
Invitation Data Model
Each invitation contains the following fields:
interface Invitation {
id: string; // Unique invitation ID, e.g. "inv_01HXYZ..."
email: string; // Invitee's email address
organizationId: string; // Target organization
role: string; // Role to assign on acceptance (e.g. "admin")
inviterId: string; // User ID of whoever sent the invitation
status: "pending" | "accepted" | "expired" | "revoked";
expiresAt: string; // ISO 8601 timestamp — 7 days from creation
createdAt: string; // ISO 8601 timestamp
}Status Lifecycle
An invitation starts as pending and moves to one of three terminal states:
┌──── accepted (user clicked link and joined)
│
pending ─────────┤
│
├──── expired (7 days passed without acceptance)
│
└──── revoked (admin cancelled the 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 |
Email Delivery
Banata sends invitation emails automatically using the branded template system. Invitation emails use the invitation system template and inherit your project's branding (logo, colors, fonts) — no custom email-sending code is required.
Setting Up Email Delivery
- Configure an email provider — Go to Emails > Providers in the dashboard and add an active provider (e.g. Resend, SendGrid, or Postmark).
- Enable invitation emails — In Emails > Configuration, make sure invitation emails are turned on.
- Customize the template — Go to Email Templates and edit the
invitationsystem template to match your brand. You can customize the subject line, body text, and call-to-action button.
Once configured, every call to authClient.organization.inviteMember(...) triggers a branded invitation email automatically.
Overriding Invitation Emails
If you need full control over invitation email delivery, you can provide a custom callback:
import { banataAuth } from "@banata-auth/convex";
export const auth = banataAuth({
email: {
sendInvitationEmail: async ({ to, data }) => {
// data includes: email, invitationId, organizationName, inviterName, acceptUrl
await myEmailService.send({
to,
subject: `Join ${data.organizationName}`,
html: myCustomTemplate(data),
});
},
},
});When you provide this callback, Banata uses it instead of the built-in branded template. If you don't provide it, the branded template system handles everything automatically.
Client API
Send an Invitation
Use authClient.organization.inviteMember to invite a user by email:
import { authClient } from "@/lib/auth-client";
const { error } = await authClient.organization.inviteMember({
organizationId: "org_01HXYZ...",
email: "newuser@example.com",
role: "admin",
});
if (error) {
// Possible errors:
// - "User is already a member" (409)
// - "Invitation already pending for this email" (409)
// - "Organization membership limit reached" (422)
// - "Forbidden" (403) — caller lacks permission to invite
console.error(error.message);
}List Invitations
Fetch all invitations for an organization:
const { data: invitations } = await authClient.organization.listInvitations({
organizationId: "org_01HXYZ...",
});
// invitations = [{ id, email, role, status, expiresAt, ... }, ...]Accept an Invitation
Accept an invitation on behalf of the currently signed-in user:
const { error } = await authClient.organization.acceptInvitation({
invitationId: "inv_01HXYZ...",
});
if (error) {
// Possible errors:
// - "Invitation expired" (410)
// - "Invitation already accepted" (409)
// - "Invitation revoked" (410)
console.error(error.message);
} else {
// User is now a member — set the organization as active
await authClient.organization.setActive({
organizationId: invitation.organizationId,
});
}Revoke an Invitation
Cancel a pending invitation so it can no longer be accepted:
const { error } = await authClient.organization.revokeInvitation({
invitationId: "inv_01HXYZ...",
});Invitation Acceptance Page
You need a page in your app that handles the invite link. Create a route at /invite/[id]:
// 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 a return URL
window.location.href = `/sign-up?redirect_url=/invite/${id}`;
return;
}
// User is signed in — accept the invitation
accept();
}, [user, isLoading]);
async function accept() {
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");
setTimeout(() => {
window.location.href = "/dashboard";
}, 2000);
}
}
if (status === "loading" || status === "accepting") {
return <div>Accepting invitation...</div>;
}
if (status === "success") {
return (
<div>
<h1>Invitation Accepted!</h1>
<p>You've joined the organization. Redirecting to dashboard...</p>
</div>
);
}
return (
<div>
<h1>Invitation Error</h1>
<p>{error}</p>
<a href="/dashboard">Go to Dashboard</a>
</div>
);
}Server-Side Management
Use the Banata SDK to manage invitations from your server or backend scripts:
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 pending invitations for an organization
const { data: invitations } = await banata.organizations.listInvitations({
organizationId: "org_01HXYZ...",
status: "pending",
});
// Revoke an invitation
await banata.organizations.revokeInvitation({
invitationId: "inv_01HXYZ...",
});Expiration
Invitations expire after 7 days. Once expired, the status changes to "expired" and the link can no longer be used. To re-invite someone whose invitation expired, send a new invitation.
Bulk Invitations
To invite multiple people at once, use Promise.allSettled to send invitations in parallel:
const invitees = [
{ email: "alice@example.com", role: "admin" },
{ email: "bob@example.com", role: "member" },
{ email: "charlie@example.com", role: "viewer" },
];
const results = await Promise.allSettled(
invitees.map(({ email, role }) =>
authClient.organization.inviteMember({
organizationId: "org_01HXYZ...",
email,
role,
})
)
);
// Check which invitations succeeded or failed
results.forEach((result, i) => {
if (result.status === "rejected" || result.value?.error) {
console.error(`Failed to invite ${invitees[i].email}`);
} else {
console.log(`Invited ${invitees[i].email}`);
}
});Troubleshooting
Invitation email not sent
- Verify you have an active email provider in Emails > Providers in the dashboard.
- Check that invitation emails are enabled in Emails > Configuration.
- Review your
invitationtemplate in Email Templates for any issues. - Confirm the invitee's email address is valid and not malformed.
"Invitation already pending"
An invitation with the same email already exists for this organization. You have two options:
- Wait for it to expire (7 days).
- Revoke the existing invitation first, then send a new one.
"Organization membership limit reached"
Your organization has hit its membership cap (default: 100). You can increase the limit in your organization settings in the dashboard.
Invitation link not working
- The invitation may have expired (older than 7 days). Send a new one.
- The invitation may have been revoked by an admin.
- Make sure you have an
/invite/[id]route in your app that handles acceptance.
Next Steps
- Organizations — Learn how to create and manage organizations.
- Roles & Permissions — Control what members can do within an organization.
- Webhooks — Get notified when invitations are created, accepted, or revoked.