code examples
code examples
MessageBird Next.js Two-Way SMS Tutorial: Auth.js + Inbound Webhooks
Build a Next.js application with Auth.js v5 authentication and MessageBird two-way SMS messaging. Implement secure webhooks for inbound messages, Prisma database integration, and user phone number association.
MessageBird Next.js Two-Way SMS Tutorial: Auth.js + Inbound Webhooks
Build a Next.js application with secure user authentication via Auth.js (v5) and two-way SMS communication powered by MessageBird. Your users will log in, associate their phone number, receive incoming SMS messages sent to a dedicated virtual number, and reply directly through your web application.
You'll implement project setup, authentication configuration, MessageBird webhooks for inbound messages, outbound replies via the MessageBird API, database integration with Prisma, security considerations, and deployment strategies. By the end, you'll have a functional application demonstrating robust integration between these technologies.
Target Audience: Developers familiar with Next.js, React, and basic API concepts.
⚠️ Article Status: This tutorial covers project setup, authentication, and inbound message handling. Sections on sending outbound SMS, building the chat interface, deployment, and advanced security considerations are not included in this version.
Project Overview and Goals
What You'll Build:
A Next.js (App Router) web application where:
- Users authenticate using email/password (via Auth.js Credentials provider).
- Authenticated users have a phone number associated with their profile (stored via Prisma).
- Incoming SMS messages sent to a specific MessageBird virtual number route to your application via webhooks.
- Your application identifies the corresponding user based on the sender's phone number (
originator). - Inbound messages store in your database and display to the relevant user within the app.
- Authenticated users send outbound SMS replies from the application interface to the original sender.
Problem You'll Solve:
This implementation bridges standard web application authentication with real-world SMS communication. It enables features like:
- Personalized SMS notifications tied to user accounts.
- In-app customer support via SMS.
- Two-factor authentication flows (though this guide focuses on general messaging).
- Any scenario requiring associating SMS conversations with specific, authenticated users.
Technologies You'll Use:
- Next.js (v15 with App Router): React framework for building your frontend and API routes. Uses React 19 support, Turbopack bundling improvements, and modern caching strategies. (As of January 2025, Next.js 15.5+ is current with stable Turbopack and TypeScript improvements.)
- Auth.js (v5 –
next-auth@5.x): Handles user authentication and session management. Chosen for flexibility, rich provider support, and seamless Next.js integration. (Formerly known as NextAuth.js; v5 is a major rewrite with @auth/core, requiring Next.js 14+ minimum. Official docs at authjs.dev.) - MessageBird: SMS API provider for sending and receiving messages. Chosen for robust API, global reach, and developer-friendly tools like Flow Builder. (MessageBird APIs remain actively maintained as of 2025 with no major deprecations announced.)
- Prisma (v6.x): Next-generation ORM for database access (connecting to PostgreSQL, SQLite, etc.). Chosen for type safety, schema management (migrations), and developer productivity. (As of 2025, Prisma 6+ includes Rust-free architecture improvements for better performance and edge compatibility.)
- PostgreSQL (or SQLite): Relational database for storing user data, authentication details, and message history. (This guide uses Prisma syntax compatible with various SQL databases).
- Tailwind CSS: For styling your user interface.
System Architecture:
graph TD
subgraph "User's Phone"
UPhone[SMS Client]
end
subgraph "MessageBird Platform"
MBNum[Virtual Mobile Number]
MBFlow[Flow Builder]
MBAPI[MessageBird API]
end
subgraph "Next.js Application (Hosted on Vercel/Server)"
subgraph "Frontend (Client Components)"
UI[React UI / Chat Interface]
end
subgraph "Backend (Server Components / API Routes)"
AuthN[Auth.js /auth Route]
Webhook[/api/webhooks/messagebird]
SendAPI[/api/messages/send]
ServerComp[Server Components]
end
DB[(Prisma Client)] -- Interacts --> Database[PostgreSQL / SQLite]
end
User[End User Browser] <--> UI
UI -- Calls API --> SendAPI
UI -- Reads Session --> AuthN
User -- Auth Flow --> AuthN
UPhone -- Sends SMS --> MBNum
MBNum -- Triggers --> MBFlow
MBFlow -- Forwards POST --> Webhook
Webhook -- Stores Msg --> DB
Webhook -- Reads User --> DB
SendAPI -- Uses Session --> AuthN
SendAPI -- Sends via SDK --> MBAPI
SendAPI -- Stores Msg --> DB
MBAPI -- Sends SMS --> UPhone
ServerComp -- Reads Session --> AuthN
ServerComp -- Reads Data --> DB
AuthN -- Manages Auth Data --> DBPrerequisites:
- Node.js (v18 or later) and npm/yarn installed. (Node.js 18.x is the current LTS version as of 2025, with Node.js 20.x also available.)
- A MessageBird account with API credentials and a purchased virtual mobile number capable of receiving SMS.
- Access to a PostgreSQL database (or setup for SQLite).
- Basic understanding of JavaScript, React, Next.js, and REST APIs.
- A tool for exposing your local development server to the internet (e.g., Ngrok). (Note: Use Ngrok for local development testing only. Production deployments require a stable, publicly accessible URL for your webhook endpoint.)
1. Setting Up the Project
Initialize your Next.js project and install necessary dependencies.
-
Create Your Next.js App: Open your terminal and run:
bashnpx create-next-app@latest nextjs-messagebird-chat --typescript --tailwind --eslint --app --src-dir --use-npm --import-alias "@/*" cd nextjs-messagebird-chat--typescript,--tailwind,--eslint: Recommended for type safety, styling, and code quality.--app: Uses the App Router.--src-dir: Places code inside asrc/directory.--use-npm: Uses npm package manager.--import-alias "@/*": Sets up path aliases.
-
Install Dependencies:
bashnpm install next-auth @auth/prisma-adapter prisma @prisma/client messagebird bcryptjs npm install -D prisma @types/bcryptjsnext-auth: The current package name for Auth.js v5. (Installnext-auth@betaornext-auth@5.xto ensure v5 compatibility.)@auth/prisma-adapter: Adapter for Auth.js to use Prisma. (Note the @auth/ scope, updated from @next-auth/* in v5.)*prisma,@prisma/client: Prisma ORM and client library.messagebird: Official MessageBird Node.js SDK.bcryptjs: For password hashing (required for secure Credentials provider).prisma(dev dependency): For Prisma CLI commands (init, migrate, generate).@types/bcryptjs(dev dependency): TypeScript types for bcryptjs.
-
Initialize Prisma:
bashnpx prisma init --datasource-provider postgresql- This creates a
prismadirectory with aschema.prismafile and a.envfile at your project root. - For SQLite, change
postgresqltosqlite.
- This creates a
-
Configure Environment Variables: Open the
.envfile created by Prisma (or create.env.localfor local overrides, which Git ignores). Add these variables:dotenv# .env / .env.local # Database # Example for PostgreSQL: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public # Example for SQLite: file:./dev.db DATABASE_URL="YOUR_DATABASE_CONNECTION_STRING" # Auth.js (v5+) # Generate with: openssl rand -hex 32 AUTH_SECRET="YOUR_STRONG_AUTH_SECRET" # AUTH_URL replaces NEXTAUTH_URL in Auth.js v5+ AUTH_URL="http://localhost:3000" # Change for production! # MessageBird MESSAGEBIRD_API_KEY="YOUR_MESSAGEBIRD_LIVE_API_KEY" # Your virtual number purchased from MessageBird (E.164 format, e.g., +12025550149) MESSAGEBIRD_SENDER_ID="YOUR_MESSAGEBIRD_VIRTUAL_NUMBER" # Create a strong, unique secret for verifying webhook requests MESSAGEBIRD_WEBHOOK_SECRET="YOUR_STRONG_UNIQUE_WEBHOOK_SECRET"DATABASE_URL: Get this from your database provider (or set for local SQLite).AUTH_SECRET: Generate a strong secret usingopenssl rand -hex 32in your terminal. Crucial for session encryption.AUTH_URL: The base URL of your application. Essential for OAuth redirects and callbacks. Update this for production. (Note: Auth.js v5 uses AUTH_ prefix instead of NEXTAUTH_. Both are supported via automatic aliasing, but AUTH_ is preferred.)*MESSAGEBIRD_API_KEY: Find this in your MessageBird Dashboard under Developers > API access > Live API Key.MESSAGEBIRD_SENDER_ID: Your virtual mobile number purchased from MessageBird, in E.164 format (e.g.,+12223334444). This will be the "from" number for outbound messages.MESSAGEBIRD_WEBHOOK_SECRET: Crucial for security. Create a long, random, unpredictable string. Use this to verify that incoming webhook requests genuinely originate from MessageBird (or at least know the secret).
-
Project Structure: Your
src/directory will containapp/for routes and components, potentiallylib/for utilities (like Prisma client instance, MessageBird client), andcomponents/for shared UI elements. Theprisma/directory holds your schema and migrations. Configuration files (.env,next.config.js,tsconfig.json) live at the root. This structure promotes separation of concerns.
2. Database Schema and Data Layer (Prisma)
Define your database models for users, authentication, and messages.
-
Define Your Prisma Schema: Open
prisma/schema.prismaand define the models:prisma// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" // Or "sqlite", "mysql", "sqlserver" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? passwordHash String? // Store hashed password for Credentials provider // Add phone number – make it unique to associate messages correctly phoneNumber String? @unique accounts Account[] sessions Session[] messages Message[] // Relation to messages sent/received by this user createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Account { id String @id @default(cuid()) userId String type String provider String providerAccountId String refresh_token String? @db.Text access_token String? @db.Text expires_at Int? token_type String? scope String? id_token String? @db.Text session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) } model Session { id String @id @default(cuid()) sessionToken String @unique userId String expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model VerificationToken { identifier String token String @unique expires DateTime @@unique([identifier, token]) } // Model for storing SMS messages model Message { id String @id @default(cuid()) content String direction String // "inbound" or "outbound" senderNumber String // Phone number sending the message (E.164) recipientNumber String // Phone number receiving the message (E.164) messageBirdId String? // Optional: Store MessageBird message ID createdAt DateTime @default(now()) // Associate message with a User based on their phone number userId String? user User? @relation(fields: [userId], references: [id], onDelete: SetNull) // SetNull keeps messages if you delete a user @@index([userId, createdAt]) // Index for efficient fetching of user messages @@index([senderNumber]) // Index for webhook lookup }- Standard Auth.js models (
User,Account,Session,VerificationToken). - Added
passwordHash(String, nullable) to store the hashed password for the Credentials provider. - Added
phoneNumber(String, unique) to theUsermodel. - Added a
Messagemodel to store SMS details, includingdirection, sender/recipient numbers, and a relation to theUsermodel. TheuserIdis nullable and usesonDelete: SetNullso messages persist if you remove a user. - Added indexes for common lookups.
- Standard Auth.js models (
-
Apply Schema to Your Database: Run this command to create or update your database tables based on the schema:
bashnpx prisma db push- For initial development setup,
db pushis convenient. For production workflows with version control and team collaboration, usenpx prisma migrate dev(creates migration files) andnpx prisma migrate deploy(applies migrations in production). (Prisma 6.x best practice: Always use migrations for production databases to track schema changes.)
- For initial development setup,
-
Create Prisma Client Instance: Create a utility file to instantiate and export the Prisma client, ensuring only one instance exists.
typescript// src/lib/prisma.ts import { PrismaClient } from '@prisma/client'; declare global { // eslint-disable-next-line no-var var prisma: PrismaClient | undefined; } export const prisma = global.prisma || new PrismaClient(); if (process.env.NODE_ENV !== 'production') global.prisma = prisma; export default prisma;
3. Implementing Authentication (Auth.js)
Configure Auth.js to handle user sign-up, sign-in, and session management using the Credentials provider and Prisma adapter.
-
Configure Auth.js: Create your main Auth.js configuration file.
typescript// src/auth.ts import NextAuth from 'next-auth'; import { PrismaAdapter } from '@auth/prisma-adapter'; import Credentials from 'next-auth/providers/credentials'; import prisma from '@/lib/prisma'; import { compare } from 'bcryptjs'; // Use compare from bcryptjs import type { User } from '@prisma/client'; // Import User type from Prisma import type { DefaultSession } from 'next-auth'; // Import DefaultSession import type { JWT as NextAuthJWT } from 'next-auth/jwt'; // Import JWT type export const { handlers, signIn, signOut, auth } = NextAuth({ adapter: PrismaAdapter(prisma), providers: [ Credentials({ name: 'Credentials', credentials: { email: { label: 'Email', type: 'email', placeholder: 'user@example.com' }, password: { label: 'Password', type: 'password' }, }, async authorize(credentials): Promise<User | null> { // Validate credentials input if (!credentials?.email || !credentials.password) { console.error('Authorize error: Missing email or password'); return null; } const email = credentials.email as string; const password = credentials.password as string; // Find user by email const user = await prisma.user.findUnique({ where: { email: email }, }); if (!user) { console.error(`No user found with email: ${email}`); return null; // User not found } // !! CRITICAL SECURITY !! // Verify the provided password against the stored hash. // The user MUST have a passwordHash field populated during registration. if (!user.passwordHash) { console.error(`User ${email} has no password hash set. Cannot authenticate via credentials.`); return null; // Cannot authenticate without a stored hash } const isValidPassword = await compare(password, user.passwordHash); if (!isValidPassword) { console.error(`Invalid password for user: ${email}`); return null; // Passwords do not match } console.log(`Credentials valid for user: ${email}`); // Return the user object if authentication succeeds // Ensure this object includes fields needed by callbacks (like id, phoneNumber) // The Prisma User type should align if you use the adapter correctly. const authorizedUser: User = { id: user.id, name: user.name, email: user.email, emailVerified: user.emailVerified, image: user.image, phoneNumber: user.phoneNumber, // Include phoneNumber passwordHash: null, // Ensure passwordHash never returns createdAt: user.createdAt, updatedAt: user.updatedAt, }; return authorizedUser; }, }), // Add other providers like Google, GitHub etc. if needed // GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET! }), ], session: { strategy: 'jwt', // Recommended strategy, especially with Credentials }, callbacks: { // Include user ID and phone number in the JWT token async jwt({ token, user }) { if (user) { // user object is available on sign-in token.id = user.id; // Add custom properties from the User object returned by authorize/provider const prismaUser = user as User; // Cast to Prisma User type if (prismaUser.phoneNumber) { token.phoneNumber = prismaUser.phoneNumber; } } return token; }, // Include user ID and phone number in the session object from the JWT async session({ session, token }) { if (token && session.user) { session.user.id = token.id as string; if (token.phoneNumber) { session.user.phoneNumber = token.phoneNumber as string; } } return session; }, }, pages: { signIn: '/login', // Custom login page path // error: '/auth/error', // Optional: Custom error page // newUser: '/register' // Optional: Custom registration page }, // Add secret from .env secret: process.env.AUTH_SECRET, // Enable debug messages in development debug: process.env.NODE_ENV === 'development', }); // Add custom fields to the default Session User type & JWT type // Ensure these match the fields added in the jwt/session callbacks declare module 'next-auth' { interface Session { user: { id: string; phoneNumber?: string | null; // Add phoneNumber here } & DefaultSession['user']; // Keep default fields like name, email, image } // Extend the User type available in callbacks if needed (e.g., from authorize) // This should align with the Prisma User type or the object returned by authorize interface User { phoneNumber?: string | null; } } declare module 'next-auth/jwt' { interface JWT extends NextAuthJWT { // Extend the imported JWT type id: string; phoneNumber?: string | null; } }- Uses
PrismaAdapter. - Configures the
Credentialsprovider with proper password verification usingbcryptjs.compare. Assumes apasswordHashfield exists on theUsermodel and is populated during registration. - CRITICAL SECURITY WARNING: You MUST implement user registration separately, ensuring you hash passwords using
bcryptjs.hashbefore storing them in thepasswordHashfield. This guide doesn't detail the registration flow, but it's essential for the Credentials provider to function securely. - Uses
jwtsession strategy. - Includes
callbacksto add the user'sidandphoneNumberto the JWT and session object. - Defines a custom login page path (
/login). - Extends the
Session,User, andJWTtypes to includephoneNumber.
- Uses
-
Create API Route Handlers: Set up the catch-all API route for Auth.js.
typescript// src/app/api/auth/[...nextauth]/route.ts export { GET, POST } from '@/auth'; // export const runtime = "edge"; // Optional: Use Edge Runtime if preferred and compatible -
Create Your Login Page: Build a simple login page component.
typescript// src/app/login/page.tsx 'use client'; import { useState } from 'react'; import { signIn } from 'next-auth/react'; import { useRouter, useSearchParams } from 'next/navigation'; export default function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState<string | null>(null); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); const searchParams = useSearchParams(); const callbackUrl = searchParams.get('callbackUrl') || '/dashboard'; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); setIsLoading(true); try { const result = await signIn('credentials', { redirect: false, // Handle redirect manually email, password, callbackUrl: callbackUrl }); setIsLoading(false); if (result?.error) { // Map common errors to user-friendly messages switch (result.error) { case 'CredentialsSignin': setError('Invalid email or password. Try again.'); break; default: setError('Login failed. Try again later.'); } console.error('Sign in error:', result.error); } else if (result?.ok && result.url) { // Sign in successful, use the returned URL (includes callbackUrl) router.push(result.url); } else if (result?.ok) { // Fallback if URL is missing but sign-in was ok router.push(callbackUrl); } else { setError('An unexpected error occurred during sign in.'); } } catch (err) { setIsLoading(false); console.error('Sign in exception:', err); setError('An error occurred. Try again later.'); } }; return ( <div className="max-w-md mx-auto mt-10 p-6 border border-gray-300 rounded-lg shadow-md"> <h1 className="text-2xl font-bold mb-6 text-center">Login</h1> <form onSubmit={handleSubmit}> {error && <p className="mb-4 text-red-600 text-sm text-center">{error}</p>} <div className="mb-4"> <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">Email:</label> <input type="email" id="email" value={email} onChange={(e) => setEmail(e.target.value)} required disabled={isLoading} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="user@example.com" /> </div> <div className="mb-6"> <label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">Password:</label> <input type="password" id="password" value={password} onChange={(e) => setPassword(e.target.value)} required disabled={isLoading} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" /> </div> <button type="submit" disabled={isLoading} className="w-full py-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-md shadow-sm disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > {isLoading ? 'Logging in…' : 'Login'} </button> </form> </div> ); }- Uses the
signInfunction fromnext-auth/react. - Handles submission, calls the
credentialsprovider, manages error display, and redirects usingnext/navigation. - Includes loading state feedback.
- Registration: This form assumes you pre-register users. You need a separate process (e.g., a registration page) to create users, hash their passwords using bcryptjs, and store the hash in the
passwordHashfield.
- Uses the
-
Wrap Your App in SessionProvider: To use client-side hooks like
useSession, wrap your application layout.typescript// src/app/providers.tsx (Create this file) 'use client'; import { SessionProvider } from 'next-auth/react'; import React from 'react'; interface ProvidersProps { children: React.ReactNode; } export function Providers({ children }: ProvidersProps) { return <SessionProvider>{children}</SessionProvider>; }typescript// src/app/layout.tsx import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; // Includes Tailwind base styles import { Providers } from './providers'; const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { title: 'Next.js MessageBird Chat', description: 'Two-way SMS with Auth.js and MessageBird', }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body className={`${inter.className} bg-gray-50`}> <Providers> <main>{children}</main> </Providers> </body> </html> ); } -
Access Session Data:
- Server Components / API Routes / RSC: Use the
auth()helper exported fromsrc/auth.ts.typescript// Example in a Server Component (e.g., src/app/dashboard/page.tsx) import { auth } from '@/auth'; import { redirect } from 'next/navigation'; export default async function DashboardPage() { const session = await auth(); // Get session on the server if (!session?.user) { // Redirect to login, preserving the intended destination redirect(`/login?callbackUrl=/dashboard`); } return ( <div className="p-4 md:p-8"> <h1 className="text-2xl font-semibold mb-4">Dashboard</h1> <p className="mb-2">Welcome, {session.user.name ?? session.user.email ?? 'User'}!</p> <p className="mb-2">Your Email: {session.user.email}</p> <p className="mb-4">Your Phone Number: {session.user.phoneNumber ?? 'Not set'}</p> {/* Add Chat Interface Component Here (conditionally based on phone number) */} </div> ); } - Client Components: Use the
useSessionhook fromnext-auth/react.typescript// Example in a Client Component (e.g., src/components/UserProfile.tsx) 'use client'; import { useSession, signOut } from 'next-auth/react'; import Link from 'next/link'; import Image from 'next/image'; export function UserProfile() { const { data: session, status } = useSession(); // status: 'loading' | 'authenticated' | 'unauthenticated' if (status === 'loading') { return <p className="text-sm text-gray-500">Loading session…</p>; } if (status === 'unauthenticated') { return ( <Link href="/login" className="text-sm text-blue-600 hover:underline"> Sign In </Link> ); } // status === 'authenticated' return ( <div className="flex items-center space-x-4 p-2 bg-white rounded shadow"> {session?.user?.image && ( <Image src={session.user.image} alt="User avatar" width={32} height={32} className="w-8 h-8 rounded-full" /> )} <div className="text-sm"> <p>Signed in as <span className="font-medium">{session?.user?.email}</span></p> <p>Phone: {session?.user?.phoneNumber ?? 'N/A'}</p> </div> <button onClick={() => signOut({ callbackUrl: '/' })} className="ml-auto px-3 py-1 text-sm bg-red-500 hover:bg-red-600 text-white rounded" > Sign Out </button> </div> ); }
- Server Components / API Routes / RSC: Use the
4. Integrating MessageBird for Inbound SMS (Webhooks)
Set up your webhook endpoint to receive incoming SMS messages from MessageBird.
-
Create MessageBird Client Instance: Similar to Prisma, create a utility for your MessageBird client.
typescript// src/lib/messagebird.ts import initMessageBird from 'messagebird'; const apiKey = process.env.MESSAGEBIRD_API_KEY; if (!apiKey) { // Log a warning in development, but potentially throw in production if critical if (process.env.NODE_ENV === 'development') { console.warn('MessageBird API Key (MESSAGEBIRD_API_KEY) not found in environment variables. MessageBird functionality will be disabled.'); } else { // In production, this might be a fatal error depending on your requirements console.error('CRITICAL: Missing MESSAGEBIRD_API_KEY environment variable.'); // throw new Error('Missing MESSAGEBIRD_API_KEY environment variable'); } } // Initialize only if apiKey exists, otherwise export null export const messagebird = apiKey ? initMessageBird(apiKey as string) : null; // Export a function to get the client to handle the null case more gracefully elsewhere export const getMessageBirdClient = () => { if (!messagebird) { console.error("Attempted to use MessageBird client, but it's not initialized (missing API key)."); // throw new Error("MessageBird client is not available."); } return messagebird; } export default messagebird; -
Create Your Webhook API Route: This route listens for POST requests from MessageBird's Flow Builder.
typescript// src/app/api/webhooks/messagebird/route.ts import { NextRequest, NextResponse } from 'next/server'; import prisma from '@/lib/prisma'; import crypto from 'crypto'; // For constant-time comparison // Secure Webhook Secret Verification (Constant Time Comparison) // SECURITY BEST PRACTICE: Always verify webhook authenticity to prevent unauthorized access function verifyWebhookSecret(request: NextRequest): boolean { const providedSecret = request.nextUrl.searchParams.get('secret'); const expectedSecret = process.env.MESSAGEBIRD_WEBHOOK_SECRET; if (!expectedSecret) { console.error('[Webhook Security] MESSAGEBIRD_WEBHOOK_SECRET is not set in environment. Cannot verify webhook.'); return false; // Fail safe } if (!providedSecret) { console.warn('[Webhook Security] Webhook request missing required "secret" query parameter.'); return false; } // Use crypto.timingSafeEqual for constant-time comparison to prevent timing attacks try { const providedBuffer = Buffer.from(providedSecret); const expectedBuffer = Buffer.from(expectedSecret); // Ensure buffers are the same length before comparing if (providedBuffer.length !== expectedBuffer.length) { console.warn('[Webhook Security] Provided secret length mismatch.'); // Still perform a comparison with the expected buffer to ensure constant time crypto.timingSafeEqual(expectedBuffer, expectedBuffer); return false; } const isEqual = crypto.timingSafeEqual(providedBuffer, expectedBuffer); if (!isEqual) { console.warn('[Webhook Security] Invalid webhook secret provided.'); } return isEqual; } catch (error) { console.error('[Webhook Security] Error during secret comparison:', error); return false; } } // Define the expected structure of the incoming webhook payload // Adjust based on what your MessageBird Flow Builder actually sends interface MessageBirdWebhookPayload { originator: string; // Sender's phone number (E.164) recipient: string; // Your MessageBird virtual number (E.164) payload: string; // The SMS message content // Add other fields you might send from Flow Builder messageId?: string; // Optional: MessageBird's internal ID receivedAt?: string; // Optional: Timestamp from MessageBird } export async function POST(request: NextRequest) { console.log('[Webhook] Received request'); // 1. Verify the webhook secret if (!verifyWebhookSecret(request)) { console.error('[Webhook] Failed webhook secret verification.'); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } console.log('[Webhook] Secret verified successfully.'); try { // 2. Parse the incoming JSON payload const payload: MessageBirdWebhookPayload = await request.json(); console.log('[Webhook] Payload received:', payload); // Basic validation of required fields if (!payload.originator || !payload.recipient || !payload.payload) { console.error('[Webhook] Invalid payload structure. Missing required fields.'); return NextResponse.json({ error: 'Bad Request: Missing required fields' }, { status: 400 }); } const senderNumber = payload.originator; // The external user's number const virtualNumber = payload.recipient; // Your app's number const content = payload.payload; const messageBirdId = payload.messageId; // Optional // 3. Find the corresponding user in your database // Use the SENDER's number (originator) to find the user who should receive this message in the app // This assumes you store the user's phone number in E.164 format and it's unique const user = await prisma.user.findUnique({ where: { phoneNumber: senderNumber }, }); if (!user) { // Decide how to handle messages from unknown numbers // Option 1: Ignore and log console.warn(`[Webhook] Received message from unknown number: ${senderNumber}. Ignoring.`); // Option 2: Store message without associating user (set userId to null) // await prisma.message.create({ … data with userId: null … }); // Option 3: Trigger an alert or specific flow return NextResponse.json({ message: 'User not found for this number' }, { status: 200 }); // Acknowledge receipt but indicate no user match } console.log(`[Webhook] Found user ${user.id} associated with number ${senderNumber}`); // 4. Store the inbound message in your database const newMessage = await prisma.message.create({ data: { content: content, direction: 'inbound', senderNumber: senderNumber, // From the external user recipientNumber: virtualNumber, // To your virtual number messageBirdId: messageBirdId, // Optional MessageBird ID userId: user.id, // Associate with the found user }, }); console.log(`[Webhook] Stored inbound message ${newMessage.id} for user ${user.id}`); // 5. (Optional) Trigger real-time updates (e.g., via WebSockets/Pusher/Server-Sent Events) // This would notify the user's frontend that a new message has arrived. // Example: await triggerRealtimeUpdate(user.id, newMessage); // 6. Respond to MessageBird to acknowledge receipt // A 2xx status code tells MessageBird the webhook was received successfully. return NextResponse.json({ message: 'Webhook received successfully' }, { status: 200 }); } catch (error) { console.error('[Webhook] Error processing webhook:', error); // Handle JSON parsing errors specifically if (error instanceof SyntaxError) { return NextResponse.json({ error: 'Bad Request: Invalid JSON payload' }, { status: 400 }); } // Generic internal server error for other issues return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); } }
Frequently Asked Questions
How to set up two-way SMS in Next.js?
Set up two-way SMS by integrating Auth.js for user authentication, MessageBird for SMS communication, and Prisma for database management. This involves configuring API keys, setting up webhooks, and implementing server-side logic to handle incoming and outgoing messages within your Next.js application.
What is MessageBird used for in this project?
MessageBird is the SMS API provider, enabling sending and receiving text messages. It provides a virtual mobile number to receive inbound messages and an API to send outbound replies from your Next.js application.
Why use Auth.js with MessageBird integration?
Auth.js (v5 or later - `next-auth`) provides secure user authentication, linking SMS conversations with specific user accounts. This is crucial for personalized notifications, in-app support, and other scenarios requiring user-specific SMS interactions.
When should I use Ngrok with MessageBird?
Ngrok is primarily for local development to expose your webhook endpoint during testing. For production deployments, use a stable, publicly accessible URL for your webhook.
Can I use a database other than PostgreSQL?
Yes, Prisma supports various SQL databases like PostgreSQL, SQLite, MySQL, and SQL Server. Configure the `provider` and `DATABASE_URL` in your `prisma/schema.prisma` file accordingly.
How to handle inbound SMS messages in Next.js?
Create a dedicated API route (`/api/webhooks/messagebird`) to receive webhook requests from MessageBird. Verify the webhook secret, parse the message content, identify the user based on the sender's number, and store the message in the database using Prisma.
What is the purpose of the MessageBird webhook secret?
The webhook secret is crucial for security. It verifies that incoming webhook requests genuinely originate from MessageBird, preventing unauthorized access to your application's data and functionality.
How to send outbound SMS replies with MessageBird?
Use the MessageBird API via their Node.js SDK. Within your application, retrieve the recipient's number and the message content, and then use the MessageBird client to send the SMS message. Remember to store the outbound message in your database as well for record-keeping.
What Next.js version is required for this tutorial?
The tutorial requires Next.js v14 or later with the App Router enabled. The App Router is chosen for its hybrid rendering capabilities, improved routing, and overall developer experience.
How to associate SMS messages with users?
Use the sender's phone number (`originator` in the webhook payload) to look up the corresponding user in your database. This assumes the user's phone number is stored in the `phoneNumber` field (unique) in the `User` model in your Prisma schema.
What is Prisma, and why is it used?
Prisma is a next-generation ORM (Object-Relational Mapper) that simplifies database interactions. It provides type safety, schema management, and increased developer productivity when working with databases like PostgreSQL or SQLite.
How to configure Auth.js Credentials provider securely?
Implement a separate registration process to collect user credentials, **hash passwords using bcryptjs**, and store only the hash in the `passwordHash` field of the `User` model. **Never** store plain-text passwords. In your Auth.js configuration, **always use `bcryptjs.compare` to verify submitted passwords against the stored hash**.
What is the purpose of the `AUTH_SECRET` environment variable?
The `AUTH_SECRET` is essential for session encryption in Auth.js. Generate a strong, random secret using `openssl rand -hex 32` and store it securely in your `.env` file. Never expose this secret publicly.