Frameworks and agents · Updated 2026-09-22
Send email from Better Auth
The sendVerificationEmail and sendResetPassword callbacks, sending through AgentiSend with an idempotency key built from the token.
To send Better Auth's verification and password-reset email through AgentiSend, implement the two callbacks it exposes, sendVerificationEmail and sendResetPassword, and spread them into betterAuth({ ... }). Better Auth calls each one with the user and a ready-made URL; everything else is yours. The file below is executed against a real API on every build of this site's repository.
Install
pnpm add better-auth agentisendEnvironment
| Variable | Required | What it is |
|---|---|---|
AGENTISEND_API_KEY | yes | A key with sending_access. |
MAIL_FROM | yes | The From address, on a domain you have verified. |
AGENTISEND_BASE_URL | no | Defaults to https://api.agentisend.com. |
The two callbacks
/**
* Better Auth — the two callbacks that decide how your auth mail leaves the
* building: `emailVerification.sendVerificationEmail` and
* `emailAndPassword.sendResetPassword`.
*
* Better Auth calls them with the user and a ready-made URL; everything else is
* yours. They are exported separately here so they can be spread into
* `betterAuth({ ... })` and tested on their own.
*/
import { AgentiSend } from 'agentisend';
const agentisend = new AgentiSend();
function mailFrom(): string {
const from = process.env.MAIL_FROM;
if (!from) throw new Error('Set MAIL_FROM to an address on a domain you have verified.');
return from;
}
/** The shape Better Auth passes these callbacks. */
export interface AuthEmailArgs {
user: { id: string; email: string; name?: string };
url: string;
token: string;
}
export async function sendVerificationEmail({ user, url, token }: AuthEmailArgs): Promise<void> {
await agentisend.emails.send(
{
from: mailFrom(),
to: user.email,
subject: 'Confirm your email',
html: `<p>Hello ${user.name ?? 'there'},</p><p><a href="${url}">Confirm your email</a></p>`,
},
// The token is the thing being confirmed, so it is the natural key: a
// retry after a network timeout replays instead of sending twice.
{ idempotencyKey: `verify-email/${token}` },
);
}
export async function sendResetPassword({ user, url, token }: AuthEmailArgs): Promise<void> {
await agentisend.emails.send(
{
from: mailFrom(),
to: user.email,
subject: 'Reset your password',
html: `<p><a href="${url}">Choose a new password</a></p><p>This link expires in one hour.</p>`,
},
{ idempotencyKey: `reset-password/${token}` },
);
}
/**
* Drop straight into your `auth.ts`:
*
* ```ts
* import { betterAuth } from 'better-auth';
* import { sendResetPassword, sendVerificationEmail } from './agentisend-email';
*
* export const auth = betterAuth({
* emailAndPassword: { enabled: true, sendResetPassword },
* emailVerification: { sendVerificationEmail, sendOnSignUp: true },
* });
* ```
*/
export const agentisendEmail = { sendVerificationEmail, sendResetPassword };Wire it up
import { betterAuth } from 'better-auth';
import { sendResetPassword, sendVerificationEmail } from './agentisend-email';
export const auth = betterAuth({
emailAndPassword: { enabled: true, sendResetPassword },
emailVerification: { sendVerificationEmail, sendOnSignUp: true },
});Why the idempotency key is the token
The token is the thing being confirmed, so it is the natural key: a retry after a network timeout replays instead of sending the same link twice.
What comes back when it goes wrong
Every refused request carries code, message, fix and docs_url. Better Auth surfaces a thrown error as a failed sign-up, which is what you want: a link that was never sent should never look like one that was. The error catalogue lists every code.