Wraps Logo
DocsHome
Guide

Better Auth

Turn signups into contacts, and send every auth email from your own AWS SES account. One plugin, two halves — use either on its own.

Sync

New users become Wraps contacts and fire a user.signed_up event, so your welcome sequences and onboarding workflows run.

Send

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.

Installation

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.

Quick Start

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.

TypeScriptauth.ts
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.

Auth Emails

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.

TypeScriptauth.ts
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 }),  ],});

AWS credentials

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.

TypeScriptauth.ts
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',    },  },});

Custom templates

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.

TypeScriptauth.ts
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}`,      }),    },  },});

Contact Sync

On user creation the plugin upserts a contact, then fires the signup event so matching workflows run.

requests.http
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.

Consent and Topics

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.

TypeScriptauth.ts
wraps({  apiKey: process.env.WRAPS_API_KEY,  // Only set this when your signup form actually asks for consent.  topicSlugs: ['product-updates'],});

Options

TypeScriptauth.ts
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 }),});

Serverless and waitUntil

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.

TypeScriptauth.ts
import { waitUntil } from '@vercel/functions';wraps({  apiKey: process.env.WRAPS_API_KEY,  waitUntil,});

Error Handling

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.

TypeScriptauth.ts
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');  },});

Every Signup Path Is Covered

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.

Client Plugin (optional)

Type inference only. Everything happens server-side and your API key never reaches the browser.

TypeScriptauth-client.ts
import { createAuthClient } from 'better-auth/client';import { wrapsClient } from '@wraps.dev/better-auth/client';export const authClient = createAuthClient({  plugins: [wrapsClient()],});

Next Steps

Building Workflows

Turn the user.signed_up event into a welcome sequence with delays, conditions, and channel cascades.

Build a workflow
Domain Verification

Auth emails need a verified sending domain with DKIM. Set yours up before going live.

Verify a domain

Need Help?

If you run into any issues, check our GitHub discussions or open an issue.

Get Help