Docs

Frameworks and agents · Updated 2026-09-22

Send email from Auth.js

A custom sendVerificationRequest so the sign-in link goes out over HTTPS instead of an SMTP connection your host may not allow.

To send Auth.js sign-in links through AgentiSend, give the email provider a custom sendVerificationRequest that calls the HTTP API instead of opening an SMTP connection, which serverless hosts often do not allow. Auth.js calls it with the address, the signed URL and the provider config. The file below is executed against a real API on every build of this site's repository.

Install

pnpm add next-auth agentisend

Environment

VariableRequiredWhat it is
AGENTISEND_API_KEYyesA key with sending_access.
MAIL_FROMyesThe From address, on a domain you have verified. Used when the provider sets no from.
AGENTISEND_BASE_URLnoDefaults to https://api.agentisend.com.

The function

/**
 * Auth.js (NextAuth) — a custom `sendVerificationRequest` for the Nodemailer /
 * email provider, so the magic link goes out over the AgentiSend HTTP API
 * instead of an SMTP connection your host may not allow.
 *
 * Auth.js calls this with the address, the signed URL and the provider config.
 * Throwing makes Auth.js report the sign-in as failed, which is what you want:
 * a magic link that was never sent should never look like one that was.
 */
import { AgentiSend, AgentiSendError } 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 parameters Auth.js passes; only the fields used here are typed. */
export interface VerificationRequestParams {
  identifier: string;
  url: string;
  provider: { from?: string };
}

export async function sendVerificationRequest({
  identifier,
  url,
  provider,
}: VerificationRequestParams): Promise<void> {
  const host = new URL(url).host;
  try {
    await agentisend.emails.send(
      {
        from: provider.from ?? mailFrom(),
        to: identifier,
        subject: `Sign in to ${host}`,
        html: `<p><a href="${url}">Sign in to ${host}</a></p><p>If you did not ask for this, ignore it.</p>`,
        text: `Sign in to ${host}\n${url}\n`,
      },
      // The signed URL is unique per request, so it is the key: Auth.js
      // retrying after a timeout replays rather than sending a second link.
      { idempotencyKey: `signin-link/${new URL(url).searchParams.get('token') ?? url}` },
    );
  } catch (err) {
    if (err instanceof AgentiSendError) {
      throw new Error(`${err.code}: ${err.fix}`);
    }
    throw err;
  }
}
examples/authjs-email-provider/send-verification-request.ts, executed against the live API on every build

Wire it up

import NextAuth from 'next-auth';
import Nodemailer from 'next-auth/providers/nodemailer';
import { sendVerificationRequest } from './send-verification-request';

export const { handlers, auth } = NextAuth({
  providers: [Nodemailer({ from: process.env.MAIL_FROM, sendVerificationRequest })],
});
auth.ts

No SMTP server is configured; the provider entry exists so Auth.js runs its email flow, and the function above is the only thing that sends.

Why the idempotency key is the signed URL's token

The signed URL is unique per request, so its token is the key: Auth.js retrying after a timeout replays rather than sending a second link.

What comes back when it goes wrong

Every refused request carries code, message, fix and docs_url. The function throws with both, so Auth.js reports the sign-in as failed rather than pretending a link went out. The error catalogue lists every code.

Next