Banata

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:

  1. You call authClient.organization.inviteMember(...) from your app.
  2. Banata creates an invitation record with status "pending".
  3. Banata renders and sends the invitation email using your configured provider and template.
  4. The recipient receives the email and clicks the invite link.
  5. If the recipient isn't signed in, they're redirected to sign up first.
  6. Once signed in, the invitation is accepted.
  7. A membership record is created with the role you specified.
  8. The invitation status changes to "accepted".

Invitation Data Model

Each invitation contains the following fields:

typescript
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:

typescript
                 ┌──── accepted (user clicked link and joined)

pending ─────────┤

                 ├──── expired  (7 days passed without acceptance)

                 └──── revoked  (admin cancelled the invitation)
StatusDescriptionTerminal?
pendingInvitation sent, waiting for responseNo
acceptedInvitee accepted and joined the organizationYes
expiredInvitation passed its 7-day expiration windowYes
revokedAdmin cancelled the invitation before acceptanceYes

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

  1. Configure an email provider — Go to Emails > Providers in the dashboard and add an active provider (e.g. Resend, SendGrid, or Postmark).
  2. Enable invitation emails — In Emails > Configuration, make sure invitation emails are turned on.
  3. Customize the template — Go to Email Templates and edit the invitation system 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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]:

tsx
// 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:

typescript
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:

typescript
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

  1. Verify you have an active email provider in Emails > Providers in the dashboard.
  2. Check that invitation emails are enabled in Emails > Configuration.
  3. Review your invitation template in Email Templates for any issues.
  4. 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.

  • 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.