@tinysend/better-auth turns your better-auth users into a consented tinysend audience. Sign-ups become subscribers on a newsletter you can broadcast to, with tags and metadata synced onto the contact, account-security emails, and unsubscribes that propagate back into your own database.
Two parts, use either or both:
tinysend()— a real better-auth plugin: audience sync + security notifications + unsubscribe webhooksenders— option adapters that route better-auth’s own emails (verification, reset, magic link, OTP) through tinysend
npm install better-auth tinysend @tinysend/better-auth
The audience plugin
import { betterAuth } from 'better-auth';
import { tinysend } from '@tinysend/better-auth';
export const auth = betterAuth({
plugins: [
tinysend({
apiKey: process.env.TINYSEND_API_KEY!,
listId: 'lst_...', // the newsletter users land on
appUrl: 'https://your-app.com', // for the unsubscribe webhook
optIn: 'double', // 'double' (default) | 'single'
// map a user onto the tinysend contact (all additive, nothing is cleared):
mapContact: (user) => ({
tags: ['from:app', user.plan === 'pro' ? 'pro' : 'free'],
metadata: { plan: user.plan, signup_source: 'web' },
org: user.company,
jobTitle: user.role,
}),
}),
],
});
Run npx @better-auth/cli generate after adding it — the plugin adds two fields to user and a small tinysendWebhook table.
How it works:
- sign-up sync: on user create the plugin subscribes them (
optIn: 'double'sends a confirmation email and holds at pending;'single'subscribes immediately — only with explicit consent on your form). tags, metadata, org, and job title frommapContactare written onto the contact, additively. - unsubscribe always propagates back: footer link, one-click
List-Unsubscribe, or replying STOP all fire asubscriber.unsubscribedwebhook (HMAC-SHA256 verified) that updates the user row. the plugin self-registers that webhook on first sync. - client helpers:
@tinysend/better-auth/clientexposesauthClient.tinysend.subscribe(),.unsubscribe(), and.preferences().
Security notifications
The plugin also emails users after events better-auth has no built-in callback for — password changed, email changed, 2FA enabled/disabled. These never block the auth operation. Disable per event with notifications: { passwordChanged: false }, or all with notifications: false.
Senders: route better-auth’s own emails through tinysend
The senders adapters wire better-auth’s email callbacks to tinysend. Because tinysend addresses are real two-way mailboxes, replies to those emails land in an inbox with webhooks and automations, not a dead no-reply.
import { betterAuth } from 'better-auth';
import { magicLink, emailOTP } from 'better-auth/plugins';
import { Tinysend } from 'tinysend';
import { senders } from '@tinysend/better-auth';
const ts = new Tinysend(process.env.TINYSEND_API_KEY!);
betterAuth({
emailVerification: { sendVerificationEmail: senders.verification(ts) },
emailAndPassword: { enabled: true, sendResetPassword: senders.reset(ts) },
plugins: [
magicLink({ sendMagicLink: senders.magicLink(ts) }),
emailOTP({ sendVerificationOTP: senders.otp(ts) }),
],
});
senders covers verification, reset, magic link, OTP, two-factor, change-email, delete-account, and invitations — each returns the exact callback shape better-auth expects and throws on failure so better-auth surfaces “could not send email”. Every sender takes an optional template to override subject, html, text, and tag. With a mailbox key (sk_mbx_…) the from defaults to the mailbox; with an account-wide key, pass from: senders.verification(ts, { from: 'auth@yourdomain.com' }).
Testing auth flows with no signup
tinysend gives every account disposable inboxes over the API, so you can end-to-end test the whole verification/OTP loop in CI without a human mailbox — register anonymously, create an inbox, send through better-auth, read the OTP back.
Source
github.com/tiny-send/tinysend-better-auth. Questions: hi@tinysend.com.