# How to send Better Auth emails with Email Bump

> Connect Better Auth to Email Bump for verification links, password resets, magic links, email OTPs, and organization invitations—with secure templates and production-safe delivery patterns.

- **Category:** Developer guide
- **Published:** July 31, 2026
- **Reading time:** 14 min read
- **Author:** Maya Chen, Email infrastructure
- **Canonical page:** [https://emailbump.com/blog/send-better-auth-emails](https://emailbump.com/blog/send-better-auth-emails)

Better Auth owns authentication state, tokens, verification rules, and the routes that consume those tokens. Email Bump owns the last mile: turning each authentication event into a branded transactional message and recording what happened after it was submitted. The connection between them is a small set of server-side callback functions.

This guide starts with email verification and password reset, then extends the same Email Bump adapter to magic links, email OTPs, and organization invitations. The examples use TypeScript and the Email Bump REST API, so they work in any Better Auth integration with a server-side fetch implementation.

> **Better Auth is provider-agnostic**
>
> Do not send auth email from the browser. Better Auth calls your server-side email hook with the recipient and a generated URL or code; your hook renders the message and submits it to Email Bump with a secret API key.

## What you will connect

### Better Auth email hooks

```text
AUTH FLOW                 BETTER AUTH HOOK                 EMAIL CONTENT
Email verification        sendVerificationEmail            verification URL
Password reset            sendResetPassword                password-reset URL
Magic-link sign-in         sendMagicLink                    single-use sign-in URL
Email OTP                  sendVerificationOTP              one-time code + purpose
Two-factor email OTP       otpOptions.sendOTP               one-time second factor
Organization invitation   sendInvitationEmail              invitation ID or app URL
Email change approval      sendChangeEmailConfirmation      approval URL
```

You only need to configure the hooks for features your application uses. Start with verification and password reset; add plugins without creating another provider integration by reusing the same transport and template boundary.

## Create an Email Bump sender and API key

- Create an Email Bump project for the application environment.
- Verify a domain you control, then choose a stable From address such as Acme <auth@updates.acme.com>.
- Create a project-scoped API key and store it in the deployment platform’s secret manager.
- Use separate projects or keys for development, preview, and production.
- Send the first test only to an inbox you control, then inspect the transactional activity timeline.

### .env.local

```bash
BETTER_AUTH_SECRET=replace-with-a-long-random-secret
BETTER_AUTH_URL=http://localhost:3000
EMAIL_BUMP_API_KEY=ebk_replace_me
EMAIL_FROM="Acme <auth@updates.acme.com>"
```

Keep EMAIL_BUMP_API_KEY server-only. Do not prefix it with NEXT_PUBLIC, VITE, PUBLIC, or any other build-system marker that exposes variables to browser code. BETTER_AUTH_URL should be the public origin Better Auth uses to build its endpoints; use HTTPS in production.

## Build one Email Bump transport adapter

Put authentication email behind one function. That keeps the credential, endpoint, timeout, request shape, response parsing, and error policy out of the Better Auth configuration.

### lib/email-bump.ts

```typescript
export type AuthEmail = {
  purpose: string;
  to: string;
  subject: string;
  html: string;
  text: string;
};

function required(name: string): string {
  const value = process.env[name]?.trim();
  if (!value) throw new Error(`Missing environment variable: ${name}`);
  return value;
}

export async function sendWithEmailBump(message: AuthEmail) {
  const response = await fetch("https://emailbump.com/api/v1/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${required("EMAIL_BUMP_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: required("EMAIL_FROM"),
      to: message.to,
      subject: message.subject,
      html: message.html,
      text: message.text,
    }),
    signal: AbortSignal.timeout(10_000),
  });

  if (!response.ok) {
    throw new Error(`Email Bump rejected ${message.purpose}: ${response.status}`);
  }

  return (await response.json()) as { id: string; message_id: string };
}

export function dispatchAuthEmail(message: AuthEmail) {
  void sendWithEmailBump(message).catch(() => {
    // Report the purpose and operation ID to your error tracker.
    // Never log the action URL, OTP, API key, or full message body.
    console.error("Authentication email failed", { purpose: message.purpose });
  });
}
```

> **The timeout does not cancel a remote send**
>
> Email Bump may accept a request just before your process times out. Treat a network timeout as ambiguous, not proof that nothing was sent. Avoid automatic, immediate resends of security messages without a deliberate deduplication policy.

## Render a safe reusable authentication template

Authentication messages are small, but they still need an escaped HTML version, a useful plain-text alternative, a clear expiry statement, and copy for a recipient who did not request the action. Never place a raw token in logs, analytics parameters, image URLs, or third-party scripts.

### lib/auth-email-template.ts

```typescript
export function escapeHtml(value: string) {
  return value.replace(/[&<>"']/g, (character) => ({
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    '"': "&quot;",
    "'": "&#039;",
  })[character]!);
}

type ActionEmail = {
  preview: string;
  heading: string;
  copy: string;
  action: string;
  url: string;
  ignore: string;
};

export function renderActionEmail(input: ActionEmail) {
  const url = escapeHtml(input.url);
  const html = `<!doctype html>
<html lang="en"><body style="font-family:Arial,sans-serif;color:#17382d">
  <div style="display:none;max-height:0;overflow:hidden">${escapeHtml(input.preview)}</div>
  <main style="max-width:560px;margin:40px auto;padding:32px">
    <h1>${escapeHtml(input.heading)}</h1>
    <p>${escapeHtml(input.copy)}</p>
    <p><a href="${url}" style="display:inline-block;padding:12px 18px;background:#17382d;color:#fff;text-decoration:none">${escapeHtml(input.action)}</a></p>
    <p>If the button does not work, copy this URL:<br><a href="${url}">${url}</a></p>
    <p style="color:#62736b;font-size:13px">${escapeHtml(input.ignore)}</p>
  </main>
</body></html>`;

  const text = `${input.heading}

${input.copy}

${input.action}: ${input.url}

${input.ignore}`;
  return { html, text };
}
```

## Configure verification and password-reset email

Better Auth supplies a complete action URL to each callback. Send that URL unchanged instead of rebuilding it from the token. This preserves Better Auth’s configured base path, callback URL, and token-consumption route.

### lib/auth.ts

```typescript
import { betterAuth } from "better-auth";
import { dispatchAuthEmail } from "./email-bump";
import { renderActionEmail } from "./auth-email-template";

export const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    async sendResetPassword({ user, url }) {
      const body = renderActionEmail({
        preview: "Reset your Acme password",
        heading: "Reset your password",
        copy: "Use this secure link to choose a new password.",
        action: "Reset password",
        url,
        ignore: "If you did not request a reset, you can ignore this email.",
      });
      dispatchAuthEmail({
        purpose: "password-reset",
        to: user.email,
        subject: "Reset your Acme password",
        ...body,
      });
    },
  },
  emailVerification: {
    sendOnSignUp: true,
    sendOnSignIn: true,
    expiresIn: 60 * 60,
    async sendVerificationEmail({ user, url }) {
      const body = renderActionEmail({
        preview: "Verify your email to finish setting up Acme",
        heading: "Verify your email",
        copy: "Confirm this address to finish setting up your account.",
        action: "Verify email",
        url,
        ignore: "If you did not create an Acme account, you can ignore this email.",
      });
      dispatchAuthEmail({
        purpose: "email-verification",
        to: user.email,
        subject: "Verify your email for Acme",
        ...body,
      });
    },
  },
});
```

sendOnSignUp sends verification during registration. requireEmailVerification prevents email-and-password sign-in until the address is verified, while sendOnSignIn sends another verification message when an unverified user tries again. Better Auth’s social-provider behavior is separate, so test every enabled sign-in method instead of assuming one setting gates all of them.

## Trigger verification from the client

### Resend verification button

```typescript
import { authClient } from "@/lib/auth-client";

const { error } = await authClient.sendVerificationEmail({
  email: "person@example.com",
  callbackURL: "/dashboard",
});

if (error) {
  // Keep the UI response neutral; do not reveal account existence.
  showMessage("If that address can be verified, an email is on the way.");
}
```

Apply a resend cooldown and rate limits by address, account, IP, and device signal. Show a neutral response for verification and password-recovery requests. A generic UI response reduces account enumeration; it does not replace server-side throttling or abuse monitoring.

## Do not lose auth email on serverless platforms

Better Auth recommends not awaiting provider delivery because provider latency can create timing differences between account states. A detached promise is acceptable only when the runtime keeps the process alive. Many serverless runtimes may freeze or terminate work after the response finishes.

- On a persistent Node.js server, dispatch the promise and report failures explicitly.
- On a serverless platform, register the promise with the platform’s waitUntil or equivalent lifecycle API.
- For the strongest reliability, enqueue a small auth-email job durably and let a worker call Email Bump.
- Keep the public response neutral and fast regardless of whether the user exists or the provider is slow.
- Expire queued work before its link or OTP becomes useless; stale auth email should be cancelled, not delivered late.

> **Queue the generated destination—not a plan to regenerate it**
>
> If you use a worker, persist the exact Better Auth URL or OTP, its purpose, recipient, and an expiry deadline in protected storage. Encrypt sensitive payloads at rest, restrict worker access, and delete or redact them after the job reaches a terminal state.

## Add magic-link sign-in

### Magic link plugin

```typescript
import { betterAuth } from "better-auth";
import { magicLink } from "better-auth/plugins";

export const auth = betterAuth({
  plugins: [
    magicLink({
      expiresIn: 5 * 60,
      async sendMagicLink({ email, url }) {
        const body = renderActionEmail({
          preview: "Your secure sign-in link for Acme",
          heading: "Sign in to Acme",
          copy: "This link signs you in and expires in five minutes.",
          action: "Sign in",
          url,
          ignore: "If you did not request this link, you can ignore this email.",
        });
        dispatchAuthEmail({
          purpose: "magic-link",
          to: email,
          subject: "Your Acme sign-in link",
          ...body,
        });
      },
    }),
  ],
});
```

A magic link is a credential. Do not prefetch it in your own application, route it through a click tracker, or expose it to analytics. Keep links single-purpose, short-lived, HTTPS-only, and associated with the intended callback policy. Leave click tracking disabled for authentication mail; Email Bump can still record submission and delivery events without rewriting the action URL.

## Add email OTPs

The emailOTP plugin calls one hook for sign-in, email verification, and password recovery. Map the Better Auth type to explicit subject and copy; do not send a generic code without telling the recipient what it authorizes.

### Email OTP plugin

```typescript
import { emailOTP } from "better-auth/plugins";
import { escapeHtml } from "./auth-email-template";

const otpCopy = {
  "sign-in": ["Your Acme sign-in code", "Use this code to sign in"],
  "email-verification": ["Verify your Acme email", "Use this code to verify your email"],
  "forget-password": ["Reset your Acme password", "Use this code to reset your password"],
} as const;

export const auth = betterAuth({
  plugins: [
    emailOTP({
      expiresIn: 5 * 60,
      allowedAttempts: 3,
      async sendVerificationOTP({ email, otp, type }) {
        const [subject, instruction] = otpCopy[type];
        const safeOtp = escapeHtml(otp);
        dispatchAuthEmail({
          purpose: `email-otp:${type}`,
          to: email,
          subject,
          html: `<h1>${subject}</h1><p>${instruction}:</p><p style="font-size:32px;letter-spacing:6px"><strong>${safeOtp}</strong></p><p>This code expires in five minutes. Do not share it.</p>`,
          text: `${subject}

${instruction}: ${otp}

This code expires in five minutes. Do not share it.`,
        });
      },
    }),
  ],
});
```

Rate-limit both code issuance and verification attempts. Better Auth’s allowedAttempts option limits guesses against one OTP; application-level controls still need to constrain repeated code generation. Decide whether a resend rotates or reuses an active code and make the email copy match that behavior.

## Send organization invitations through the same adapter

### Organization invitation hook

```typescript
import { organization } from "better-auth/plugins";

organization({
  requireEmailVerificationOnInvitation: true,
  async sendInvitationEmail(data) {
    const baseURL = process.env.BETTER_AUTH_URL;
    if (!baseURL) throw new Error("BETTER_AUTH_URL is required");

    const url = new URL("/accept-invitation", baseURL);
    url.searchParams.set("invitationId", data.id);
    const organizationName = data.organization.name.replace(/[\r\n]+/g, " ").trim();
    const body = renderActionEmail({
      preview: `${data.inviter.user.name} invited you to ${organizationName}`,
      heading: `Join ${organizationName}`,
      copy: `${data.inviter.user.name} invited you to their organization on Acme.`,
      action: "Review invitation",
      url: url.toString(),
      ignore: "If you were not expecting this invitation, you can ignore it.",
    });
    dispatchAuthEmail({
      purpose: "organization-invitation",
      to: data.email,
      subject: `You’re invited to ${organizationName}`,
      ...body,
    });
  },
});
```

The invitation landing page should require the recipient to sign in before it calls Better Auth’s acceptInvitation method. Use opaque invitation IDs and verify that the session email matches the invitation. Requiring verified email adds a stronger ownership check when membership is sensitive or invitation identifiers may be exposed elsewhere.

## Authentication email security checklist

- Keep Email Bump credentials and all send functions on the server.
- Use only Better Auth-generated action URLs and validate your configured public origin.
- Do not add action URLs, tokens, or OTPs to logs, analytics, tracking redirects, or error reports.
- Escape names, organization names, and all other untrusted values in HTML and subject lines.
- Use HTTPS, short expirations, bounded attempts, resend cooldowns, and neutral recovery responses.
- Separate authentication messages from promotional content and marketing suppression rules.
- Disable click tracking for authentication mail so action URLs are not rewritten.
- Keep HTML and plain-text alternatives, and state what the action does and when it expires.
- Treat provider acceptance, delivery, and successful token consumption as separate events.
- Alert on auth-email failures and latency without storing message secrets in telemetry.

## Test the full flow

### Integration test matrix

```text
CONFIGURATION
missing API key · unverified From domain · wrong production origin

VERIFICATION
sign-up send · sign-in resend · expired link · already-used link · callback redirect

PASSWORD RESET
known address · unknown address with neutral response · expired link · password changed

MAGIC LINK / OTP
single-use link · resend behavior · expired code · attempt limit · rate limit

DELIVERY
provider 400 · provider 429 · provider 500 · timeout after possible acceptance
serverless response completion · durable-job expiry · duplicate request

CONTENT
escaped name and organization · valid HTTPS action URL · useful plain text
mobile layout · no secret in logs or tracked links
```

Mock sendWithEmailBump in unit tests and assert the recipient, purpose, subject, and action destination. Use a restricted development key for one end-to-end send. Then test the real link or code through Better Auth and confirm the Email Bump timeline records acceptance and delivery without exposing the credential in observability data.

## Send every Better Auth message through Email Bump

Use one transactional endpoint for verification, recovery, magic-link, OTP, and invitation email—then inspect delivery outcomes from one activity timeline.

- Simple bearer-authenticated REST API
- Branded HTML and plain-text messages
- Delivery, bounce, and complaint activity

[Learn more](https://emailbump.com/signup)

## Production checklist

- Verify the production sending domain and create a least-privilege project key.
- Validate BETTER_AUTH_URL, BETTER_AUTH_SECRET, EMAIL_FROM, and EMAIL_BUMP_API_KEY at startup.
- Centralize Email Bump submission and keep it out of browser bundles.
- Configure only the Better Auth hooks and plugins the product actually uses.
- Choose a runtime-safe background strategy; do not detach work on serverless without waitUntil.
- Make auth jobs expire before their URL or OTP and suppress stale queued messages.
- Rate-limit requests and resends while preserving neutral account-existence responses.
- Test link consumption, expiry, replay, callbacks, provider failures, and message rendering.
- Monitor provider acceptance and delivery separately from successful authentication.

## Ship authentication email with a transport you can observe

Create an Email Bump project, verify your sender, and connect the transactional API to the Better Auth callbacks your application already owns.

[Start sending](https://emailbump.com/signup)

## Sources

- [Better Auth: Email](https://better-auth.com/docs/concepts/email)
- [Better Auth: Email and password](https://better-auth.com/docs/authentication/email-password)
- [Better Auth: Magic link plugin](https://better-auth.com/docs/plugins/magic-link)
- [Better Auth: Email OTP plugin](https://better-auth.com/docs/plugins/email-otp)
- [Better Auth: Two-factor authentication plugin](https://better-auth.com/docs/plugins/2fa)
- [Better Auth: Organization plugin](https://better-auth.com/docs/plugins/organization)
- [Better Auth: Users and accounts](https://better-auth.com/docs/concepts/users-accounts)
- [Email Bump: Transactional API](/docs/transactional-api)
- [Email Bump: Send email in Node.js](/blog/send-email-nodejs)
