Better Auth
Sync Better Auth signups to Wraps contacts and send verification, password reset, magic link, OTP, and invitation emails from your own AWS SES account.
Turn signups into contacts, and send every auth email from your own AWS SES account. One plugin, two halves — use either on its own.
New users become Wraps contacts and fire a user.signed_up event, so your welcome sequences and onboarding workflows run.
Drop-in senders for verification, password reset, magic link, OTP, and org invites — delivered through your SES account. No Wraps account needed for this half.
Install the plugin alongside Better Auth 1.6 or later.
npm install @wraps.dev/better-auth
@wraps.dev/email is an optional peer dependency, needed only for the email half.
Add the plugin to your Better Auth config. That is the whole setup — new users sync, and sendVerificationEmail, sendResetPassword, and onPasswordReset are wired for you.
import { betterAuth } from 'better-auth';import { wraps } from '@wraps.dev/better-auth';export const auth = betterAuth({ emailAndPassword: { enabled: true }, plugins: [ wraps({ // Contact sync — omit to disable apiKey: process.env.WRAPS_API_KEY, // Auth emails via your own SES account — omit to disable email: { from: 'Acme <auth@acme.com>', appName: 'Acme', brand: { logoUrl: 'https://acme.com/logo.png', primaryColor: '#4f46e5', supportEmail: 'help@acme.com', }, }, }), ],});Your config always wins
Better Auth merges plugin options underneath your own. If you already define sendVerificationEmail, the plugin leaves it alone — the senders it supplies are defaults that fill gaps. It also never sets emailAndPassword.enabled, so configuring the email half cannot switch on password auth for an app that did not ask for it.
The plugin fills in the senders it can reach on its own. Magic link, OTP, and organisation invites belong to other Better Auth plugins, so build the senders once and pass them in.
import { wrapsAuthEmails } from '@wraps.dev/better-auth';import { emailOTP, magicLink, organization } from 'better-auth/plugins';const emails = wrapsAuthEmails({ from: 'Acme <auth@acme.com>', appName: 'Acme', appUrl: 'https://app.acme.com', // builds the invitation link});export const auth = betterAuth({ plugins: [ magicLink({ sendMagicLink: emails.magicLink }), emailOTP({ sendVerificationOTP: emails.otp }), organization({ sendInvitationEmail: emails.invitation }), ],});Credentials follow the standard @wraps.dev/email resolution chain. With nothing set, the AWS credential chain resolves as usual — environment variables, shared config, or an instance role.
wraps({ email: { from: 'auth@acme.com', ses: { region: 'us-east-1', // OIDC role assumption on Vercel or GitHub Actions roleArn: 'arn:aws:iam::123456789012:role/AcmeMail', }, },});The bundled templates are plain HTML with no React dependency, and carry no Wraps branding — they read as coming from your app. Override any of them.
wraps({ email: { from: 'auth@acme.com', templates: { verification: ({ user, url, appName }) => ({ subject: `Confirm your ${appName} account`, html: renderMyEmail({ user, url }), text: `Confirm your email: ${url}`, }), }, },});On user creation the plugin upserts a contact, then fires the signup event so matching workflows run.
POST /v1/contacts/{ "externalId": "K3mQx...", // the better-auth user id "email": "ada@example.com", "firstName": "Ada", "lastName": "Lovelace", "emailStatus": "active"}POST /v1/events/{ "name": "user.signed_up", "contactId": "con_...", "properties": { "method": "oauth", "provider": "google", "source": "better-auth" }}If the email already belongs to a contact — a newsletter subscriber converting, say — the existing contact is patched instead of failing. properties.method records how they signed up: email, oauth, passkey, magic-link, or otp.
New contacts are subscribed to no topics by default. A signup is a transactional relationship, not marketing consent — quietly adding every new account to a marketing list is how SES reputations get damaged.
wraps({ apiKey: process.env.WRAPS_API_KEY, // Only set this when your signup form actually asks for consent. topicSlugs: ['product-updates'],});wraps({ // --- contact sync --- apiKey: process.env.WRAPS_API_KEY, baseUrl: 'https://api.wraps.dev', eventName: 'user.signed_up', // or false to skip the event topicSlugs: [], emailStatus: 'active', properties: (user) => ({ plan: 'free' }), shouldSync: (user) => !user.email.endsWith('@internal.acme.com'), syncOnUpdate: true, // patch the contact on email/name change syncOnDelete: false, // or 'unsubscribe' | 'delete' // --- auth emails --- email: { from: 'Acme <auth@acme.com>', appName: 'Acme', appUrl: 'https://app.acme.com', replyTo: 'support@acme.com', configurationSetName: 'acme-auth', brand: { logoUrl, primaryColor, supportEmail, footerText }, templates: { /* per-template overrides */ }, ses: { /* region, credentials, roleArn, client */ }, }, // --- behaviour --- waitUntil: (promise) => ctx.waitUntil(promise), onContactSynced: ({ userId, contactId, created }) => {}, onError: (error, { stage }) => logger.warn({ error, stage }),});Sync work is awaited by default. On Lambda the runtime freezes the moment the handler returns, so fire-and-forget background work never happens. Pass waitUntil when your platform has a real background primitive.
import { waitUntil } from '@vercel/functions';wraps({ apiKey: process.env.WRAPS_API_KEY, waitUntil,});Every contact write and every send is wrapped. Failures go to onError and stop there — a Wraps outage or an SES throttle cannot break a signup.
wraps({ apiKey: process.env.WRAPS_API_KEY, onError: (error, { stage, user }) => { // stage is 'contact' | 'event' | 'email' logger.warn({ err: error, stage, userId: user?.id }, 'wraps sync failed'); },});The plugin hangs off databaseHooks.user.create.after, not response-level after hooks. Better Auth skips after hooks on OAuth redirect responses, so a plugin that matches on /callback/* silently misses every Google and GitHub signup. Database hooks fire for all of them, including users created by an admin or by SCIM — and plugin hooks are additive, so your own databaseHooks still run.
Type inference only. Everything happens server-side and your API key never reaches the browser.
import { createAuthClient } from 'better-auth/client';import { wrapsClient } from '@wraps.dev/better-auth/client';export const authClient = createAuthClient({ plugins: [wrapsClient()],});Turn the user.signed_up event into a welcome sequence with delays, conditions, and channel cascades.
Auth emails need a verified sending domain with DKIM. Set yours up before going live.
Verify a domain