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.
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:
- Auth.js / NextAuth + Rovela — Auth.js Email provider +
sendVerificationRequest - Clerk + Rovela / Supabase + Rovela / Firebase Auth + Rovela — hosted auth platforms
- Magic link security best practices — threat model, not a Better Auth wiring guide
What Better Auth does vs what Rovela sends
| Concern | Owner |
|---|---|
| Auth config, plugins, database adapters | Better Auth + your DB |
| Generating verification / magic-link / OTP challenges | Better Auth |
Building the url or otp passed to your hook | Better Auth |
| HTML/text of the auth email | Your send callback |
POST /emails, DKIM, queue, bounces | Rovela |
| Redeeming the link/OTP and creating the session | Better Auth |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Prerequisites
- Verified sending domain in the Rovela dashboard. Unverified
fromdomains are refused with400(Quick Start). Prefer a dedicated subdomain such asmail.example.comfor auth mail (Why OTP and magic-link emails go to spam). - An API key shaped like
re_…. Prefersending_accessfor production senders (Authentication). - 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. - 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
htmlandtextwhen 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’stoken/otp(or another stable per-challenge id), not a random UUID on every attempt.frommust be on a verified domain or the API responds400.
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
- Authenticate SPF/DKIM/DMARC for the subdomain you use in
ROVELA_FROM(DKIM setup, DMARC for SaaS). - Keep auth mail on a dedicated subdomain so product newsletters cannot burn the same reputation (spam pitfalls).
- Prefer
sending_accesskeys in production senders; usefull_accessonly when you need to read message events (Authentication). - 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. - On retries after timeouts, reuse a stable
Idempotency-Keyderived from the challenge so users do not receive two disagreeing codes or links.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Rovela 400 on send | from domain not verified, or missing html/text/template |
Rovela 401 | Missing/invalid Bearer key (Authentication) |
| Verification never arrives, send returns 200 | Queued but delayed/filtered—check domain auth and spam folder; inspect events with a full_access key if needed |
| Duplicate emails on retry | Missing or non-stable Idempotency-Key |
| Hook never fires | Plugin 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