Managed Better Auth is a managed authentication and user management service that stores its data in your Neon database. By default, it handles common authentication flows and delivers OTPs for email verification and passwordless login.

You may need to customize how these messages are delivered or add extra logic to your authentication flow. Common scenarios include:

  • Multi-channel delivery: Sending OTPs via SMS (e.g., with Twilio) or delivering notifications through WhatsApp or Slack instead of email.
  • Custom branding: Using dedicated providers like Resend, SendGrid, or Postmark to design branded HTML email templates.
  • Localization: Translating OTP or Magic Link messages dynamically based on the user’s language or region.
  • Analytics & integrations: Logging user creation events to a CRM, triggering marketing automations, or syncing with third-party identity providers.
  • Signup validation: Blocking disposable email domains, IP addresses, or implementing custom fraud detection logic before allowing user sign-ups.

For these cases, Managed Better Auth provides webhooks. Webhooks let you intercept authentication events (such as send.otp, send.magic_link, and user.before_create) and handle them with your own backend logic. For example, when you subscribe to send.otp, Managed Better Auth skips its default delivery, leaving your webhook handler in charge of sending the OTP. You control the content, the delivery method, and any side effects.

This guide walks through building a custom webhook handler in a Next.js application. You’ll learn how to send OTPs via email using Resend, implement a blocking handler to prevent sign-ups from disposable email providers, and optionally modify the handler to send SMS or WhatsApp messages using Twilio.

Prerequisites

Before you begin, ensure you have the following:

  • Node.js: Version 18 or later installed on your machine.
  • Neon account: A free Neon project. If you don't have one, sign up at Neon.
  • Resend account: To send custom emails. You'll need an API key. You can use the default resend.dev testing domain, or verify a custom domain.
  • ngrok (or a similar tunneling tool): To test webhooks locally, you will need ngrok installed and configured on your machine. See the ngrok Quickstart to sign up, install the CLI, and authenticate with your auth token.

See the Managed Better Auth webhooks reference for complete event payload fields, required response formats, retry behavior, and signature verification details.

  1. Create a Neon project with Auth

    You'll need a Neon project with Auth enabled to generate webhook events.

    1. Create a Neon project: Navigate to the Neon Console to create a new Neon project. Give your project a name, such as neon-webhooks-demo.
    2. Enable Auth:
      • In your project's dashboard, go to the Auth tab.
      • Click Enable Auth to set up authentication for your project.
    3. Copy your credentials:
      • Managed Better Auth URL: Found on the Configuration tab of the Auth page. Managed Better Auth URL
      • Neon API key: Create an API key in the Neon Console (see Creating API keys). You'll need it to register your webhook. Neon API Key
      • Neon Project ID: Found in your project settings.
      • Neon Branch ID: Found in the Branches section of your project. Click on the branch you are using (e.g., production) to find the Branch ID.
  2. Set up the Next.js application

    Create a new Next.js project and install the dependencies for Managed Better Auth and Resend.

    npx create-next-app@latest neon-webhooks-demo --yes
    cd neon-webhooks-demo

    Install the required packages:

    npm install @neondatabase/auth@latest @neondatabase/auth-ui @neondatabase/neon-js@latest resend
  3. Configure environment variables

    Create a .env.local file in the root of your project. Add your API keys and Managed Better Auth details. Generate a random string of at least 32 characters for NEON_AUTH_COOKIE_SECRET. For example, run openssl rand -base64 32.

    # Neon configuration
    NEON_AUTH_BASE_URL="https://ep-xxx.neon.tech/neondb/auth"
    NEON_AUTH_COOKIE_SECRET="your-random-string" # openssl rand -base64 32
    NEON_API_KEY="your-neon-api-key"
    NEON_PROJECT_ID="your-project-id"
    NEON_BRANCH_ID="your-branch-id"
    
    # Resend configuration
    RESEND_API_KEY="re_123456789"
    # Use the default resend.dev domain for testing, or verify your own domain
    EMAIL_FROM="onboarding@yourdomain.com"
  4. Configure Managed Better Auth in Next.js

    Set up the Managed Better Auth SDK to handle user sessions, API routing, and the frontend UI.

    1. Create the server client: Create lib/auth/server.ts:

      import { createNeonAuth } from '@neondatabase/auth/next/server';
      
      export const auth = createNeonAuth({
        baseUrl: process.env.NEON_AUTH_BASE_URL!,
        cookies: { secret: process.env.NEON_AUTH_COOKIE_SECRET! },
      });
    2. Create the browser client: Create lib/auth/client.ts:

      'use client';
      import { createAuthClient } from '@neondatabase/auth/next';
      
      export const authClient = createAuthClient();
    3. Set up the Auth API route: Create app/api/auth/[...path]/route.ts. This proxies Next.js auth requests to the Managed Better Auth service:

      import { auth } from '@/lib/auth/server';
      export const { GET, POST } = auth.handler();
    4. Add Managed Better Auth styles: Update app/globals.css to include the Managed Better Auth UI styles:

      @import 'tailwindcss';
      @import '@neondatabase/auth-ui/tailwind'; 
      
      /* other styles.. */
    5. Add Managed Better Auth UI provider: Update app/layout.tsx to wrap your application with the NeonAuthUIProvider, which supplies authentication context and UI components.

      import { authClient } from '@/lib/auth/client';
      import { NeonAuthUIProvider, UserButton } from '@neondatabase/auth-ui';
      import './globals.css';
      
      export default function RootLayout({ children }: { children: React.ReactNode }) {
        return (
          <html lang="en" suppressHydrationWarning>
            <body>
              <NeonAuthUIProvider authClient={authClient as any} emailOTP social={{ providers: ['google'] }}>
                <header className="flex h-16 items-center justify-between border-b p-4">
                  <h1 className="text-xl font-bold">Managed Better Auth Webhooks Demo</h1>
                  <UserButton size={'icon'} />
                </header>
                {children}
              </NeonAuthUIProvider>
            </body>
          </html>
        );
      }
    6. Create Auth page: Create app/auth/[path]/page.tsx for the authentication UI using Neon's pre-built components.

      import { AuthView } from '@neondatabase/auth-ui';
      
      export const dynamicParams = false;
      
      export default async function AuthPage({ params }: { params: Promise<{ path: string }> }) {
        const { path } = await params;
      
        return (
          <main className="container mx-auto flex grow flex-col items-center justify-center gap-3 self-center p-4 md:p-6">
            <AuthView path={path} />
          </main>
        );
      }
    7. Create the main page: Overwrite app/page.tsx with a simple app that shows a "Logged In" state or a link to sign up.

      'use client';
      
      import { UserButton } from '@neondatabase/auth-ui';
      import { authClient } from '@/lib/auth/client';
      import Link from 'next/link';
      
      export default function Home() {
        const { data: session, isPending } = authClient.useSession();
      
        if (isPending) return <div className="flex min-h-screen items-center justify-center">Loading...</div>;
      
        return (
          <main className="flex min-h-screen flex-col items-center justify-center p-8">
            {session?.user ? (
              <div className="bg-white p-8 rounded-xl shadow-sm text-center space-y-4 dark:bg-gray-800">
                <h1 className="text-2xl font-bold">Welcome, {session.user.email}</h1>
                <p className="text-gray-500 dark:text-gray-400">You successfully bypassed the default email provider!</p>
                <div className="flex justify-center mt-4">
                  <UserButton size={"sm"} />
                </div>
              </div>
            ) : (
              <div className="w-full max-w-md text-center">
                <h1 className="text-3xl font-bold mb-8">Managed Better Auth Webhooks Demo</h1>
                <Link href="/auth/sign-up" className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
                  Sign In / Sign Up
                </Link>
              </div>
            )}
          </main>
        );
      }
  5. Create the webhook

    Implement webhook signature verification

    Next, build the webhook functionality. Managed Better Auth uses EdDSA (Ed25519) signatures with detached JWS to secure webhook payloads. Verify these signatures so no one can forge authentication events.

    Create lib/neon-webhook.ts:

    import crypto from 'node:crypto';
    
    export async function verifyNeonWebhook(rawBody: string, headers: Headers) {
      const signature = headers.get('x-neon-signature');
      const kid = headers.get('x-neon-signature-kid');
      const timestamp = headers.get('x-neon-timestamp');
    
      if (!signature || !kid || !timestamp) {
        throw new Error('Missing required Neon webhook headers');
      }
    
      // 1. Fetch JWKS and find the matching key
      const res = await fetch(`${process.env.NEON_AUTH_BASE_URL}/.well-known/jwks.json`);
      const jwks = await res.json();
      const jwk = jwks.keys.find((k: any) => k.kid === kid);
      if (!jwk) throw new Error(`Key ${kid} not found in JWKS`);
    
      // 2. Import the Ed25519 public key
      const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' });
    
      // 3. Parse detached JWS (header..signature)
      const [headerB64, emptyPayload, signatureB64] = signature.split('.');
      if (emptyPayload !== '') throw new Error('Expected detached JWS format');
    
      // 4. Reconstruct signing input (standard JWS, double base64url encoding)
      const payloadB64 = Buffer.from(rawBody, 'utf8').toString('base64url');
      const signaturePayload = `${timestamp}.${payloadB64}`;
      const signaturePayloadB64 = Buffer.from(signaturePayload, 'utf8').toString('base64url');
      const signingInput = `${headerB64}.${signaturePayloadB64}`;
    
      // 5. Verify Ed25519 signature
      const isValid = crypto.verify(
        null,
        Buffer.from(signingInput),
        publicKey,
        Buffer.from(signatureB64, 'base64url')
      );
    
      if (!isValid) throw new Error('Invalid webhook signature');
    
      // 6. Check timestamp freshness (prevent replay attacks - 5 min window)
      const ageMs = Date.now() - parseInt(timestamp, 10);
      if (ageMs > 5 * 60 * 1000) throw new Error('Webhook timestamp too old');
    
      return JSON.parse(rawBody);
    }

    Create the webhook route

    Create the endpoint that receives the events from Managed Better Auth. This route reads the raw text, verifies the signature using your helper, and routes the payload to the appropriate handler (Resend or the blocking logic).

    Create app/api/webhooks/neon/route.ts:

    import { NextRequest, NextResponse } from 'next/server';
    import { verifyNeonWebhook } from '@/lib/neon-webhook';
    import { Resend } from 'resend';
    import { User } from '@neondatabase/auth/types';
    
    const resend = new Resend(process.env.RESEND_API_KEY);
    
    export async function POST(request: NextRequest) {
        try {
            const rawBody = await request.text();
            const payload = await verifyNeonWebhook(rawBody, request.headers);
            const { event_type, event_data, user } = payload;
    
            // Route the event
            switch (event_type) {
                case 'send.otp':
                    await handleSendOtp(event_data, user);
                    return NextResponse.json({ success: true });
    
                case 'send.magic_link':
                    await handleSendMagicLink(event_data, user);
                    return NextResponse.json({ success: true });
    
                case 'user.before_create':
                    const validationResult = await handleUserBeforeCreate(user);
                    // Blocking events must return specific JSON to allow/deny the action
                    return NextResponse.json(validationResult);
    
                case 'user.created':
                    console.log(`New user completely registered: ${user.email}`);
                    return NextResponse.json({ success: true });
    
                default:
                    console.log(`Unhandled event type: ${event_type}`);
                    return NextResponse.json({ success: true });
            }
        } catch (error: any) {
            console.error('Webhook error:', error.message);
            return NextResponse.json(
                { error: error.message },
                { status: error.message.includes('signature') ? 400 : 500 }
            );
        }
    }
    
    async function handleSendOtp(eventData: any, user: User) {
        const { otp_code } = eventData;
    
        console.log(`Sending custom Resend OTP to ${user.email}`);
        await resend.emails.send({
            from: process.env.EMAIL_FROM!,
            to: user.email,
            subject: 'Your Verification Code',
            html: `
          <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
            <h2>Welcome to Our App!</h2>
            <p>Your secure verification code is:</p>
            <h1 style="background: #f4f4f5; padding: 16px; letter-spacing: 6px; text-align: center; border-radius: 8px;">
              ${otp_code}
            </h1>
            <p>This code will expire in 15 minutes.</p>
          </div>
        `,
        });
    }
    
    async function handleSendMagicLink(eventData: any, user: User) {
        const { link_url, link_type } = eventData;
    
        console.log(`Sending custom ${link_type} magic link to ${user.email}`);
        await resend.emails.send({
            from: process.env.EMAIL_FROM!,
            to: user.email,
            subject: 'Your Secure Sign-in Link',
            html: `
          <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
            <h2>Sign in to your account</h2>
            <p>Use the button below to continue:</p>
            <p style="text-align: center; margin: 24px 0;">
              <a href="${link_url}" style="display: inline-block; background: #111827; color: #ffffff; text-decoration: none; padding: 12px 20px; border-radius: 8px;">Open secure link</a>
            </p>
            <p>If you didn't request this, you can safely ignore this email.</p>
          </div>
        `,
        });
    }
    
    // Example: Block signups from disposable domains
    async function handleUserBeforeCreate(user: User) {
        const blockedDomains = ['spam.com', 'tempmail.org'];
        const userDomain = user.email.split('@')[1];
    
        if (blockedDomains.includes(userDomain)) {
            console.log(`Blocked signup for domain: ${userDomain}`);
            return {
                allowed: false,
                error_message: "Signups from this domain are not allowed. Please use a work email.",
                error_code: "DOMAIN_BLOCKED"
            };
        }
    
        return { allowed: true };
    }

    The above code does the following:

    • Verifies the webhook signature and parses the payload.
    • For send.otp events, it sends a custom OTP email using Resend.
    • For send.magic_link events, it sends a custom sign-in or password reset link email using Resend.
    • For user.before_create events, it checks if the email domain is in a blocked list and returns a response to allow or deny the signup accordingly.
    • For user.created events, logs the new user, which you can extend for analytics or other side effects.
  6. Expose and register the webhook

    Managed Better Auth requires a public HTTPS URL to deliver webhooks. Use ngrok to expose your local Next.js server.

    1. Start your Next.js development server:
      npm run dev
    2. In a new terminal, start ngrok on port 3000:
      ngrok http 3000
      Note the forwarding URL provided by ngrok (e.g., https://a1b2c3d4.ngrok.app).

    Next, register this webhook URL with Managed Better Auth using the Neon API. You can also do this in the Neon Console under Auth > Configuration > Webhooks. Run the following curl command, replacing the placeholders with your API Key, Project ID, Branch ID, and your ngrok URL:

    curl -X PUT "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/branches/$NEON_BRANCH_ID/auth/webhooks" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer $NEON_API_KEY" \
      -d '{
        "enabled": true,
        "webhook_url": "https://a1b2c3d4.ngrok.app/api/webhooks/neon",
        "enabled_events": ["send.otp", "send.magic_link", "user.before_create", "user.created"]
      }'

    If successful, the API returns your updated configuration as JSON.

    {
        "enabled":true,
        "webhook_url":"https://a1b2c3d4.ngrok.app/api/webhooks/neon",
        "enabled_events":["send.otp","send.magic_link","user.before_create","user.created"],
        "timeout_seconds":5
    }
  7. Test the complete flow

    With the webhook active and your Next.js app running, test the full authentication flow, including the blocking logic and custom OTP/magic link delivery.

    1. Test the blocking logic:

      • Open your browser to http://localhost:3000.
      • In the Sign Up form, try to register with testuser@spam.com and a random password.
      • Result: Managed Better Auth halts the signup. The UI immediately displays the error you defined in your webhook: "Signups from this domain are not allowed. Please use a work email." Blocked Signup
    2. Test custom OTP and magic link delivery:

      • Sign up using a valid email.
      • Sign out and either click on "Sign In with Email Code" or click "Forgot your password?".
      • Result: Instead of receiving a default email from Neon, you receive your custom Resend email. OTP flows trigger send.otp, and if you choose "Send reset link" in password reset, Neon triggers send.magic_link and your webhook delivers the custom link email.

Optional: Send OTPs via SMS

The example above delivers email. To send OTPs via SMS or WhatsApp instead, change the delivery code in the handler.

First, install the Twilio SDK in your project:

npm install twilio

Then, you can adjust your handleSendOtp function to send an SMS instead of an email. Here's an example of how to do this with Twilio:

import twilio from 'twilio';

const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);

async function handleSendOtp(eventData: any, user: any) {
    const { otp_code } = eventData;
    await twilioClient.messages.create({
        body: `Your App verification code is: ${otp_code}. Do not share this with anyone.`,
        from: process.env.TWILIO_PHONE_NUMBER,
        to: USER_PHONE_NUMBER
    });
}

You can integrate WhatsApp or other channels supported by Twilio or your chosen provider the same way, and log these events to a CRM or analytics platform.

Deploy to production

When you're ready to go live:

  1. Deploy your app to a hosting platform like Vercel, Netlify, or AWS.
  2. Ensure you configure your environment variables (RESEND_API_KEY, NEON_AUTH_COOKIE_SECRET, etc.) in your hosting provider's dashboard.
  3. Update your webhook URL in Managed Better Auth. Run the curl command from Expose and register the webhook again, replacing the ngrok URL with your new production URL (e.g., https://yourdomain.com/api/webhooks/neon).

important

Fail-closed behavior: The user.before_create event is a blocking event. If your webhook endpoint goes down or keeps returning errors in production, all new sign-ups fail. Blocking deliveries get up to 3 attempts (1 initial plus 2 retries) within a 15-second global timeout, so keep response times well under your configured timeout_seconds. For non-blocking events like user.created, return 200 OK immediately and process the event asynchronously.

Source code

The complete source code for a Next.js application implementing these webhooks is available on GitHub.

Resources