A working demo · Better Auth + MailKite
Better Auth sends the email. Now it can receive it too.
Two plugins. One sends every transactional email Better Auth generates; the other gives your app a real mailbox — the half no auth library offers. Everything on this site is live, not a screenshot.
Real emails are sent. Use an address you can actually check.
Every auth email, sent
Verification, password reset, magic links, OTPs and org invites — all five surfaces.
A real mailbox, received
Claim an address, get mail as a signature-verified webhook, read it in-app.
Reply from the app
Answers go out from the address that received them, threaded to the original.
No API key in the browser
Every inbox call is session-scoped against your own auth server.
lib/auth.ts — the whole integration
import { betterAuth } from "better-auth";
import { emailOTP, magicLink } from "better-auth/plugins";
import { mailkite } from "@mailkite/better-auth";
import { mailkiteInbox } from "@mailkite/better-auth-inbox";
// apiKey is required on both plugins — there is no environment fallback.
const apiKey = process.env.MAILKITE_API_KEY!;
const mk = mailkite({ apiKey, from: "auth@yourdomain.com", appName: "Acme" });
export const auth = betterAuth({
emailAndPassword: { enabled: true },
plugins: [
mk, // verification + reset, wired for you
magicLink({ sendMagicLink: mk.sendMagicLink }),
emailOTP({ sendVerificationOTP: mk.sendVerificationOTP }),
mailkiteInbox({
apiKey, // the same key, declared once
domain: "yourdomain.com",
webhookSecret: process.env.MAILKITE_WEBHOOK_SECRET!,
}),
],
});What actually happens
Every email surface Better Auth ships is outbound — a magic link, an OTP, an invitation. When someone replies, there is nowhere for it to land. These two plugins cover both directions.
You install two plugins
@mailkite/better-auth is a real BetterAuthPlugin — dropping it into plugins wires sendVerificationEmail and sendResetPassword through its init(). Better Auth merges that with defu, so if you already set either callback, yours wins.
Better Auth calls them
Sign up here and Better Auth generates a token and a URL, then calls the plugin. No callback written by hand. Magic link, email OTP and org invites read their callback from their own plugin's closure, so those get passed explicitly off the same object.
Sends are backgrounded on purpose
Awaiting the send would make the response slower when an account exists, which leaks who has an account. The plugin dispatches and returns immediately; failures go to onError, never to the caller. On serverless you pass waitUntil so the runtime doesn't kill the send.
Mail comes back
Claim an address and the inbox plugin registers a route with MailKite. Incoming mail arrives at /api/auth/mailkite/inbox/webhook, is HMAC-verified against that route's own signing secret, stored against the owning mailbox, and read through session-scoped endpoints.
Neither plugin puts a MailKite API key in the browser, and the sending one adds no database tables — it's a transport, so there's no migration to run just to send a password-reset email.
Questions about the plugins
If it isn't answered here, the docs and the demo source are both public.