Overview
The invite plugin requires email configuration to send private invitations (invitations with an email address). The sendUserInvitation function is called automatically when creating private invites.
Private invites will fail without email configuration. You must implement sendUserInvitation to create invitations for specific email addresses.
Basic setup
Configure the sendUserInvitation function in your plugin options:
import { betterAuth } from "better-auth" ;
import { invite } from "better-auth/plugins" ;
import { sendEmail } from "./email" ; // Your email service
export const auth = betterAuth ({
plugins: [
invite ({
sendUserInvitation : async ( data , request ) => {
await sendEmail ({
to: data . email ,
subject: `You've been invited to join as ${ data . role } ` ,
html: `
<h1>You've been invited!</h1>
<p>Click <a href=" ${ data . url } ">here</a> to accept your invitation.</p>
` ,
});
},
}),
],
});
Function signature
The sendUserInvitation function receives two parameters:
Data parameter
The recipient’s email address.
The recipient’s name. Available for existing users, undefined for new users.
The role the user will receive when accepting the invitation.
The complete activation URL. This includes the base URL, token, and callback URL. Example: https://yourapp.com/api/auth/invite/abc123?callbackURL=https%3A%2F%2Fyourapp.com%2Fdashboard
The raw invitation token. Useful if you want to construct your own URL or display the token.
true: The recipient needs to create a new account
false: The recipient has an existing account and their role will be upgraded
Request parameter
The original HTTP request object. Use this to access headers, determine the origin, or extract other request context.
Email templates
Here are example templates for different scenarios:
New user invitation
sendUserInvitation : async ( data ) => {
if ( data . newAccount ) {
await sendEmail ({
to: data . email ,
subject: "You've been invited to join our platform" ,
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.button {
display: inline-block;
padding: 12px 24px;
background: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
}
.footer { margin-top: 30px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<h1>Welcome!</h1>
<p>You've been invited to join our platform as a <strong> ${ data . role } </strong>.</p>
<p>
<a href=" ${ data . url } " class="button">Accept Invitation</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p><code> ${ data . url } </code></p>
<div class="footer">
<p>This invitation will expire in 7 days.</p>
<p>If you didn't expect this invitation, you can safely ignore this email.</p>
</div>
</div>
</body>
</html>
` ,
});
}
}
Role upgrade invitation
sendUserInvitation : async ( data ) => {
if ( ! data . newAccount ) {
await sendEmail ({
to: data . email ,
subject: `Your role has been upgraded to ${ data . role } ` ,
html: `
<h1>Role Upgrade</h1>
<p>Hi ${ data . name || 'there' } ,</p>
<p>Your account has been upgraded to <strong> ${ data . role } </strong>!</p>
<p>
<a href=" ${ data . url } ">Click here to activate your new role</a>
</p>
<p>You'll need to sign in to confirm this change.</p>
` ,
});
}
}
Combined template
sendUserInvitation : async ( data ) => {
const subject = data . newAccount
? "You've been invited to join"
: `Your role has been upgraded to ${ data . role } ` ;
const greeting = data . name ? `Hi ${ data . name } ,` : "Hello," ;
const action = data . newAccount
? "Create your account and get started"
: "Sign in to activate your new role" ;
await sendEmail ({
to: data . email ,
subject ,
html: `
<h1> ${ subject } </h1>
<p> ${ greeting } </p>
<p> ${ action } :</p>
<p><a href=" ${ data . url } "> ${ action } </a></p>
<p>Role: <strong> ${ data . role } </strong></p>
` ,
});
}
Email service providers
Integrate with popular email services:
Resend
Nodemailer
SendGrid
Postmark
import { Resend } from "resend" ;
const resend = new Resend ( process . env . RESEND_API_KEY );
export const auth = betterAuth ({
plugins: [
invite ({
sendUserInvitation : async ( data ) => {
await resend . emails . send ({
from: "[email protected] " ,
to: data . email ,
subject: `Invitation to join as ${ data . role } ` ,
html: `
<p>Click <a href=" ${ data . url } ">here</a> to accept.</p>
` ,
});
},
}),
],
});
import nodemailer from "nodemailer" ;
const transporter = nodemailer . createTransport ({
host: process . env . SMTP_HOST ,
port: Number ( process . env . SMTP_PORT ),
auth: {
user: process . env . SMTP_USER ,
pass: process . env . SMTP_PASS ,
},
});
export const auth = betterAuth ({
plugins: [
invite ({
sendUserInvitation : async ( data ) => {
await transporter . sendMail ({
from: '"Your App" <[email protected] >' ,
to: data . email ,
subject: `Invitation to join as ${ data . role } ` ,
html: `<a href=" ${ data . url } ">Accept invitation</a>` ,
});
},
}),
],
});
import sgMail from "@sendgrid/mail" ;
sgMail . setApiKey ( process . env . SENDGRID_API_KEY ! );
export const auth = betterAuth ({
plugins: [
invite ({
sendUserInvitation : async ( data ) => {
await sgMail . send ({
from: "[email protected] " ,
to: data . email ,
subject: `Invitation to join as ${ data . role } ` ,
html: `<a href=" ${ data . url } ">Accept invitation</a>` ,
});
},
}),
],
});
import postmark from "postmark" ;
const client = new postmark . ServerClient ( process . env . POSTMARK_API_KEY ! );
export const auth = betterAuth ({
plugins: [
invite ({
sendUserInvitation : async ( data ) => {
await client . sendEmail ({
From: "[email protected] " ,
To: data . email ,
Subject: `Invitation to join as ${ data . role } ` ,
HtmlBody: `<a href=" ${ data . url } ">Accept invitation</a>` ,
});
},
}),
],
});
URL generation
The data.url parameter contains a fully formed activation URL:
https://yourapp.com/api/auth/invite/{token}?callbackURL={callbackURL}
This URL is constructed in src/utils.ts by the createRedirectURL function:
export const createRedirectURL = ({
ctx ,
invitation ,
callbackURL ,
customInviteUrl ,
} : {
ctx : GenericEndpointContext ;
invitation : InviteTypeWithId ;
callbackURL : string ;
customInviteUrl ?: string ;
}) => {
if ( ! customInviteUrl ) {
return ` ${ ctx . context . baseURL } /invite/ ${ invitation . token } ?callbackURL= ${ encodeURIComponent ( callbackURL ) } ` ;
}
return customInviteUrl
. replace ( "{token}" , invitation . token )
. replace ( "{callbackURL}" , encodeURIComponent ( callbackURL ));
};
Override the URL format using customInviteUrl:
invite ({
defaultCustomInviteUrl: "https://myapp.com/join?code={token}&next={callbackURL}" ,
sendUserInvitation : async ( data ) => {
// data.url will use your custom format:
// https://myapp.com/join?code=abc123&next=...
await sendEmail ({ to: data . email , html: `<a href=" ${ data . url } ">Join</a>` });
},
})
Per-invitation URLs
You can also override the URL per invitation:
await authClient . invite . create ({
email: "[email protected] " ,
role: "member" ,
customInviteUrl: "https://special.myapp.com/invite/{token}" ,
});
Testing emails
Use a local email testing tool during development:
MailHog
# Start MailHog
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
# Configure Nodemailer
const transporter = nodemailer.createTransport ({
host: "localhost",
port: 1025,
ignoreTLS: true ,
});
# View emails at http://localhost:8025
Ethereal Email
import nodemailer from "nodemailer" ;
// Generate test credentials
const testAccount = await nodemailer . createTestAccount ();
const transporter = nodemailer . createTransport ({
host: "smtp.ethereal.email" ,
port: 587 ,
auth: {
user: testAccount . user ,
pass: testAccount . pass ,
},
});
export const auth = betterAuth ({
plugins: [
invite ({
sendUserInvitation : async ( data ) => {
const info = await transporter . sendMail ({
from: '"Test" <[email protected] >' ,
to: data . email ,
subject: "Test invite" ,
html: `<a href=" ${ data . url } ">Accept</a>` ,
});
// Preview URL: https://ethereal.email/message/...
console . log ( "Preview:" , nodemailer . getTestMessageUrl ( info ));
},
}),
],
});
Error handling
Handle email sending failures gracefully:
invite ({
sendUserInvitation : async ( data , request ) => {
try {
await sendEmail ({
to: data . email ,
subject: "Invitation" ,
html: `<a href=" ${ data . url } ">Accept</a>` ,
});
} catch ( error ) {
// Log the error for debugging
console . error ( "Failed to send invitation email:" , error );
// The plugin will catch this and return an error to the client
throw new Error ( "Failed to send invitation email" );
}
},
})
The plugin catches errors from sendUserInvitation and returns:
{
"message" : "Error sending the invitation email" ,
"errorCode" : "ERROR_SENDING_THE_INVITATION_EMAIL"
}
Logging and monitoring
Log email activity for debugging and monitoring:
invite ({
sendUserInvitation : async ( data , request ) => {
console . log ( "Sending invitation:" , {
to: data . email ,
role: data . role ,
newAccount: data . newAccount ,
token: data . token . substring ( 0 , 8 ) + "..." , // Log partial token
});
await sendEmail ({ ... });
console . log ( "Invitation sent successfully to" , data . email );
},
})
Deprecated: sendUserRoleUpgrade
The sendUserRoleUpgrade option is deprecated. Use sendUserInvitation instead, which receives a newAccount parameter to distinguish between new users and role upgrades.
If you’re upgrading from an older version:
// Old (deprecated)
invite ({
sendUserRoleUpgrade : async ( data ) => {
// Only called for existing users
},
})
// New (recommended)
invite ({
sendUserInvitation : async ( data ) => {
if ( data . newAccount ) {
// New user - send welcome email
} else {
// Existing user - send role upgrade email
}
},
})
Complete example
import { betterAuth } from "better-auth" ;
import { invite } from "better-auth/plugins" ;
import { Resend } from "resend" ;
const resend = new Resend ( process . env . RESEND_API_KEY );
export const auth = betterAuth ({
plugins: [
invite ({
// Email configuration
sendUserInvitation : async ( data , request ) => {
const origin = new URL ( request . url ). origin ;
const subject = data . newAccount
? `You've been invited to join ${ origin } `
: `Your role has been upgraded to ${ data . role } ` ;
const greeting = data . name ? `Hi ${ data . name } ,` : "Hello," ;
try {
await resend . emails . send ({
from: "[email protected] " ,
to: data . email ,
subject ,
html: `
<!DOCTYPE html>
<html>
<body style="font-family: Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1> ${ subject } </h1>
<p> ${ greeting } </p>
${ data . newAccount
? `<p>You've been invited to join as a <strong> ${ data . role } </strong>.</p>`
: `<p>Your account has been upgraded to <strong> ${ data . role } </strong>.</p>`
}
<p>
<a href=" ${ data . url } " style="display: inline-block; padding: 12px 24px; background: #007bff; color: white; text-decoration: none; border-radius: 4px;">
${ data . newAccount ? "Accept Invitation" : "Activate New Role" }
</a>
</p>
<p style="color: #666; font-size: 12px; margin-top: 30px;">
This invitation expires in 7 days.
</p>
</div>
</body>
</html>
` ,
});
} catch ( error ) {
console . error ( "Failed to send invitation email:" , error );
throw error ;
}
},
// Other options
invitationTokenExpiresIn: 7 * 24 * 60 * 60 , // 7 days
}),
],
});
Next steps
Creating invites Learn how to create invitations
Hooks and callbacks Respond to invitation lifecycle events