Skip to main content

Overview

There are two ways users can activate invitations:
  1. Direct activation - Programmatically activate using the token (API endpoint)
  2. Callback flow - User clicks email link and is redirected through activation

Direct activation

Use the activate endpoint when you have the invite token and want to handle the flow programmatically:
const result = await authClient.invite.activate({
  token: "invitation-token",
  callbackURL: "/dashboard",
});

Response types

The activation endpoint returns different responses based on user state:
{
  "status": true,
  "message": "Invite activated successfully",
  "redirectTo": "/dashboard" // from redirectToAfterUpgrade
}
The invitation is immediately accepted and the user’s role is updated.

Handling activation flow

try {
  const result = await authClient.invite.activate({
    token: inviteToken,
    callbackURL: "/dashboard",
  });

  if (result.action === "SIGN_IN_UP_REQUIRED") {
    // User needs to authenticate first
    window.location.href = result.redirectTo;
  } else {
    // Invite accepted, user role updated
    window.location.href = result.redirectTo;
  }
} catch (error) {
  if (error.errorCode === "INVALID_TOKEN") {
    alert("This invitation link is invalid or has expired");
  }
}
When users click invitation links from emails, they use the callback endpoint which handles redirects automatically. Invitation URLs follow this pattern:
https://yourapp.com/api/auth/invite/{token}?callbackURL={url}
Example:
https://yourapp.com/api/auth/invite/abc123xyz?callbackURL=https%3A%2F%2Fyourapp.com%2Fdashboard

Callback flow steps

2
The link opens in their browser and hits the /api/auth/invite/{token} endpoint.
3
Plugin validates the token
4
  • Checks if token exists and is not expired
  • Verifies token hasn’t exceeded max uses
  • For private invites, checks email matches (if user is logged in)
  • 5
    Plugin checks authentication
    6
    If user is logged in:
    7
  • Invite is immediately accepted
  • User role is updated in the database
  • User is redirected to redirectToAfterUpgrade (if configured) or callbackURL
  • 8
    If user is not logged in:
    9
  • Token is stored in a secure cookie
  • User is redirected to sign-in or sign-up page
  • After authentication, a hook automatically accepts the invite
  • 10
    User authenticates (if needed)
    11
    User completes sign-in or sign-up flow.
    12
    Invite is automatically accepted
    13
    A hook (invitesHooks) detects the invite cookie and:
    14
  • Updates the user’s role
  • Records the invite usage
  • Deletes the invite cookie
  • Redirects to the appropriate page
  • Error handling in callback flow

    If an error occurs during the callback flow, the user is redirected to the callbackURL with error parameters:
    https://yourapp.com/dashboard?error=INVALID_TOKEN&message=Invalid+or+expired+invite+code
    
    Handle these errors in your page:
    import { useSearchParams } from "next/navigation";
    
    function DashboardPage() {
      const searchParams = useSearchParams();
      const error = searchParams.get("error");
      const message = searchParams.get("message");
    
      if (error) {
        return (
          <div>
            <h1>Invitation Error</h1>
            <p>{message || "An error occurred while processing your invitation."}</p>
          </div>
        );
      }
    
      return <div>Welcome to your dashboard!</div>;
    }
    

    Authentication hooks

    The plugin hooks into Better Auth’s authentication flow to automatically accept invites:

    Supported authentication methods

    Invites are automatically activated after:
    • Email/password sign-up (/sign-up/email)
    • Email/password sign-in (/sign-in/email)
    • Email OTP sign-in (/sign-in/email-otp)
    • Social login callback (/callback/:id)
    • Email verification (/verify-email)
    The hook runs after these endpoints and checks for the invite cookie.

    Hook behavior

    // From hooks.ts - automatically executed by Better Auth
    export const invitesHooks = (options: NewInviteOptions) => {
      return {
        after: [
          {
            matcher: (context) =>
              context.path === "/sign-up/email" ||
              context.path === "/sign-in/email" ||
              // ... other paths
            handler: async (ctx) => {
              // Get invite token from cookie
              const inviteToken = await ctx.getSignedCookie("invite_token");
              
              if (!inviteToken) return;
              
              // Validate and accept invite
              // Update user role
              // Clean up cookie
            },
          },
        ],
      };
    };
    

    Private vs public invites

    Private invite activation

    Private invites (with email) have additional validation:
    const invitation = {
      email: "[email protected]",
      role: "admin",
      newAccount: true,
    };
    
    // For new users:
    // - Must sign up with matching email
    // - Token in cookie is validated after sign-up
    // - Role is assigned after account creation
    
    // For existing users:
    // - Must sign in with matching email
    // - Role is upgraded after sign-in
    

    Public invite activation

    Public invites (no email) can be used by anyone:
    const invitation = {
      role: "member",
      maxUses: 10,
    };
    
    // Anyone with the token can:
    // - Sign up with any email
    // - Sign in with their account
    // - Get assigned the role
    // - Multiple people can use the same token (up to maxUses)
    

    Example: Custom activation page

    import { useEffect, useState } from "react";
    import { useRouter, useSearchParams } from "next/navigation";
    import { authClient } from "@/lib/auth-client";
    
    export default function ActivateInvite() {
      const router = useRouter();
      const searchParams = useSearchParams();
      const [status, setStatus] = useState<"loading" | "error" | "success">("loading");
      const [message, setMessage] = useState("");
    
      useEffect(() => {
        const token = searchParams.get("token");
        const callbackURL = searchParams.get("callbackURL") || "/dashboard";
    
        if (!token) {
          setStatus("error");
          setMessage("No invitation token provided");
          return;
        }
    
        activateInvite(token, callbackURL);
      }, [searchParams]);
    
      const activateInvite = async (token: string, callbackURL: string) => {
        try {
          const result = await authClient.invite.activate({
            token,
            callbackURL,
          });
    
          if (result.action === "SIGN_IN_UP_REQUIRED") {
            setMessage("Redirecting to sign in...");
            setTimeout(() => router.push(result.redirectTo), 1000);
          } else {
            setStatus("success");
            setMessage("Invitation accepted! Redirecting...");
            setTimeout(() => router.push(result.redirectTo), 1000);
          }
        } catch (error) {
          setStatus("error");
          setMessage(error.message || "Failed to activate invitation");
        }
      };
    
      return (
        <div className="activation-page">
          {status === "loading" && <p>Activating your invitation...</p>}
          {status === "success" && <p>{message}</p>}
          {status === "error" && (
            <div>
              <h2>Activation Failed</h2>
              <p>{message}</p>
              <button onClick={() => router.push("/")}>Go Home</button>
            </div>
          )}
        </div>
      );
    }
    

    Custom invite URLs

    You can customize the invitation URL format:
    invite({
      defaultCustomInviteUrl: "https://myapp.com/join?code={token}&next={callbackUrl}",
    })
    
    Then build your own activation page that extracts the token and uses the activate endpoint:
    // /join page
    const code = searchParams.get("code");
    const next = searchParams.get("next");
    
    if (code) {
      await authClient.invite.activate({
        token: code,
        callbackURL: next || "/dashboard",
      });
    }
    

    Redirect after upgrade

    When a logged-in user accepts an invite, control where they go:

    Server default

    invite({
      defaultRedirectAfterUpgrade: "/welcome?upgraded=true",
    })
    

    Per-invitation override

    await authClient.invite.create({
      email: "[email protected]",
      role: "admin",
      redirectToAfterUpgrade: "/admin/dashboard?new=true",
    });
    

    Using token in redirect

    invite({
      defaultRedirectAfterUpgrade: "/dashboard?token={token}",
    })
    
    // Redirects to: /dashboard?token=abc123xyz
    // Use this to show a welcome message or track invite source
    
    The invite cookie stores the token during sign-up flow:
    invite({
      inviteCookieMaxAge: 10 * 60, // 10 minutes (default)
    })
    
    The cookie is automatically deleted after the invite is accepted or if it expires. Users must complete authentication within the cookie’s max age.
    If a user takes too long to sign up (longer than inviteCookieMaxAge), the invite cookie will expire and they’ll need a new invitation link.

    Next steps

    Managing invites

    Cancel, reject, and retrieve invitations

    Hooks and callbacks

    Respond to invitation lifecycle events

    Build docs developers (and LLMs) love