Send With Better Auth by connecting its email callbacks to Volanea’s transactional REST API. This guide uses Better Auth’s provider-agnostic email hooks and a small server-only TypeScript client, so you can send authentication messages without depending on a fictional provider-specific adapter.

Better Auth deliberately lets your application bring its own transactional email provider. That is useful when email infrastructure is a product decision: you can keep authentication, delivery, domain authentication, sending limits, templates, and message events under the provider and account you choose. The integration point is simply an async function that receives an address, a subject, and message content, then hands that message to Volanea.

This page shows a complete implementation for email-verification links. The same sendTransactionalEmail helper also works for password resets, account-change notifications, organization invitations, and one-time-password messages. The code uses the Volanea REST endpoint directly with the built-in fetch available in modern Node.js runtimes; there is no Volanea-specific Better Auth SDK to install or pretend exists.

What this integration does

The integration has two separate responsibilities:

  1. Better Auth creates the authentication action. For example, it creates a verification URL and chooses when verification should be sent.
  2. Your Volanea mail helper delivers the message. It reads a secret API key on the server, makes an authenticated JSON request, checks the HTTP response, and returns the provider response to the caller.

Keeping those jobs separate is important. Better Auth should remain responsible for tokens, sessions, callback URLs, and verification state. Your mail helper should remain responsible for outbound delivery. This separation makes it much easier to reuse the same sender for every authentication flow and to replace only the transport layer if your application needs a different mail provider later.

The API request below sends one transactional email. It uses a verified from address, one recipient, a subject, an HTML body, and a plain-text fallback. The sender is authenticated with VOLANEA_API_KEY, which must only be available to server-side code.

Prerequisites

Before pasting the code, make sure the following are true:

  • You have a Better Auth application running on a Node.js-compatible server runtime.
  • Better Auth is installed in that application.
  • You have created a Volanea API key beginning with sk_ or sk_test_.
  • The sender address you place in VOLANEA_FROM_EMAIL belongs to a domain configured for sending in Volanea.
  • Your app can use the global fetch API. Node.js 18 or later provides it without an extra dependency.
  • Your Better Auth configuration already has the database, secret, trusted origins, and other application-specific settings it needs.

Do not expose VOLANEA_API_KEY through a browser bundle, a NEXT_PUBLIC_ variable, client-side route code, source control, logs, or error pages. An email API key authorizes sending and should be treated like any other server credential.

For the underlying endpoint, request format, and setup details, see the email API reference and setup guides.

Install Better Auth

This integration needs Better Auth. The Volanea transport shown here uses native fetch, so it does not require a separate mailing package.

npm install better-auth

If Better Auth is already in your project, you do not need to run the command again. You also do not need nodemailer, an SMTP package, or a provider-specific SDK for this REST-based example.

Using native fetch has two practical benefits. First, the integration has a small dependency surface: you are calling a standard HTTPS endpoint with standard JSON. Second, the request, authentication header, retry behavior, and error handling remain visible in your own code instead of being hidden behind an abstraction with provider-specific behavior.

If your runtime is older than Node.js 18, upgrade the runtime or provide a standards-compatible server-side fetch implementation. Do not copy an HTTP client intended for browser code into your authentication layer without confirming that it runs only on the server and can safely read environment variables.

Add your environment variables

Create or update your local environment file. The exact filename varies by framework; .env.local is common for local development, while hosted environments usually provide a secrets configuration screen.

# Server-only Volanea secret key. Never expose this to the browser.
VOLANEA_API_KEY=sk_test_replace_with_your_volanea_key

# Must be an address on a sending domain configured in Volanea.
VOLANEA_FROM_EMAIL=Acme <auth@updates.example.com>

Use a test key while developing if your Volanea workspace provides one, then add the production key only to the production environment. Keep local, preview, staging, and production keys separate. That prevents a preview deployment from sending real authentication messages from your production sender identity.

The VOLANEA_FROM_EMAIL value includes a display name and an email address in standard mailbox format. You can also use an address without a display name when that is more appropriate for your product:

VOLANEA_FROM_EMAIL=auth@updates.example.com

The from-domain requirement is not cosmetic. Authentication emails are among the first messages a new user receives, so they are a poor place to use an unverified address, a placeholder domain, or an address that does not align with your product. Configure and verify your sending domain before asking real users to depend on verification or reset messages.

Create a reusable Volanea email client

Create src/lib/volanea.ts or the equivalent server-only module in your project. The helper below validates the values your application controls, sends JSON to POST https://api.volanea.com/v1/send, and surfaces a useful error when Volanea returns a non-success status.

// src/lib/volanea.ts

type SendTransactionalEmailInput = {
  to: string;
  subject: string;
  html: string;
  text: string;
  idempotencyKey?: string;
};

const VOLANEA_SEND_URL = "https://api.volanea.com/v1/send";

function getVolaneaConfig() {
  const apiKey = process.env.VOLANEA_API_KEY;
  const from = process.env.VOLANEA_FROM_EMAIL;

  if (!apiKey) {
    throw new Error("Missing VOLANEA_API_KEY environment variable.");
  }

  if (!from) {
    throw new Error("Missing VOLANEA_FROM_EMAIL environment variable.");
  }

  return { apiKey, from };
}

export async function sendTransactionalEmail({
  to,
  subject,
  html,
  text,
  idempotencyKey,
}: SendTransactionalEmailInput) {
  const { apiKey, from } = getVolaneaConfig();

  if (!to) {
    throw new Error("A recipient email address is required.");
  }

  if (!subject) {
    throw new Error("An email subject is required.");
  }

  if (!html && !text) {
    throw new Error("Provide an HTML body, a text body, or both.");
  }

  const response = await fetch(VOLANEA_SEND_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      ...(idempotencyKey
        ? { "Idempotency-Key": idempotencyKey }
        : {}),
    },
    body: JSON.stringify({
      from,
      to,
      subject,
      html,
      text,
    }),
  });

  const responseText = await response.text();

  if (!response.ok) {
    throw new Error(
      `Volanea send failed with ${response.status} ${response.statusText}: ${responseText}`,
    );
  }

  try {
    return JSON.parse(responseText) as unknown;
  } catch {
    return responseText;
  }
}

This module is intentionally small. It does not create a fake Volanea class, invent a sendEmail() SDK method, or claim that Better Auth has a first-party Volanea adapter. Instead, it uses the documented REST base URL and send endpoint directly.

Why the helper reads environment variables at send time

Reading configuration inside getVolaneaConfig() makes failures explicit when the sender is invoked. In development, a missing key produces a clear error rather than a confusing authorization failure. In tests, it also makes it easier to set and reset environment values around a test case.

Some teams prefer to read environment variables when the module first loads. That approach is valid, but it can cause an application to fail during build time, static analysis, or test discovery when secrets are intentionally absent. The implementation above waits until an actual send is attempted.

Why send both HTML and text

HTML lets you format a verification email with a prominent button and readable layout. Plain text provides a fallback for recipients whose clients do not render HTML, users who prefer text-only mail, security scanners, and accessibility workflows. A text alternative also makes it easy to copy the verification URL from a simple message.

Do not make the HTML body the only place the verification URL appears. Include the URL as text too. Users sometimes open authentication mail in clients that block links, security tools that rewrite links, or environments where a clearly visible fallback URL is the fastest way to complete the task.

Connect the sender to Better Auth verification email

Now call the helper from Better Auth’s emailVerification.sendVerificationEmail callback. Create or update your Better Auth server configuration file, commonly src/lib/auth.ts.

The callback receives a user, a verification url, and a token. For a normal Better Auth verification flow, use the provided url. Better Auth generated that URL for the current request and knows how to complete the verification when the user opens it.

// src/lib/auth.ts

import { betterAuth } from "better-auth";
import { sendTransactionalEmail } from "./volanea";

export const auth = betterAuth({
  // Keep your existing database, secret, trustedOrigins, and provider config here.
  emailAndPassword: {
    enabled: true,
  },
  emailVerification: {
    sendOnSignUp: true,
    sendVerificationEmail: async ({ user, url }) => {
      const subject = "Verify your email address";

      const text = [
        "Welcome to Acme.",
        "",
        "Verify your email address by opening this link:",
        url,
        "",
        "If you did not create an Acme account, you can ignore this email.",
      ].join("\n");

      const html = `
        <!doctype html>
        <html lang="en">
          <body style="margin:0;padding:24px;font-family:Arial,sans-serif;color:#111827;">
            <h1 style="font-size:24px;line-height:32px;">Verify your email address</h1>
            <p>Welcome to Acme. Confirm that this email address belongs to you.</p>
            <p style="margin:24px 0;">
              <a
                href="${url}"
                style="display:inline-block;background:#111827;color:#ffffff;padding:12px 18px;border-radius:6px;text-decoration:none;"
              >
                Verify email address
              </a>
            </p>
            <p>If the button does not work, copy and paste this URL into your browser:</p>
            <p><a href="${url}">${url}</a></p>
            <p>If you did not create an Acme account, you can ignore this email.</p>
          </body>
        </html>
      `;

      await sendTransactionalEmail({
        to: user.email,
        subject,
        html,
        text,
        idempotencyKey: `verify-email:${user.id}:${url}`,
      });
    },
  },
});

This is a complete email-verification transport: Better Auth generates the URL, the mail helper reads the API key from VOLANEA_API_KEY, and Volanea receives one transactional email request. Replace Acme and the sender address with your own product name and verified sending domain.

The sendOnSignUp: true setting asks Better Auth to initiate verification during sign-up. Whether users must verify before other actions are allowed is a separate product and Better Auth configuration decision. Sending a verification email is not, by itself, the same thing as blocking unverified users from sensitive operations.

Understand async behavior and timing trade-offs

The sample above uses await inside sendVerificationEmail, which is the most straightforward approach when your server can wait for a provider response. It has a major operational advantage: if Volanea rejects the request, your application sees the failure in the same execution path and can log, alert, or return an appropriate error.

However, Better Auth’s email guidance warns against waiting for authentication-email delivery in situations where response timing could reveal whether an account or email exists. This is a security consideration, especially for flows such as password reset. If your threat model requires reducing timing differences, queue the email work or use the runtime’s background-task capability while ensuring the runtime does not terminate before the request completes.

A common pattern is to separate two concerns:

  • The authentication request returns along a consistent path.
  • A durable queue, background task, or serverless waitUntil mechanism runs the outbound email job.

Do not simply remove await and assume the message will send. A fire-and-forget promise can be abandoned when a serverless invocation ends. If you deliberately use void sendTransactionalEmail(...), add error handling and use a runtime-supported mechanism that keeps the work alive. The exact mechanism differs among application platforms, which is why this guide uses await as the portable copy-paste baseline.

For higher-volume applications, use a durable job queue. The authentication callback can create an email job containing the recipient, prebuilt content, a stable idempotency key, and the relevant event type. A worker performs the Volanea call, retries transient failures, records the provider response, and avoids resending messages after a successful request.

Send a password-reset email with the same helper

Authentication products rarely stop at verification messages. Better Auth can also call your code when a user requests a password reset. Reuse sendTransactionalEmail rather than duplicating API credentials and HTTP logic in every callback.

Add the following callback to the same Better Auth configuration. Preserve any existing configuration properties while merging it into your own emailAndPassword setup.

// Add this inside the betterAuth({ ... }) configuration.

emailAndPassword: {
  enabled: true,
  sendResetPassword: async ({ user, url }) => {
    const subject = "Reset your password";

    await sendTransactionalEmail({
      to: user.email,
      subject,
      text: `Reset your password by opening this link:\n${url}\n\nIf you did not request a reset, you can ignore this email.`,
      html: `
        <h1>Reset your password</h1>
        <p>We received a request to reset your password.</p>
        <p><a href="${url}">Reset password</a></p>
        <p>If you did not request a reset, you can ignore this email.</p>
      `,
      idempotencyKey: `reset-password:${user.id}:${url}`,
    });
  },
},

Use the exact callback names and options supported by the Better Auth version installed in your application. Better Auth’s email-and-password configuration provides the reset-email hook, while email verification belongs in the separate emailVerification configuration. Do not place a reset callback under emailVerification or a verification callback under emailAndPassword; a misplaced callback is valid JavaScript but will not be invoked by the feature you expect.

Password-reset content should be direct and conservative. State what action was requested, make the action link obvious, and explain what to do if the recipient did not request it. Avoid including passwords, session tokens, personally sensitive account data, or claims that an account exists beyond what your application’s security model permits.

Test the integration safely

Test the mail path before enabling verification for all production sign-ups. Start with an inbox you control, a test API key where available, and a sender domain configured for the environment you are testing.

A useful test sequence is:

  1. Start the application with VOLANEA_API_KEY and VOLANEA_FROM_EMAIL set in the server environment.
  2. Trigger a Better Auth sign-up using a new test email address.
  3. Confirm that your server logs do not contain the API key or the full verification URL.
  4. Confirm the message arrives and that both the HTML button and visible fallback URL work.
  5. Open the verification link once and verify that the user becomes verified.
  6. Open it again and confirm your application handles the already-used or expired state safely.
  7. Test a password-reset request separately, including a request for an address that should not reveal account membership.

Test delivery separately from authentication correctness. An HTTP success response means Volanea accepted the send request; it does not necessarily mean the message is already in an inbox. Delivery can still be affected by sender-domain setup, recipient-server policy, suppressions, bounces, or mailbox filtering. Treat provider acceptance, delivery, and user action as distinct states in your product telemetry.

For local testing, do not hard-code a production recipient or production sender. Environment variables make it easy to route development messages to a controlled address while preserving production behavior in deployment configuration.

Use idempotency keys for safe retries

The helper supports an optional Idempotency-Key header. Volanea supports idempotency keys for safe retries, which matters whenever a request may have succeeded but your application did not receive the response because of a timeout, network interruption, or process restart.

The key should identify one logical send, not merely one function execution. In the verification example, it combines the user ID and verification URL. If the same URL is retried after an uncertain network failure, the same key is sent again. If Better Auth creates a new URL for a later verification request, the key changes and the new message is a distinct send.

Avoid these idempotency-key mistakes:

  • Using a random UUID for every retry. That defeats deduplication because every retry appears new.
  • Using only the recipient address. That can suppress legitimate future messages to the same user.
  • Reusing one global key. That can collapse unrelated authentication emails into one request.
  • Putting a secret in the key. Headers can appear in logs and observability systems.

If you add a job queue, store the idempotency key with the job. Then a restarted worker and a manually retried job retain the same send identity. This turns retries from a guess into a controlled operation.

Common errors

401 Unauthorized or 403 Forbidden

These errors usually mean the API key is missing, malformed, revoked, associated with a different environment, or sent using the wrong authorization scheme. Confirm that the server process has VOLANEA_API_KEY set and that the code sends it as:

Authorization: `Bearer ${apiKey}`

Do not use a browser-exposed environment variable. Also restart the development server after editing a local environment file; many frameworks load environment variables only during process startup.

If a key works locally but fails after deployment, inspect the deployment environment’s server-side secrets configuration. Preview and production environments frequently use separate variable sets. Never paste the key into a client-side debugging console to test it.

415 Unsupported Media Type or a request body that cannot be parsed

Volanea expects a JSON request body for this REST example. Ensure that both the header and body are correct:

headers: {
  "Content-Type": "application/json",
}

body: JSON.stringify({ from, to, subject, html, text })

A frequent mistake is passing a JavaScript object directly to fetch as body. Fetch does not automatically serialize arbitrary objects to JSON. Another is using form-data, URL-encoded values, or a framework request object when the endpoint expects JSON.

Do not set Content-Type: application/json without calling JSON.stringify. That produces a header/body mismatch and can lead to confusing parsing failures.

TypeError: Failed to parse URL, fetch is not defined, or server-runtime failures

The example assumes a modern server-side JavaScript runtime with the global Fetch API. Use Node.js 18 or later, or configure an appropriate server-side fetch implementation for your runtime.

Keep the Volanea module in server code. Calling it from a React client component, browser route, or frontend bundle can fail because environment variables are unavailable there—and worse, attempts to fix that by exposing the API key would compromise the key.

The message function runs but no email arrives

First distinguish an API failure from a delivery outcome. Log the HTTP status and response body from the server safely, without logging secrets or full authentication URLs. If Volanea accepted the message, then verify the sender domain, recipient address, suppression status, spam folder, and any sending restrictions that apply to the account.

Also inspect the generated content. An empty to, malformed sender, missing subject, invalid HTML, or a URL pointing to localhost may cause behavior that looks like a delivery issue but is actually an application-data problem.

await errors, unhandled promise rejections, or emails that disappear on serverless platforms

Do not call an async sender without handling its result:

// Bad: failures can become unhandled, and work may end with the request.
sendTransactionalEmail({ to, subject, html, text });

At minimum, await it in a normal server request:

await sendTransactionalEmail({ to, subject, html, text });

If you intentionally avoid awaiting because of authentication timing concerns, use a background-execution mechanism supported by your framework and attach a .catch() handler that records the failure. On short-lived serverless functions, a bare void promise may never finish after the response returns.

Verification URLs render incorrectly or do not work

Always use the url supplied by Better Auth. Do not reconstruct it manually from the token unless you are intentionally implementing a custom verification endpoint and understand the related security requirements.

When inserting url into HTML, make sure your template does not transform, truncate, or double-encode it. Test the link in the exact email client users will receive. For robust messages, include the URL in the plain-text body and visibly in the HTML body as shown in this guide.

Duplicate verification or reset emails

Retries, double-clicks, concurrent sign-up attempts, and network timeouts can all produce repeated work. Pass a stable idempotencyKey for each logical message, and avoid generating a new key inside each retry attempt.

If your application itself calls the sender more than once, idempotency can reduce duplicate API sends, but it does not fix an incorrect workflow. Trace the Better Auth event, request ID, user ID, URL, and idempotency key to find why the callback ran multiple times.

Production considerations for authentication email

Authentication mail is security-sensitive transactional mail. It deserves more care than a generic product announcement because users will judge your application’s legitimacy by the sender identity, content, links, and timing of these messages.

Use a dedicated authenticated sender such as auth@updates.example.com or security@example.com. Keep its purpose consistent. Avoid switching between unrelated sender names or domains, as that makes phishing harder for users to spot and reduces recognition in crowded inboxes.

Make messages action-specific. A verification email should say verification; a password-reset email should say password reset; a sign-in alert should say sign-in alert. Do not reuse vague subjects like “Important account information” for every workflow. Clear subjects reduce user confusion and support requests.

Use the recipient email supplied by Better Auth rather than accepting a recipient address from untrusted client input at the point of sending. The callback’s user.email represents the authentication flow’s intended recipient. Separately, ensure that your overall signup and reset endpoints include appropriate abuse protections, rate limits, and bot defenses.

Finally, think through observability. Record a safe internal event when a send is accepted or rejected, but do not store raw API keys, full reset URLs, or sensitive message bodies in broad-access logs. For user support, a timestamp, user ID, recipient hash or masked address, event type, HTTP status, and provider message identifier are often enough.

Next steps

After your first authentication email works, move repeated markup into reusable templates. Templates let your team centralize brand styling, copy review, accessibility changes, and shared footer content rather than embedding long HTML strings in every authentication callback. When using templates, keep the Better Auth-provided verification or reset URL as a variable and continue to offer a readable plain-text fallback where possible.

Also plan for webhooks. A webhook endpoint can receive delivery-related events and lets your application record whether a message was accepted, bounced, complained, or was otherwise processed. This is especially useful for support workflows and for preventing repeated sends to addresses that cannot receive mail. Verify webhook signatures, process events idempotently, return success responses promptly, and move slow downstream work to a queue.

As you expand from authentication mail to receipts, invitations, and product notifications, review transactional email pricing so sending volume, environments, and delivery requirements match the plan you choose.

FAQ

Does Better Auth require a specific email provider?

No. Better Auth supports bringing your own transactional email provider through callbacks such as sendVerificationEmail. This guide uses Volanea’s REST endpoint from that callback.

Is there a Volanea SDK for Better Auth to install?

This guide does not rely on one. It uses Better Auth plus the standard server-side fetch API to call Volanea’s REST send endpoint. That keeps the code explicit and avoids inventing provider-specific methods.

Should I await the Volanea send call in Better Auth?

Awaiting is the simplest portable implementation and lets you handle immediate API errors. For flows where response timing can disclose account information, consider a durable queue or platform-supported background task instead. Do not use an untracked fire-and-forget promise on serverless infrastructure.

Can I use this helper for password resets and OTP emails?

Yes. Reuse sendTransactionalEmail from the relevant Better Auth callback. Change the subject and content, use the URL or OTP supplied by Better Auth, and generate an idempotency key that identifies that exact logical message.

Why should authentication emails include both HTML and text?

HTML improves presentation and makes an action button easy to find. Plain text provides a reliable fallback for clients and users that do not render HTML, and it exposes the authentication link in a copyable format.