eusend
Integrations

Better Auth

Send Better Auth's verification, password-reset, OTP, magic-link and invitation email through eusend with the official plugin — branded default templates, non-blocking sends, and contact sync for verified users.

Better Auth doesn't ship an email transport. It asks you for a function for each email it needs — verify an address, reset a password, send a sign-in code. @eusend_dev/better-auth is a Better Auth plugin that supplies those functions, with default templates you can brand or replace, and can add verified users to an eusend audience.

The eusend dashboard runs on Better Auth too.

Installation

npm install @eusend_dev/better-auth
# or
bun add @eusend_dev/better-auth

Setup

Verify your sending domain

Add your domain under Domains and publish the DNS records — eusend only sends from a verified domain. See domains if you have not done this yet.

Add the API key

Create a key under API keys and put it in your environment. The plugin reads EUSEND_API_KEY by default.

.env
EUSEND_API_KEY=eu_live_...

Use an eu_test_ key while developing: the send goes through validation, quota and suppression checks and shows up in your email log, but nothing is delivered.

Add the plugin

lib/auth.ts
import { betterAuth } from 'better-auth'
import { eusend } from '@eusend_dev/better-auth'

export const auth = betterAuth({
  emailAndPassword: { enabled: true, requireEmailVerification: true },
  emailVerification: { sendOnSignUp: true },
  plugins: [
    eusend({
      email: {
        from: 'Acme <auth@acme.com>',
        appName: 'Acme',
        brand: {
          logoUrl: 'https://acme.com/logo.png',
          primaryColor: '#4f46e5',
          supportEmail: 'help@acme.com',
        },
      },
    }),
  ],
})

Sign up with a real address and the verification email arrives from your domain. The plugin also sends password-reset emails and a "your password was changed" notice after a reset.

Everything the plugin sets is a default. Better Auth merges plugin options underneath your own, so a sendVerificationEmail you already wrote keeps working, and the plugin never turns on password sign-in: emailAndPassword.enabled stays whatever you set.

These emails belong to other Better Auth plugins, so build the senders once and pass them in:

lib/auth.ts
import { eusend, eusendAuthEmails } from '@eusend_dev/better-auth'
import { emailOTP, magicLink, organization } from 'better-auth/plugins'

const emails = eusendAuthEmails({
  from: 'Acme <auth@acme.com>',
  appName: 'Acme',
  appUrl: 'https://app.acme.com',
})

export const auth = betterAuth({
  plugins: [
    eusend({ email: { from: 'Acme <auth@acme.com>', appName: 'Acme' } }),
    emailOTP({ sendVerificationOTP: emails.otp }),
    magicLink({ sendMagicLink: emails.magicLink }),
    organization({ sendInvitationEmail: emails.invitation }),
  ],
})

The OTP subject names the flow — sign-in, email verification, password reset or email change. Invitation links default to ${appUrl}/accept-invitation/${id}, the route Better Auth's docs use; pass invitationUrl: ({ id }) => ... for your own.

Sends don't block the response

No sender waits for eusend to answer. That is what Better Auth recommends: an endpoint that waits for the send responds faster when an address has no account, and that difference tells an attacker which addresses are registered.

On a long-running server nothing else is needed. On serverless, the function can be frozen as soon as the response goes out, so hand the send to the platform:

lib/auth.ts
import { after } from 'next/server'

eusend({ waitUntil: after, email: { from: 'Acme <auth@acme.com>', appName: 'Acme' } })

On Vercel outside Next.js, use waitUntil from @vercel/functions; on Cloudflare Workers, use ctx.waitUntil.

Syncing users to an audience

Add sync and users are added to an audience once their email is verified, ready for your product updates and broadcasts:

lib/auth.ts
eusend({
  email: { from: 'Acme <auth@acme.com>', appName: 'Acme' },
  sync: {
    audienceId: process.env.EUSEND_AUDIENCE_ID!,
    properties: (user) => ({ user_id: user.id }),
  },
})
  • Verified addresses only. Password sign-ups are added when they verify. Users whose provider already verified them, like Google or GitHub, are added when they are created. A sign-up form accepts typos and other people's addresses, and mailing those damages your sending domain's reputation.
  • Nothing is overwritten. The sync merges into an existing contact. It never clears properties or names you set elsewhere, and it never removes an opt-out: someone who unsubscribed and later signs up stays unsubscribed.
  • Every sign-up path. It runs on Better Auth's database hooks, so email and password, OAuth, magic link, OTP and admin-created users all go through it, alongside any database hooks of your own.

Syncing adds a contact; whether someone agreed to marketing email is still yours to record. Topics let contacts choose which kinds of email they get.

Templates

The default templates are plain HTML with a text version, and carry no eusend branding — they read as coming from your app. Everything you pass in is HTML-escaped, and a link that isn't http or https is refused rather than rendered.

Replace any of the six — verification, resetPassword, passwordChanged, magicLink, otp, invitation:

lib/auth.ts
eusend({
  email: {
    from: 'Acme <auth@acme.com>',
    appName: 'Acme',
    templates: {
      verification: ({ user, url }, { appName }) => ({
        subject: `Confirm your ${appName} account`,
        html: renderMyEmail({ user, url }),
        text: `Confirm your account: ${url}`,
      }),
    },
  },
})

If your templates are React Email components, render them in the template with @react-email/render.

Errors and the email log

A failed send never throws into Better Auth — a sign-up still succeeds when its email could not be sent. Failures are logged with console.error, or passed to your own handler:

eusend({
  onError: ({ category, to, code, message }) => logger.warn({ category, to, code }, message),
  email: { from: 'Acme <auth@acme.com>', appName: 'Acme' },
})

The two codes you are most likely to meet first:

  • DOMAIN_NOT_VERIFIED — from is on a domain that has not finished verifying. Check it under Domains.
  • ALL_SUPPRESSED — the recipient hard-bounced or complained before and is on your suppression list.

Every message is tagged source: better-auth and category: verification (or reset-password, password-changed, otp, magic-link, invitation), so the email log can show only auth mail. Click tracking is off for auth email: some link scanners follow every link in a message, and following a tracked single-use link would use it up before the user clicks.

Options

ParameterTypeDescription
apiKeystringAn eusend API key. Defaults to EUSEND_API_KEY.
waitUntil(promise) => voidKeeps sends alive after the response on serverless — after, waitUntil or ctx.waitUntil.
onError(error) => voidReceives { category, to, code, message } for a failed send or sync. Defaults to console.error.
baseUrlstringOverride the API host. Only useful against a staging deployment.
email.fromrequiredstringSender for every auth email. Its domain must be verified on your account.
email.appNamerequiredstringYour product name, used in subjects and copy.
email.brandobjectlogoUrl (absolute https), primaryColor (hex) and supportEmail.
email.replyTostringReply-to address for auth email.
email.templatesobjectReplace any of the six templates.
email.tagsRecord<string, string>Extra tags on every send, up to 8.
email.appUrlstringYour app URL, used to build invitation links.
email.invitationUrl({ id, email }) => stringBuild invitation links yourself instead.
sync.audienceIdrequiredstringThe audience verified users are added to.
sync.properties(user) => Record<string, string>Custom contact properties, merged into what the contact already has.

eusendAuthEmails() takes the same fields as email, plus apiKey, waitUntil, onError and baseUrl.

Where auth email is processed

Every auth email carries personal data — who signed up and when — plus a link or code that logs someone into your app. With eusend, that message is accepted, stored and delivered on infrastructure in the EU, by a company with no US parent. That keeps it out of the third-country-transfer section of your privacy review. See sub-processors for the full list.

Without the plugin

If you would rather write the senders yourself, call the Node SDK from Better Auth's hooks. Don't await the send, for the reason above:

lib/auth.ts
import { betterAuth } from 'better-auth'
import { Eusend } from '@eusend_dev/sdk'

const eusend = new Eusend()

export const auth = betterAuth({
  emailVerification: {
    sendVerificationEmail: async ({ user, url }) => {
      void eusend.emails.send({
        from: 'Acme <auth@acme.com>',
        to: user.email,
        subject: 'Verify your email address',
        html: `<p><a href="${url}">Verify your email address</a></p>`,
        tags: { source: 'better-auth', category: 'verification' },
      })
    },
  },
})

The SDK returns { data, error } rather than throwing, so check error if you want failures logged.

Coming from Resend

If you followed Resend's Better Auth guide, your senders call resend.emails.send(). Either replace them with the plugin, or keep them and swap the client — the payload shape is the same:

lib/auth.ts
- import { Resend } from 'resend'
+ import { Eusend } from '@eusend_dev/sdk'

- const resend = new Resend(process.env.RESEND_API_KEY)
+ const eusend = new Eusend(process.env.EUSEND_API_KEY)

- await resend.emails.send({ from, to, subject, html })
+ await eusend.emails.send({ from, to, subject, html })