Flowmails + Better Auth
Wire Better Auth's email verification and password-reset flows to Flowmails in four callbacks. Verification mail and reset mail go out through your own domain, so deliverability lives with your brand rather than a shared sending IP.
Step 1
Install the SDK
pnpm add @flowmails/flowmails-sdk better-authStep 2
Send your first message
// The four Better Auth callbacks all funnel through one call.
import { Flowmails } from "@flowmails/flowmails-sdk";
const fm = new Flowmails({ apiKey: process.env.FLOWMAILS_API_KEY! });
const { id, status } = await fm.send({
from: "auth@yourdomain.com",
to: "user@example.com",
subject: "Verify your email address",
text: "Confirm your address: https://app.example.com/verify?t=…",
});
// → { id: "msg_8421", status: "queued" }Step 1
One send helper, and why it must not throw
Better Auth has no email provider of its own — the `import { sendEmail } from "./email"` you see in its docs is annotated "your email sending function". That function is this one. Keep it in a single module so all four callbacks share one client, one error taxonomy, and one log line.
import { Flowmails, FlowmailsError } from "@flowmails/flowmails-sdk";
const fm = new Flowmails({ apiKey: process.env.FLOWMAILS_API_KEY! });
export type AuthEmail = {
to: string;
subject: string;
text: string;
html: string;
};
export async function sendAuthEmail(mail: AuthEmail) {
try {
const { id } = await fm.send({
from: process.env.FLOWMAILS_AUTH_FROM!,
fromName: "Acme",
to: mail.to,
subject: mail.subject,
text: mail.text,
html: mail.html,
});
return { ok: true as const, id };
} catch (err) {
// Do not rethrow. See the callout below.
const code = err instanceof FlowmailsError ? err.code : "unknown";
console.error("[auth-mailer] send failed", { code, to: mail.to });
return { ok: false as const, code };
}
}Better Auth runs every send callback through runInBackgroundOrAwait, which catches any rejection and only logs it — the HTTP request still answers 200. An uncaught error in your sender is invisible to the user and to the caller: sign-up reports success, no mail arrives, and every later sign-in fails with EMAIL_NOT_VERIFIED. Rethrowing buys you nothing, so catch inside the sender and record the failure where you can see it.
Step 2
Wire the callbacks into auth.ts
Better Auth exposes four email seams: sendVerificationEmail, sendResetPassword, onExistingUserSignUp, and onPasswordReset. The first two are the ones that matter for sign-up and recovery; both need a real sender behind them.
import { betterAuth } from "better-auth";
import { sendAuthEmail, verifyEmail, resetEmail } from "./auth-mailer";
// The key and the from-address gate as ONE predicate. See the callout.
const authEmailEnabled =
!!process.env.FLOWMAILS_API_KEY && !!process.env.FLOWMAILS_AUTH_FROM;
export const auth = betterAuth({
database: /* your adapter — Drizzle, Prisma, … */,
account: {
accountLinking: {
// Both are the defaults. Spelled out because the pairing matters:
// implicit linking is what lets a password user and their Google
// account resolve to one user row, and allowDifferentEmails: false
// is the guard rail on that same mechanism.
enabled: true,
allowDifferentEmails: false,
},
},
...(authEmailEnabled && {
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
minPasswordLength: 8,
maxPasswordLength: 128,
revokeSessionsOnPasswordReset: true,
resetPasswordTokenExpiresIn: 3600,
sendResetPassword: async ({ user, url }) => {
await sendAuthEmail({ to: user.email, ...resetEmail(url) });
},
onPasswordReset: async ({ user }) => {
console.log(`password reset completed for ${user.id}`);
},
},
emailVerification: {
expiresIn: 3600,
sendVerificationEmail: async ({ user, url }) => {
await sendAuthEmail({ to: user.email, ...verifyEmail(url) });
},
},
}),
});requireEmailVerification: true with no sendVerificationEmail registered is a permanent lockout: sign-up succeeds silently behind an if (…?.sendVerificationEmail) guard, no mail is ever sent, and every subsequent sign-in throws EMAIL_NOT_VERIFIED. Gate both halves together and omit the whole block when either is unset — never half-configure it.
- requireEmailVerification: true suppresses auto sign-in even when autoSignIn is true, so sign-up returns { token: null } and the user must click the link before doing anything.
- expiresIn and resetPasswordTokenExpiresIn are in seconds and both default to 3600. Set them explicitly so the copy you render into the email matches the server.
- revokeSessionsOnPasswordReset: true signs out every other device — usually what you want, since the usual reason to reset is that the old password leaked.
Step 3
The verification email
The url Better Auth hands you is `<baseURL>/verify-email?token=<jwt>&callbackURL=<encoded>`. The token is a stateless HS256 JWT with no server-side revocation, so your copy should state the window rather than imply you can recall it.
function escapeHtml(v: string) {
return v
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
export function verifyEmail(url: string, brand = "Acme") {
const href = escapeHtml(url);
return {
subject: "Verify your email address",
text: [
`Verify your ${brand} email address`,
"",
"Confirm this address to activate your account:",
url,
"",
"This link expires in 60 minutes.",
].join("\n"),
html: `<div style="font-family:system-ui,sans-serif;padding:24px">
<p>Confirm your email address to finish signing up:</p>
<p><a href="${href}">Verify email address</a></p>
<p style="color:#6b7280;font-size:12px">
This link expires in 60 minutes. If the button does not work, paste this
URL into your browser:<br>${href}
</p>
</div>`,
};
}- Render the URL twice — as a button and as bare text. Plenty of clients strip or mangle the anchor, and a text fallback is the whole ballgame.
- escapeHtml the href. Both URLs carry a query string, so an unescaped & in the attribute is the realistic hazard.
- Inline styles only. Every mainstream client strips <style> blocks and several ignore external CSS.
Step 4
The password-reset email
The url here is `<baseURL>/reset-password/<token>?callbackURL=<encoded>`. Its token is a row in the verifications table — single-use, one-hour life — unlike the stateless JWT on the verification link.
export function resetEmail(url: string, brand = "Acme") {
const href = escapeHtml(url);
return {
subject: "Reset your password",
text: [
`Reset your ${brand} password`,
"",
"Choose a new password using this link:",
url,
"",
"This link expires in 60 minutes and can be used once.",
"",
"If you did not request this, your password stays unchanged.",
].join("\n"),
html: `<div style="font-family:system-ui,sans-serif;padding:24px">
<p>Choose a new password using the link below:</p>
<p><a href="${href}">Choose a new password</a></p>
<p style="color:#6b7280;font-size:12px">
This link expires in 60 minutes and can be used once. If you did not
request this, no action is needed.<br>${href}
</p>
</div>`,
};
}Always pass redirectTo when you call authClient.requestPasswordReset. Better Auth folds it into the emailed URL as callbackURL, and its GET /reset-password/:token handler rejects the click outright when callbackURL is absent — an omitted redirectTo mints a link that fails every single time.
Step 5
Mount the handler
Better Auth serves everything under one prefix, and the emailed links land there as GET requests. If your framework maps methods separately, exporting only the POST handler silently breaks every verification and reset link you send.
// React Router 7 — both exports are required.
import type { PlatformBindings } from "~/server/contracts/platform";
import { createAuth } from "~/server/auth/auth-instance";
function env(context: unknown): PlatformBindings {
const c = (context as { cloudflare?: { env: PlatformBindings } })?.cloudflare;
if (!c) throw new Response("Missing bindings", { status: 500 });
return c.env;
}
export async function loader({ request, context }: Route.LoaderArgs) {
return createAuth(env(context)).handler(request);
}
export async function action({ request, context }: Route.ActionArgs) {
return createAuth(env(context)).handler(request);
}- Next.js App Router is the same shape via toNextJsHandler(auth), exported as both GET and POST from app/api/auth/[...all]/route.ts.
- Dropping the loader is the classic mistake: /verify-email and /reset-password/:token are GETs, so they 404 and every link you ever send is dead.
- Set baseURL explicitly. Better Auth otherwise infers it from the incoming request, which breaks behind a proxy that rewrites the host.
Step 6
The client pages
Five calls cover the whole flow. Note that requestPasswordReset takes redirectTo, while every other call takes callbackURL — the field name changes and it is not optional in the reset case.
// Sign up — callbackURL is where the emailed link lands on success.
await authClient.signUp.email({ name, email, password, callbackURL: "/verify-email" });
// Sign in.
await authClient.signIn.email({ email, password, callbackURL: "/dashboard" });
// Resend verification.
await authClient.sendVerificationEmail({ email, callbackURL: "/verify-email" });
// Start a reset. redirectTo is REQUIRED — omit it and the link is dead.
await authClient.requestPasswordReset({ email, redirectTo: "/reset-password" });
// Finish it. The GET handler bounced you to redirectTo with ?token=…
const token = new URLSearchParams(location.search).get("token");
await authClient.resetPassword({ newPassword, token });Your reset page is the worst case. resetPassword with a rejected token answers 400, which resolves rather than throws — so the code after the await runs as if it succeeded, and the user is told their password was updated when it was not. Check the error field before you set your success state.
- Every one of these calls RESOLVES on failure — a wrong password comes back as { data: null, error: { code: "INVALID_EMAIL_OR_PASSWORD", message: "Invalid email or password" } }, not a rejected promise. Nothing throws unless the network itself does, so a plain try/catch around these lines catches nothing but an offline tab. Either inspect the error field, or wrap the call in a helper that turns it into a throw: `const { data, error } = await authClient.signIn.email(…); if (error) throw new Error(error.message ?? error.statusText);`. Do not reach for fetchOptions: { throw: true } instead — it rejects with a BetterFetchError whose message is the HTTP status text, so your user reads "Unauthorized" instead of "Invalid email or password".
- requestPasswordReset returns the same answer whether or not the address exists — Better Auth even runs a dummy lookup to flatten the timing. Your copy must not imply more than that.
- Sign-up and sign-in only navigate when you pass callbackURL; the client assigns window.location.href from the response's url field.
- After a successful reset there is no auto sign-in and no redirect. Send the user to sign-in yourself — and remember revokeSessionsOnPasswordReset may have just killed their current session.
Step 7
What fails silently
Nine behaviours in this surface are invisible at the call site. Most produce a 200 and a cheerful success message, so you find out from the user rather than from a log.
Check these before you ship. Most of them answer 200 and leave your UI looking successful — you find out from the user, or from your own logs, long after.
- Errors thrown inside sendVerificationEmail or sendResetPassword are swallowed — runInBackgroundOrAwait logs and moves on. Instrument your sender yourself; nothing else will tell you.
- Client calls resolve on failure. A wrong password or a spent reset token comes back as { data: null, error } — not a rejected promise — so your try/catch never runs and your page reports success for a request that failed. Check the error field. See Step 6.
- requireEmailVerification: true suppresses auto sign-in even with autoSignIn: true. Sign-up returns { token: null }; if your UI reads success as "you're in", it lies.
- Setting sendOnSignUp: false overrides requireEmailVerification: true and reproduces the silent permanent lockout — accounts created, never verifiable, never able to sign in.
- Always pass redirectTo to requestPasswordReset. Better Auth folds it into the emailed URL as callbackURL, and its GET /reset-password/:token handler rejects the click when callbackURL is absent — an omitted redirectTo mints a link that fails every single time, and the mail still goes out looking perfectly fine.
- Redirect error codes are UPPERCASE. Both /verify-email and /reset-password/:token send ?error=INVALID_TOKEN (and ?error=TOKEN_EXPIRED for a stale verification token). The published docs show lowercase invalid_token; that is wrong for the 1.6 line — match on it and every expired link silently renders your form again.
- Rate limits are on by default in production and are much tighter than the global default: /request-password-reset and /send-verification-email get 3 requests per 60 seconds, /sign-in* and /sign-up* get 3 per 10 seconds. Your customRules override these last. Rate limiting is also switched off in development and skipped entirely when the client IP cannot be resolved — configure advanced.ipAddress.ipAddressHeaders if you sit behind a proxy.
- Two independent clocks. The verification token is a stateless JWT with no revocation; the reset token is a database row, single-use. Expiring one does nothing to the other.
- v0.1 silently drops a replyTo you set — the SDK backend accepts the field but never forwards it to the send backend, so a "Reply-To" on your auth mail is a no-op until the API surfaces it.
Prerequisites
What you need first
- A Flowmails account with a domain fully onboarded — the receive Worker deployed, Email Routing live
- An SDK key from Settings → API Keys, pinned to that domain
- A sender localpart on the pinned domain, e.g. `auth@yourdomain.com` — the SDK backend rejects any other `from` with 422 `from_domain_mismatch`
- BETTER_AUTH_SECRET, at least 32 random bytes
Runtime notes
Things to know on Better Auth
The SDK key belongs on your server. Never construct `Flowmails` in a module that reaches the browser — a build-time import of your auth config is enough to leak it.
Flowmails pins one key to one domain. Sending verification mail from `auth@yourdomain.com` and password resets from `noreply@otherdomain.com` needs two keys, or one key per domain you send from.
In v0.1 the SDK backend does not forward `replyTo` to the send backend, so a `replyTo` you set is silently dropped. Auth mail rarely needs one.
Other integrations