← Back to blog

Better Auth Transactional Email with Rovela (2026)

Custom Better Auth sendVerificationEmail, sendResetPassword, magicLink, and emailOTP hooks calling Rovela POST /emails with Idempotency-Key—Better Auth owns sessions/tokens; Rovela delivers. Distinct from Auth.js, Clerk, Supabase, Firebase guides.

Updated November 4, 2026
Better Auth Transactional Email with Rovela (2026)

Better Auth owns sessions — Rovela only delivers the mail

Better Auth is a TypeScript authentication framework: it issues sessions, verifies credentials, and mints the tokens behind verification links, magic links, and email OTPs. It does not ship a built-in mail transport. You bring your own sender through hooks such as emailVerification.sendVerificationEmail, emailAndPassword.sendResetPassword, the magicLink plugin’s sendMagicLink, and the emailOTP plugin’s sendVerificationOTP (Better Auth Email).

This guide wires those hooks to Rovela’s flat POST https://api.rovela.dev/emails with an Idempotency-Key, so Better Auth keeps owning users, sessions, and token lifecycle while Rovela queues, DKIM-signs, and delivers (Send Email API, Quick Start, Authentication).

Rovela does not replace Better Auth. It does not create users, store sessions, hash verification tokens, or decide whether a clicked link or typed OTP is valid. Your send callbacks only transport the already-built url or otp to the inbox.

Distinct from:

What Better Auth does vs what Rovela sends

ConcernOwner
Auth config, plugins, database adaptersBetter Auth + your DB
Generating verification / magic-link / OTP challengesBetter Auth
Building the url or otp passed to your hookBetter Auth
HTML/text of the auth emailYour send callback
POST /emails, DKIM, queue, bouncesRovela
Redeeming the link/OTP and creating the sessionBetter Auth

A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).

Prerequisites

  1. Verified sending domain in the Rovela dashboard. Unverified from domains are refused with 400 (Quick Start). Prefer a dedicated subdomain such as mail.example.com for auth mail (Why OTP and magic-link emails go to spam).
  2. An API key shaped like re_…. Prefer sending_access for production senders (Authentication).
  3. A Better Auth server setup with the database adapter your app already uses. Email verification, password reset, magic link, and email OTP are configured in betterAuth({ ... }) per the official docs—not invented here.
  4. Keep ROVELA_API_KEY, Better Auth secrets, and DB credentials on the server only—never in a client bundle.

Environment

export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <auth@mail.example.com>'
# Better Auth secret + database URL as required by your adapter

Rotate Rovela keys by creating a new one, deploying, then revoking the old key (Authentication).

Shared Rovela sender helper

Centralize fetch so every Better Auth hook uses the same headers, error handling, and idempotency rules. Do not ask Rovela to invent tokens—pass through what Better Auth already gave you.

// lib/rovelaSendAuthEmail.ts
const ROVELA_URL = "https://api.rovela.dev/emails";

export async function rovelaSendAuthEmail(opts: {
  to: string;
  subject: string;
  html: string;
  text: string;
  /** Stable per challenge — retries within 24h reuse the same acceptance */
  idempotencyKey: string;
}) {
  const apiKey = process.env.ROVELA_API_KEY!;
  const from = process.env.ROVELA_FROM!;
  const key = opts.idempotencyKey.slice(0, 256);

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

  if (!res.ok) {
    const data = await res.json().catch(() => ({}));
    // Do not log API keys — status + provider message only
    console.error("rovela_send_failed", res.status, data.message);
    throw new Error(`Rovela send failed: ${res.status}`);
  }
}

Notes aligned with Rovela’s docs:

  • Send both html and text when you can (Send Email API).
  • Idempotency-Key (≤ 256 chars): a retry within 24 hours returns the original acceptance instead of mailing twice (Send Email API — Idempotency). Prefer a key derived from Better Auth’s token / otp (or another stable per-challenge id), not a random UUID on every attempt.
  • from must be on a verified domain or the API responds 400.

Better Auth’s docs recommend not blocking the auth response on mail I/O for some flows (timing-attack guidance). On serverless hosts, pair a fire-and-forget void send with waitUntil (or equivalent) so the platform does not freeze the isolate before the fetch completes—see Better Auth Email. Throwing from a send callback when Rovela returns non-2xx is still useful so a hard transport failure is visible in your logs.

Email verification → Rovela

Configure emailVerification.sendVerificationEmail. Better Auth passes { user, url, token } (Better Auth Email):

import { betterAuth } from "better-auth";
import { rovelaSendAuthEmail } from "./lib/rovelaSendAuthEmail";

export const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
    // requireEmailVerification / sendResetPassword as your product needs
  },
  emailVerification: {
    sendOnSignUp: true,
    sendVerificationEmail: async ({ user, url, token }, _request) => {
      const html =
        `<p>Verify your email for Acme</p>` +
        `<p><a href="${url}">Verify email</a></p>` +
        `<p>If you did not create an account, you can ignore this message.</p>`;
      const text = `Verify your email for Acme\n${url}\n`;

      void rovelaSendAuthEmail({
        to: user.email,
        subject: "Verify your email address",
        html,
        text,
        idempotencyKey: `better-auth-verify-${token}`,
      });
    },
  },
});

Optional knobs documented by Better Auth include sendOnSignIn, autoSignInAfterVerification, and expiresIn—configure them in Better Auth; Rovela does not interpret them.

Password reset → Rovela

Password reset uses emailAndPassword.sendResetPassword with the same { user, url, token } shape (Better Auth Email):

emailAndPassword: {
  enabled: true,
  sendResetPassword: async ({ user, url, token }, _request) => {
    void rovelaSendAuthEmail({
      to: user.email,
      subject: "Reset your password",
      html:
        `<p>Reset your Acme password</p>` +
        `<p><a href="${url}">Reset password</a></p>` +
        `<p>If you did not request this, you can ignore this message.</p>`,
      text: `Reset your Acme password\n${url}\n`,
      idempotencyKey: `better-auth-reset-${token}`,
    });
  },
},

Magic link plugin → Rovela

Add the server magicLink plugin and implement sendMagicLink({ email, token, url, metadata }) (Magic link plugin):

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

plugins: [
  magicLink({
    sendMagicLink: async ({ email, token, url }, _ctx) => {
      void rovelaSendAuthEmail({
        to: email,
        subject: "Your sign-in link",
        html:
          `<p>Sign in to Acme</p>` +
          `<p><a href="${url}">Sign in</a></p>` +
          `<p>If you did not request this, you can ignore this message.</p>`,
        text: `Sign in to Acme\n${url}\n`,
        idempotencyKey: `better-auth-magic-${token}`,
      });
    },
  }),
],

Client apps call authClient.signIn.magicLink({ email, callbackURL }) (with magicLinkClient() registered). Better Auth builds url and token; Rovela only delivers the message. After the user clicks, Better Auth verifies the challenge and establishes the session—Rovela is not in that path.

Email OTP plugin → Rovela

The emailOTP plugin calls sendVerificationOTP({ email, otp, type }) where type is "sign-in" | "email-verification" | "forget-password" (Email OTP plugin):

import { emailOTP } from "better-auth/plugins";

plugins: [
  emailOTP({
    async sendVerificationOTP({ email, otp, type }) {
      const labels: Record<string, string> = {
        "sign-in": "sign-in",
        "email-verification": "email verification",
        "forget-password": "password reset",
      };
      const label = labels[type] ?? "verification";
      void rovelaSendAuthEmail({
        to: email,
        subject: `Your Acme ${label} code: ${otp}`,
        html:
          `<p>Your ${label} code is</p>` +
          `<p style="font-size:28px;letter-spacing:6px"><b>${otp}</b></p>` +
          `<p>If you did not request this, you can ignore this message.</p>`,
        text: `Your ${label} code is ${otp}.`,
        idempotencyKey: `better-auth-otp-${type}-${email}-${otp}`,
      });
    },
  }),
],

Prefer not to put raw OTPs into Rovela tags or broadly readable logs; the email body already carries the one-time value. Rate-limit how often a single address can request a new OTP at the application layer—Rovela’s idempotency window prevents duplicate identical sends, not request flooding with new codes.

Deliverability and ops checklist

  1. Authenticate SPF/DKIM/DMARC for the subdomain you use in ROVELA_FROM (DKIM setup, DMARC for SaaS).
  2. Keep auth mail on a dedicated subdomain so product newsletters cannot burn the same reputation (spam pitfalls).
  3. Prefer sending_access keys in production senders; use full_access only when you need to read message events (Authentication).
  4. Align Better Auth token/expiresIn (and plugin OTP lifetime settings) with what you promise in the email copy—Rovela does not expire links for you.
  5. On retries after timeouts, reuse a stable Idempotency-Key derived from the challenge so users do not receive two disagreeing codes or links.

Troubleshooting

SymptomLikely cause
Rovela 400 on sendfrom domain not verified, or missing html/text/template
Rovela 401Missing/invalid Bearer key (Authentication)
Verification never arrives, send returns 200Queued but delayed/filtered—check domain auth and spam folder; inspect events with a full_access key if needed
Duplicate emails on retryMissing or non-stable Idempotency-Key
Hook never firesPlugin or emailVerification / sendResetPassword not registered in betterAuth({ ... })

Summary

Wire Better Auth’s verification, reset, magic-link, and email-OTP send hooks to a shared helper that fetches Rovela POST /emails, set a stable Idempotency-Key, and keep secrets on the server. Better Auth remains the source of truth for tokens and sessions; Rovela is the transactional delivery layer—nothing more, nothing less.

Next steps: Quick Start · Send Email API · Better Auth Email