code examples
code examples
Twilio SMS Delivery Status Tracking with Next.js, NextAuth, and MongoDB
Build a production-ready SMS delivery tracking system using Twilio webhooks, Next.js 15 App Router, NextAuth.js v5, and MongoDB. Complete guide with authentication, status callbacks, and real-time updates.
Twilio SMS Delivery Status Tracking with Next.js, NextAuth, and MongoDB
Track SMS delivery status in real-time using Twilio webhooks, Next.js 15 App Router, and MongoDB. This comprehensive tutorial shows you how to build a production-ready SMS tracking system with authenticated user sessions, secure webhook callbacks, and persistent message logging. Learn to implement Twilio status callbacks with signature validation, handle delivery confirmations, and update message statuses automatically when SMS messages are queued, sent, delivered, or fail.
Building applications that interact with users via SMS often requires knowing whether your messages actually reached their destination. Relying solely on sending a message isn't enough; confirming delivery provides crucial feedback for user experience and system reliability.
This guide details how to build a robust system within a Next.js application to send SMS messages via Twilio and receive real-time delivery status updates using Twilio's webhook callbacks. We'll leverage NextAuth.js for user authentication and session management, and MongoDB for storing message logs and statuses.
By the end of this guide, you will have:
- A Next.js application with user authentication.
- Functionality to send SMS messages via Twilio from authenticated user sessions.
- A database schema to log sent messages and their statuses.
- A secure API endpoint to receive status callbacks from Twilio.
- Logic to update message statuses in your database based on Twilio callbacks.
- A basic UI to demonstrate sending messages and viewing their status.
Technologies Used:
- Next.js 15.5 (App Router): A React framework for building server-rendered and static web applications. We'll use the App Router for modern routing and API handling. Latest stable version with React 19 support and Turbopack improvements.
- NextAuth.js v5 (Auth.js): Handles authentication and session management, ensuring only logged-in users can send messages. Now branded as Auth.js with universal auth() method and App Router-first design.
- Twilio Node.js SDK v5: A communication platform as a service (CPaaS) used for sending SMS and providing status webhooks with built-in webhook validation support.
- MongoDB with Mongoose: A NoSQL database for storing user data and message logs. We use Mongoose as the Object Data Modeling (ODM) library for schema definition and interaction with MongoDB.
- Tailwind CSS: For basic styling.
- Node.js 22.x LTS: The runtime environment. Currently in Active LTS until October 2025, then Maintenance LTS until April 2027.
System Architecture:
+-----------------+ +--------------------+ +-----------------+ +----------------------+ +-----------------+
| User Browser | --> | Next.js Frontend | --> | Next.js API Route | --> | Twilio SMS API | --> | Recipient Phone |
| (Authenticated) | | (Send SMS UI) | | (/api/send-sms) | | | +-----------------+
+-----------------+ +--------------------+ +--------+--------+ +----------+-----------+
| | |
| | Stores Initial | Sends Status Update
| | Message Log | (Webhook POST)
| v v
| +---------+---------+ +--------+-------------+
| | MongoDB | <-- | Next.js API Route |
| | (Message Status) | | (/api/sms-status) |
| +-------------------+ +----------------------+
| ^
| | Verifies Request
| |
+-----------------------------------------------------------------------+
(Session Check)Prerequisites:
- Node.js (LTS version recommended) and npm/yarn installed.
- A Twilio account with an active phone number capable of sending SMS. Find your Account SID and Auth Token in the Twilio Console.
- A MongoDB database instance (e.g., MongoDB Atlas free tier or local installation) and its connection string.
- Familiarity with JavaScript, React, and Next.js concepts.
ngrokor a similar tunneling service (primarily for testing webhooks locally before deployment).
1. Project Setup
Let's initialize our Next.js project and install necessary dependencies.
1.1 Create Next.js App
Open your terminal and run:
npx create-next-app@latest sms-status-trackerFollow the prompts:
Would you like to use TypeScript?No (This guide uses JavaScript)Would you like to use ESLint?YesWould you like to use Tailwind CSS?YesWould you like to use \src/` directory?` No (or Yes, adjust paths accordingly)Would you like to use App Router? (recommended)YesWould you like to customize the default import alias?No
1.2 Navigate and Install Dependencies
cd sms-status-tracker
npm install next-auth @auth/mongodb-adapter mongodb mongoose twilionext-auth: Core library for authentication.@auth/mongodb-adapter: NextAuth.js adapter for MongoDB.mongodb: The native MongoDB driver (required by the adapter).mongoose: ODM for interacting with MongoDB more easily (optional but recommended for schema definition).twilio: The official Twilio Node.js helper library.
1.3 Configure Environment Variables
Create a file named .env.local in the root of your project. Never commit this file to version control.
# .env.local
# Twilio Credentials (https://console.twilio.com/)
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token_here # KEEP THIS SECRET
TWILIO_PHONE_NUMBER=+15551234567 # Your active Twilio phone number
# MongoDB Connection String (e.g., from MongoDB Atlas)
DATABASE_URL=mongodb+srv://<username>:<password>@<cluster-url>/<database-name>?retryWrites=true&w=majority
# NextAuth.js Configuration
# Generate strong secrets: `openssl rand -base64 32`
NEXTAUTH_SECRET=your_super_secret_nextauth_value_here # Required for production
NEXTAUTH_URL=http://localhost:3000 # Development URL, change for production
# Twilio Webhook Security (Use TWILIO_AUTH_TOKEN by default, or generate a specific secret)
# Using AUTH_TOKEN is simpler but less isolated. For higher security, set up specific webhook signing keys in Twilio.
TWILIO_WEBHOOK_SECRET=your_auth_token_here # Or a dedicated webhook signing secret- Obtaining Credentials:
TWILIO_ACCOUNT_SIDandTWILIO_AUTH_TOKEN: Found on your main Twilio Console dashboard.TWILIO_PHONE_NUMBER: One of your active numbers from the Twilio Phone Numbers section.DATABASE_URL: Provided by your MongoDB hosting service (like Atlas) when you create a cluster and user. Ensure the user has read/write permissions to the specified database.NEXTAUTH_SECRET: Generate a strong, unique secret usingopenssl rand -base64 32in your terminal.NEXTAUTH_URL: The base URL of your application. Usehttp://localhost:3000for local development. Update this when deploying.TWILIO_WEBHOOK_SECRET: For validating incoming webhook requests. By default, Twilio uses yourTWILIO_AUTH_TOKEN. You can keep it like that for simplicity or configure specific webhook signing keys in Twilio for enhanced security and use that key here.
1.4 Project Structure
Your initial relevant structure should look like this:
sms-status-tracker/
├── app/
│ ├── api/
│ │ ├── auth/
│ │ │ └── [...nextauth]/
│ │ │ └── route.js # NextAuth.js handler
│ │ ├── send-sms/
│ │ │ └── route.js # API to send SMS
│ │ └── sms-status/
│ │ └── route.js # API to receive Twilio callbacks
│ ├── components/
│ │ └── Provider.js # Session Provider wrapper
│ ├── lib/
│ │ ├── mongodb.js # MongoDB client promise setup
│ │ └── mongoose.js # Mongoose connection setup (optional)
│ ├── models/
│ │ └── Message.js # Mongoose schema for messages
│ ├── dashboard/
│ │ └── page.js # Simple UI to send SMS
│ ├── auth/ # Optional: Custom auth pages dir
│ │ ├── signin/
│ │ │ └── page.js
│ │ └── verify-request/
│ │ └── page.js
│ ├── layout.js # Root layout
│ └── page.js # Home page
├── .env.local # Environment variables (DO NOT COMMIT)
├── next.config.js
├── package.json
└── tailwind.config.jsWe will create the missing files and folders as we proceed.
2. Authentication Setup (NextAuth.js)
We need a basic authentication system so only logged-in users can send messages. We'll use the Email provider for simplicity.
2.1 MongoDB Client Setup
Create app/lib/mongodb.js for managing the MongoDB connection promise, essential for the adapter.
// app/lib/mongodb.js
import { MongoClient } from 'mongodb';
if (!process.env.DATABASE_URL) {
throw new Error('Invalid/Missing environment variable: `DATABASE_URL`');
}
const uri = process.env.DATABASE_URL;
const options = {};
let client;
let clientPromise;
if (process.env.NODE_ENV === 'development') {
// In development mode, use a global variable so that the value
// is preserved across module reloads caused by HMR (Hot Module Replacement).
if (!global._mongoClientPromise) {
client = new MongoClient(uri, options);
global._mongoClientPromise = client.connect();
}
clientPromise = global._mongoClientPromise;
} else {
// In production mode, it's best to not use a global variable.
client = new MongoClient(uri, options);
clientPromise = client.connect();
}
// Export a module-scoped MongoClient promise. By doing this in a
// separate module, the client can be shared across functions.
export default clientPromise;2.2 NextAuth.js Configuration
Create the NextAuth.js API route handler at app/api/auth/[...nextauth]/route.js.
// app/api/auth/[...nextauth]/route.js
import NextAuth from 'next-auth';
import EmailProvider from 'next-auth/providers/email';
import { MongoDBAdapter } from '@auth/mongodb-adapter';
import clientPromise from '@/app/lib/mongodb'; // Use alias if configured, else adjust path
export const authOptions = {
adapter: MongoDBAdapter(clientPromise),
providers: [
EmailProvider({
// Configure your email sending service here (e.g., SendGrid, Resend)
// This setup requires an email provider like SendGrid, Nodemailer, etc.
// For simplicity in this guide, we assume setup, but ensure this works.
// Example using environment variables for a generic SMTP server:
server: {
host: process.env.EMAIL_SERVER_HOST,
port: process.env.EMAIL_SERVER_PORT,
auth: {
user: process.env.EMAIL_SERVER_USER,
pass: process.env.EMAIL_SERVER_PASSWORD,
},
},
from: process.env.EMAIL_FROM,
}),
// Add other providers like Google, GitHub, etc. if needed
],
pages: {
signIn: '/auth/signin', // Custom sign-in page (optional)
verifyRequest: '/auth/verify-request', // Page shown after email sent (optional)
// error: '/auth/error', // Error code passed in query string as ?error=
},
session: {
strategy: 'jwt', // Use JWT for session strategy
},
secret: process.env.NEXTAUTH_SECRET, // Use the secret from .env.local
// Add callbacks if needed for customizing session/token data
callbacks: {
async session({ session, token, user }) {
// Send properties to the client, like an access_token and user id from a provider.
if (token) {
session.userId = token.sub // Add user ID to session
}
return session
}
}
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };- Note: You need to configure the
EmailProviderwith actual email sending credentials (like SendGrid API key, SMTP details) in your.env.localfile for magic link emails to work. This setup is shown conceptually; refer to NextAuth.js and your email provider's documentation for details. For this guide's core focus (SMS status), having any working authentication method (Email, Google, Credentials, etc.) is sufficient.
2.3 Session Provider Setup
NextAuth.js requires a <SessionProvider> at the root of your application to manage session state client-side.
Create app/components/Provider.js:
// app/components/Provider.js
'use client';
import { SessionProvider } from 'next-auth/react';
const Provider = ({ children, session }) => {
return <SessionProvider session={session}>{children}</SessionProvider>;
};
export default Provider;Update your root layout app/layout.js:
// app/layout.js
import './globals.css';
import { Inter } from 'next/font/google';
import Provider from './components/Provider'; // Adjust path if needed
const inter = Inter({ subsets: ['latin'] });
export const metadata = {
title: 'SMS Status Tracker',
description: 'Track Twilio SMS delivery status with Next.js',
};
export default function RootLayout({ children }) {
return (
<html lang=""en"">
<body className={inter.className}>
<Provider> {/* Wrap children with the Session Provider */}
{children}
</Provider>
</body>
</html>
);
}2.4 Basic Sign-in UI (Optional but Recommended)
Create simple sign-in/out buttons. Update app/page.js:
// app/page.js
'use client';
import { useSession, signIn, signOut } from 'next-auth/react';
import Link from 'next/link';
export default function Home() {
const { data: session, status } = useSession();
return (
<main className=""flex min-h-screen flex-col items-center justify-center p-24"">
<h1 className=""text-4xl font-bold mb-8"">SMS Status Tracker</h1>
{status === 'loading' && <p>Loading...</p>}
{status === 'authenticated' && session && (
<div className=""text-center"">
<p className=""mb-4"">Signed in as {session.user?.email}</p>
<Link
href=""/dashboard""
className=""bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-2""
>
Go to Dashboard
</Link>
<button
onClick={() => signOut()}
className=""bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded""
>
Sign Out
</button>
</div>
)}
{status === 'unauthenticated' && (
<button
// Point to the specific provider or the default sign-in page
onClick={() => signIn('email')} // Or just signIn() for default page
className=""bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded""
>
Sign In
</button>
// If using Email provider, you might need an input field here or redirect to pages.signIn
)}
</main>
);
}Create a basic sign-in page app/auth/signin/page.js if using the pages.signIn option:
// app/auth/signin/page.js
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
export default function SignIn() {
const [email, setEmail] = useState('');
const handleSignIn = (e) => {
e.preventDefault();
// Redirect to the verify request page after email is sent
signIn('email', { email, callbackUrl: '/auth/verify-request' });
};
return (
<div className=""flex justify-center items-center min-h-screen"">
<form
onSubmit={handleSignIn}
className=""bg-white p-8 rounded shadow-md w-full max-w-sm""
>
<h2 className=""text-2xl font-bold mb-6 text-center text-gray-800"">Sign In / Sign Up</h2>
<p className=""text-center text-gray-600 mb-6"">
Enter your email to receive a magic link.
</p>
<div className=""mb-4"">
<label htmlFor=""email"" className=""block text-gray-700 text-sm font-bold mb-2"">
Email
</label>
<input
type=""email""
id=""email""
name=""email""
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className=""shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline""
placeholder=""you@example.com""
/>
</div>
<button
type=""submit""
className=""bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline w-full""
>
Send Magic Link
</button>
</form>
</div>
);
}And a verification request page app/auth/verify-request/page.js:
// app/auth/verify-request/page.js
export default function VerifyRequest() {
return (
<div className=""flex justify-center items-center min-h-screen"">
<div className=""bg-white p-8 rounded shadow-md text-center"">
<h2 className=""text-2xl font-bold mb-4 text-gray-800"">Check your email</h2>
<p className=""text-gray-600"">
A sign in link has been sent to your email address. Click the link to sign in.
</p>
</div>
</div>
);
}Now you should have a functional authentication flow.
3. Database Schema for Messages
We need a way to store information about the SMS messages we send, including their status.
3.1 Mongoose Connection (Optional but helpful)
Create app/lib/mongoose.js to manage the Mongoose connection.
// app/lib/mongoose.js
import mongoose from 'mongoose';
const MONGODB_URI = process.env.DATABASE_URL;
if (!MONGODB_URI) {
throw new Error(
'Please define the DATABASE_URL environment variable inside .env.local'
);
}
/**
* Global is used here to maintain a cached connection across hot reloads
* in development. This prevents connections growing exponentially
* during API Route usage.
*/
let cached = global.mongoose;
if (!cached) {
cached = global.mongoose = { conn: null, promise: null };
}
async function dbConnect() {
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
const opts = {
bufferCommands: false,
};
cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
return mongoose;
});
}
cached.conn = await cached.promise;
console.log(""Mongoose Connected!""); // Log successful connection
return cached.conn;
}
export default dbConnect;3.2 Message Model
Create the Mongoose schema in app/models/Message.js.
// app/models/Message.js
import mongoose from 'mongoose';
const MessageSchema = new mongoose.Schema(
{
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User', // Reference to the User model created by NextAuth.js adapter
required: true,
},
to: {
type: String,
required: [true, 'Recipient phone number is required.'],
trim: true,
},
body: {
type: String,
required: [true, 'Message body is required.'],
trim: true,
},
twilioSid: {
// The unique ID returned by Twilio upon sending
type: String,
required: true,
unique: true,
index: true, // Index for faster lookups by SID
},
status: {
// Status from Twilio (e.g., 'queued', 'sent', 'delivered', 'failed', 'undelivered')
type: String,
required: true,
default: 'queued', // Initial status
enum: ['queued', 'sending', 'sent', 'delivered', 'undelivered', 'failed', 'received'], // Add more as needed
},
errorCode: {
// Twilio error code if status is 'failed' or 'undelivered'
type: Number,
required: false,
},
errorMessage: {
// Twilio error message
type: String,
required: false,
}
},
{
timestamps: true, // Automatically add createdAt and updatedAt
}
);
// Avoid recompiling the model if it already exists
export default mongoose.models.Message || mongoose.model('Message', MessageSchema);This schema stores essential details: who sent it (userId), the recipient (to), the content (body), the unique Twilio Message SID (twilioSid) for correlation, the delivery status, and timestamps. We also added fields for potential error details.
4. Implementing SMS Sending
Let's create the API endpoint and UI to send SMS messages.
4.1 API Route: Send SMS
Create app/api/send-sms/route.js.
// app/api/send-sms/route.js
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/app/api/auth/[...nextauth]/route'; // Adjust path
import dbConnect from '@/app/lib/mongoose'; // Adjust path
import Message from '@/app/models/Message'; // Adjust path
import twilio from 'twilio';
// Initialize Twilio client
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;
const twilioPhoneNumber = process.env.TWILIO_PHONE_NUMBER;
const client = twilio(accountSid, authToken);
export async function POST(request) {
// 1. Check Authentication
const session = await getServerSession(authOptions);
if (!session || !session.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 2. Parse Request Body
let recipient;
let messageBody;
try {
const body = await request.json();
recipient = body.to;
messageBody = body.body;
// Basic Input Validation
if (!recipient || typeof recipient !== 'string' || !recipient.trim()) {
return NextResponse.json({ error: 'Recipient phone number (`to`) is required.' }, { status: 400 });
}
if (!messageBody || typeof messageBody !== 'string' || !messageBody.trim()) {
return NextResponse.json({ error: 'Message body (`body`) is required.' }, { status: 400 });
}
// Consider more robust phone number validation (e.g., using a library)
// Format recipient number to E.164 if necessary (Twilio prefers this)
// Example basic check:
if (!/^\+?[1-9]\d{1,14}$/.test(recipient)) {
return NextResponse.json({ error: 'Invalid recipient phone number format.' }, { status: 400 });
}
} catch (error) {
return NextResponse.json({ error: 'Invalid request body.' }, { status: 400 });
}
// 3. Construct Status Callback URL
// IMPORTANT: Use your production URL or ngrok URL here during development
const statusCallbackUrl = `${process.env.NEXTAUTH_URL}/api/sms-status`;
// 4. Send SMS via Twilio & Log to DB
try {
await dbConnect(); // Ensure DB connection
const message = await client.messages.create({
body: messageBody,
from: twilioPhoneNumber,
to: recipient,
statusCallback: statusCallbackUrl, // Tell Twilio where to POST status updates
statusCallbackMethod: 'POST', // Use POST method
});
console.log(`Twilio message sent: SID ${message.sid}, Status: ${message.status}`);
// 5. Store Message Log in MongoDB
const newMessageLog = new Message({
userId: session.userId,
to: recipient,
body: messageBody,
twilioSid: message.sid, // Store the SID from Twilio's response
status: message.status, // Initial status from Twilio (e.g., 'queued', 'sending')
});
await newMessageLog.save();
console.log(`Message log created in DB for SID: ${message.sid}`);
return NextResponse.json(
{ success: true, messageSid: message.sid, status: message.status },
{ status: 201 }
);
} catch (error) {
console.error('Error sending SMS or saving log:', error);
// Handle specific Twilio errors if needed
const errorMessage = error.message || 'Failed to send SMS.';
let statusCode = 500;
if (error.status) {
statusCode = error.status; // Use status code from Twilio error if available
}
// Attempt to log the failure if possible (optional)
try {
await dbConnect(); // Ensure connection for logging failure
const failedLog = new Message({
userId: session.userId,
to: recipient,
body: messageBody,
twilioSid: `failed_${Date.now()}`, // Generate a temporary SID for failed attempts
status: 'failed',
errorMessage: errorMessage,
errorCode: error.code // Store Twilio error code if available
});
await failedLog.save();
} catch (dbError) {
console.error(""Failed to save error log to DB:"", dbError);
}
return NextResponse.json({ error: errorMessage, code: error.code }, { status: statusCode });
}
}Explanation:
- Authentication: Uses
getServerSessionto ensure only logged-in users can access this endpoint. - Input Parsing & Validation: Reads the
to(recipient phone number) andbody(message content) from the request. Includes basic validation. - Status Callback URL: Constructs the absolute URL pointing to our
sms-statusAPI route. Crucially, this must be a publicly accessible URL. For local development, use yourngrokURL. For production, use your deployed application's URL. - Twilio Interaction: Initializes the Twilio client and calls
client.messages.create. We pass thestatusCallbackURL here. - Database Logging: Connects to MongoDB using
dbConnectand creates a newMessagedocument using theMessagemodel, storing thetwilioSidand initial status provided by Twilio. - Error Handling: Includes
try...catchblocks to handle potential errors during Twilio API calls or database operations, returning appropriate error responses. It attempts to log failed attempts as well.
4.2 Frontend UI: Send SMS Form
Create a simple dashboard page at app/dashboard/page.js where users can send messages.
// app/dashboard/page.js
'use client';
import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation'; // Use next/navigation for App Router
export default function Dashboard() {
const { data: session, status } = useSession();
const router = useRouter();
const [recipient, setRecipient] = useState('');
const [messageBody, setMessageBody] = useState('');
const [isSending, setIsSending] = useState(false);
const [feedback, setFeedback] = useState({ type: '', message: '' });
const [messages, setMessages] = useState([]); // State to hold sent messages
// Redirect if not authenticated
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/'); // Redirect to home/login page
}
}, [status, router]);
// Function to fetch messages (using dummy data for this guide)
const fetchMessages = () => {
// In a real application, you would fetch this from an API endpoint
// that retrieves messages for the logged-in user from the database.
console.log(""Fetching messages - API endpoint needed"");
// Using dummy data for demonstration purposes:
setMessages([
{_id: ""1"", to: ""+1555000111"", body: ""Test message 1"", status: ""delivered"", twilioSid: ""SMxxx1"", createdAt: new Date(Date.now() - 60000 * 5).toISOString()}, // 5 mins ago
{_id: ""2"", to: ""+1555000222"", body: ""Test message 2"", status: ""sent"", twilioSid: ""SMxxx2"", createdAt: new Date(Date.now() - 60000 * 2).toISOString()}, // 2 mins ago
{_id: ""3"", to: ""+1555000333"", body: ""Test message 3"", status: ""failed"", twilioSid: ""SMxxx3"", createdAt: new Date(Date.now() - 60000 * 1).toISOString(), errorMessage: ""Invalid number""}, // 1 min ago
].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))); // Sort newest first
};
// Call fetchMessages on component mount when authenticated
useEffect(() => {
if (status === 'authenticated') {
fetchMessages();
}
}, [status]);
const handleSubmit = async (e) => {
e.preventDefault();
if (!recipient || !messageBody) {
setFeedback({ type: 'error', message: 'Please enter recipient and message.' });
return;
}
setIsSending(true);
setFeedback({ type: '', message: '' }); // Clear previous feedback
try {
const response = await fetch('/api/send-sms', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ to: recipient, body: messageBody }),
});
const data = await response.json();
if (response.ok) {
setFeedback({ type: 'success', message: `Message sent! SID: ${data.messageSid}, Status: ${data.status}` });
setRecipient(''); // Clear form
setMessageBody('');
// Refresh the message list (using dummy data refresh for now)
fetchMessages();
} else {
setFeedback({ type: 'error', message: `Error: ${data.error || 'Failed to send message.'}` });
}
} catch (error) {
console.error('Send SMS error:', error);
setFeedback({ type: 'error', message: 'An unexpected error occurred.' });
} finally {
setIsSending(false);
}
};
if (status === 'loading') {
return <p className=""text-center mt-10"">Loading session...</p>;
}
if (status === 'unauthenticated') {
// Should be redirected by useEffect, but good practice to handle render
return <p className=""text-center mt-10"">Redirecting...</p>;
}
return (
<div className=""container mx-auto p-4 md:p-8"">
<h1 className=""text-3xl font-bold mb-6"">Send SMS</h1>
{feedback.message && (
<div
className={`p-4 mb-4 rounded ${
feedback.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{feedback.message}
</div>
)}
<form onSubmit={handleSubmit} className=""bg-white p-6 rounded shadow-md mb-8"">
<div className=""mb-4"">
<label htmlFor=""recipient"" className=""block text-gray-700 text-sm font-bold mb-2"">
Recipient Phone (E.164 format, e.g., +15551234567)
</label>
<input
type=""tel""
id=""recipient""
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
className=""shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline""
placeholder=""+15551234567""
required
/>
</div>
<div className=""mb-6"">
<label htmlFor=""messageBody"" className=""block text-gray-700 text-sm font-bold mb-2"">
Message
</label>
<textarea
id=""messageBody""
value={messageBody}
onChange={(e) => setMessageBody(e.target.value)}
className=""shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline h-24""
placeholder=""Enter your message here""
required
></textarea>
</div>
<div className=""flex items-center justify-between"">
<button
type=""submit""
className=""bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:opacity-50""
disabled={isSending}
>
{isSending ? 'Sending...' : 'Send Message'}
</button>
</div>
</form>
{/* Display Sent Messages */}
<h2 className=""text-2xl font-bold mb-4"">Message Log (Demo Data)</h2>
<div className=""bg-white p-6 rounded shadow-md"">
{messages.length > 0 ? (
<ul className=""divide-y divide-gray-200"">
{messages.map((msg) => (
<li key={msg._id || msg.twilioSid} className=""py-4"">
<p><strong>To:</strong> {msg.to}</p>
<p><strong>Body:</strong> {msg.body}</p>
<p><strong>Status:</strong> <span className={`font-semibold ${
msg.status === 'delivered' ? 'text-green-600' :
msg.status === 'failed' || msg.status === 'undelivered' ? 'text-red-600' :
'text-yellow-600' // queued, sending, sent
}`}>{msg.status}</span></p>
{msg.errorMessage && <p className=""text-red-500 text-sm""><strong>Error:</strong> {msg.errorMessage} (Code: {msg.errorCode || 'N/A'})</p>}
<p className=""text-xs text-gray-500"">SID: {msg.twilioSid}</p>
<p className=""text-xs text-gray-500"">Sent: {new Date(msg.createdAt).toLocaleString()}</p>
</li>
))}
</ul>
) : (
<p>No message history yet.</p> // Added fallback text
)}
</div>
</div>
);
}Explanation:
- Form Handling: Includes a form to input recipient number and message body. Validates input and sends a POST request to
/api/send-sms. - Feedback Messages: Displays success or error feedback based on the API response.
- Message Log Display: Shows a list of sent messages (using dummy data for demonstration). In a production app, fetch this from a dedicated API endpoint that queries your database.
5. Receiving Status Callbacks from Twilio
The core feature: receiving updates from Twilio about message delivery status.
5.1 Understanding Twilio Status Callbacks
When you send an SMS via Twilio and include a statusCallback URL, Twilio will send HTTP POST requests to that URL whenever the message status changes. Possible statuses include:
queued: Message queued for sendingsending: Message in process of being sentsent: Message sent to the carrierdelivered: Message successfully delivered to recipientundelivered: Message failed to deliverfailed: Message failed (permanent error)
Security Consideration: The statusCallback URL must be publicly accessible. Twilio signs all webhook requests with an X-Twilio-Signature header using HMAC-SHA1 and your Auth Token. You must validate this signature to ensure requests genuinely come from Twilio and haven't been tampered with.
5.2 API Route: Receive Status Updates
Create app/api/sms-status/route.js to handle incoming Twilio webhooks.
// app/api/sms-status/route.js
import { NextResponse } from 'next/server';
import twilio from 'twilio';
import dbConnect from '@/app/lib/mongoose'; // Adjust path
import Message from '@/app/models/Message'; // Adjust path
// Twilio credentials for signature validation
const authToken = process.env.TWILIO_AUTH_TOKEN;
export async function POST(request) {
// 1. Extract Request Data
const url = new URL(request.url);
const twilioSignature = request.headers.get('x-twilio-signature') || '';
// Parse form data (Twilio sends application/x-www-form-urlencoded)
const formData = await request.formData();
const params = {};
for (const [key, value] of formData.entries()) {
params[key] = value;
}
console.log('Received Twilio status callback:', params);
// 2. Validate Twilio Signature for Security
// CRITICAL: Always validate to prevent unauthorized status updates
const fullUrl = url.toString(); // Use the complete URL including query params
// Twilio's webhook validation
const isValid = twilio.validateRequest(
authToken,
twilioSignature,
fullUrl,
params
);
if (!isValid) {
console.error('Invalid Twilio signature. Potential security breach.');
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 403 }
);
}
console.log('Twilio signature validated successfully.');
// 3. Extract Message Details from Twilio Callback
const messageSid = params.MessageSid || params.SmsSid; // Twilio uses both
const messageStatus = params.MessageStatus || params.SmsStatus;
const errorCode = params.ErrorCode ? parseInt(params.ErrorCode, 10) : null;
const errorMessage = params.ErrorMessage || null;
if (!messageSid || !messageStatus) {
console.error('Missing required parameters in callback.');
return NextResponse.json(
{ error: 'Missing required parameters' },
{ status: 400 }
);
}
// 4. Update Message Status in Database
try {
await dbConnect(); // Ensure DB connection
const updatedMessage = await Message.findOneAndUpdate(
{ twilioSid: messageSid }, // Find by Twilio SID
{
status: messageStatus,
...(errorCode && { errorCode }),
...(errorMessage && { errorMessage }),
},
{ new: true, runValidators: true } // Return updated document
);
if (!updatedMessage) {
console.warn(`Message with SID ${messageSid} not found in database.`);
// Return 200 anyway to prevent Twilio from retrying
return NextResponse.json(
{ message: 'Message not found, but acknowledged' },
{ status: 200 }
);
}
console.log(
`Message ${messageSid} updated to status: ${messageStatus}`
);
// 5. Respond to Twilio
// Always return 200 OK to prevent Twilio from retrying
return NextResponse.json(
{ success: true, messageSid, newStatus: messageStatus },
{ status: 200 }
);
} catch (error) {
console.error('Error updating message status in database:', error);
// Return 200 to prevent Twilio retries even on DB errors
// Log the error for investigation but acknowledge receipt
return NextResponse.json(
{ message: 'Error logged, but acknowledged' },
{ status: 200 }
);
}
}Explanation:
- Extract Request Data: Parses the incoming POST request from Twilio (sent as
application/x-www-form-urlencoded). Retrieves theX-Twilio-Signatureheader. - Signature Validation: Uses
twilio.validateRequest()to verify the signature using your Auth Token, the full request URL, and the POST parameters. This is critical for security – it ensures the request genuinely comes from Twilio. According to Twilio's security documentation, this validation uses HMAC-SHA1 with your Auth Token as the secret key. - Extract Status Information: Retrieves
MessageSid(orSmsSid),MessageStatus(orSmsStatus), and optionalErrorCodeandErrorMessagefrom the callback parameters. - Database Update: Connects to MongoDB and uses
Message.findOneAndUpdate()to find the message bytwilioSidand update itsstatus(and error details if present). - Response to Twilio: Always return HTTP 200 OK to acknowledge receipt. If you return an error, Twilio will retry the webhook multiple times, potentially causing duplicate processing. Even if your database update fails, acknowledge receipt and log the error for later investigation.
Important Note on SSL Termination: If you're deploying behind a load balancer or using a service that terminates SSL (like Heroku, AWS ELB, or when using ngrok), the URL Twilio uses might be https:// while your validation code sees http://. This mismatch causes signature validation to fail. For production deployments, ensure your validation uses the correct protocol, or configure your infrastructure to pass the original protocol. During local development with ngrok, validation should work correctly as ngrok provides a public HTTPS URL.
6. Testing the Application Locally
To test webhooks locally, you need a publicly accessible URL since Twilio can't reach localhost.
6.1 Install and Setup ngrok
-
Download ngrok: Visit ngrok.com and sign up for a free account. Download and install ngrok.
-
Start Your Next.js Application:
bashnpm run devYour app should be running on
http://localhost:3000. -
Expose localhost with ngrok:
bashngrok http 3000ngrok will provide a public URL like
https://abc123def456.ngrok.io. -
Update
NEXTAUTH_URLin.env.local:plaintextNEXTAUTH_URL=https://abc123def456.ngrok.ioRestart your Next.js dev server after changing environment variables.
6.2 Configure Your Email Provider (NextAuth.js)
Ensure your EmailProvider in app/api/auth/[...nextauth]/route.js is configured with valid SMTP credentials or an email service API key (like SendGrid, Resend, or Nodemailer). Without this, you won't receive magic link emails for authentication.
6.3 Test the Flow
- Sign In: Navigate to your ngrok URL (
https://abc123def456.ngrok.io), click "Sign In", enter your email, and check your inbox for the magic link. Click the link to authenticate. - Send an SMS: Go to the Dashboard (
https://abc123def456.ngrok.io/dashboard), enter a valid recipient phone number (in E.164 format, e.g.,+15551234567), type a message, and click "Send Message". You should see a success message with the Twilio Message SID. - Check Twilio Logs: Open your Twilio Console Messaging Logs to verify the message was sent.
- Monitor Webhook Callbacks: Watch your terminal where your Next.js app is running. You should see console logs like:
Received Twilio status callback: { MessageSid: 'SMxxx...', MessageStatus: 'sent', ... } Twilio signature validated successfully. Message SMxxx... updated to status: sent - Verify Database Updates: Connect to your MongoDB database (e.g., via MongoDB Compass or
mongosh) and check themessagescollection. The message document should show the updatedstatusfield changing fromqueuedtosenttodelivered(orfailedif there's an error).
6.4 Troubleshooting Webhook Validation
If signature validation fails:
- Check
TWILIO_AUTH_TOKEN: Ensure it's correct and matches your Twilio account. - Verify URL in Logs: Check that the URL being validated matches exactly what Twilio sends (including
https://vshttp://and any query parameters). - SSL Termination Issues: If using a reverse proxy or load balancer, it might terminate SSL. See Twilio's documentation on handling SSL termination: https://www.twilio.com/docs/usage/tutorials/how-to-secure-your-express-app-by-validating-incoming-twilio-requests
- ngrok URL Changes: ngrok free tier generates a new URL each time you restart it. Update
NEXTAUTH_URLaccordingly and restart your app.
7. Deploying to Production
When deploying, ensure the following:
7.1 Environment Variables
Set all environment variables in your hosting platform (Vercel, AWS, Heroku, etc.):
TWILIO_ACCOUNT_SIDTWILIO_AUTH_TOKEN(keep secret)TWILIO_PHONE_NUMBERDATABASE_URL(MongoDB connection string)NEXTAUTH_SECRET(generate a strong secret)NEXTAUTH_URL(your production domain, e.g.,https://yourdomain.com)- Email provider credentials (
EMAIL_SERVER_HOST,EMAIL_SERVER_PORT, etc.)
7.2 Webhook URL Configuration
Update your statusCallback URL in app/api/send-sms/route.js to use your production domain:
const statusCallbackUrl = `${process.env.NEXTAUTH_URL}/api/sms-status`;Since NEXTAUTH_URL is set to your production URL, this will automatically use the correct callback URL.
7.3 Database Indexes
For production performance, ensure MongoDB indexes are created on frequently queried fields:
// In app/models/Message.js, the index on twilioSid is already defined:
twilioSid: {
type: String,
required: true,
unique: true,
index: true, // Creates an index for faster lookups
}Consider adding a compound index on userId and createdAt for efficient user-specific message queries:
MessageSchema.index({ userId: 1, createdAt: -1 });7.4 Rate Limiting and Security
- Rate Limit API Routes: Implement rate limiting on
/api/send-smsto prevent abuse. Use packages likeexpress-rate-limitor middleware solutions provided by your hosting platform. - Input Validation: Enhance phone number validation using libraries like
libphonenumber-jsto ensure E.164 format compliance. - CORS Configuration: If building a separate frontend, configure CORS appropriately to allow only your frontend domain.
- Webhook Signature Validation: Never disable webhook signature validation in production. This is your primary defense against unauthorized status updates.
7.5 Monitoring and Logging
- Structured Logging: Replace
console.logstatements with a proper logging solution (e.g., Winston, Pino, or your platform's logging service). - Error Tracking: Integrate error tracking tools like Sentry or Rollbar to catch and monitor exceptions in production.
- Webhook Delivery Monitoring: Check Twilio's Webhook Event Logs regularly to ensure callbacks are being delivered successfully. Failed webhooks might indicate issues with your endpoint.
Frequently Asked Questions (FAQ)
How do Twilio SMS status callbacks work?
Twilio SMS status callbacks are HTTP POST requests that Twilio sends to your webhook URL whenever your SMS message status changes. When you send an SMS with a statusCallback parameter, Twilio tracks the message through its lifecycle (queued, sending, sent, delivered, or failed) and posts updates to your endpoint. Each callback includes the MessageSid, current MessageStatus, and optional error details. Your webhook must return HTTP 200 to acknowledge receipt and prevent retry loops.
How do I validate Twilio webhook signatures in Next.js?
Validate Twilio webhook signatures using the twilio.validateRequest() method from the Twilio Node.js SDK. This method verifies the X-Twilio-Signature header by computing an HMAC-SHA1 hash of your full webhook URL and POST parameters using your Auth Token as the secret key. The validation ensures requests genuinely come from Twilio and haven't been tampered with. Always validate signatures in production to prevent unauthorized status updates.
What are the possible SMS message status values from Twilio?
Twilio SMS messages progress through these statuses: queued (message queued for sending), sending (actively being sent), sent (delivered to carrier), delivered (confirmed delivery to recipient's phone), undelivered (temporary delivery failure), and failed (permanent delivery failure). Your webhook receives callbacks for each status change, allowing you to track message progress in real-time and handle delivery failures appropriately.
Why must I return HTTP 200 from Twilio webhooks even on errors?
Always return HTTP 200 OK from Twilio webhooks to acknowledge receipt, even when your database update fails or other errors occur. If you return error status codes (4xx or 5xx), Twilio interprets this as a delivery failure and retries the webhook multiple times, potentially causing duplicate processing. Log errors internally for investigation, but always acknowledge receipt to Twilio to prevent retry loops.
How do I test Twilio webhooks locally with Next.js?
Test Twilio webhooks locally using ngrok to expose your localhost development server with a public HTTPS URL. Run ngrok http 3000 to create a tunnel, then update your NEXTAUTH_URL environment variable to the ngrok URL (e.g., https://abc123.ngrok.io). Restart your Next.js dev server and send test SMS messages. Monitor your terminal for webhook callback logs and verify status updates in your MongoDB database. ngrok's free tier generates new URLs on each restart.
What Node.js version should I use for Next.js 15 and Twilio?
Use Node.js 22.x LTS (codenamed "Jod") for Next.js 15 and Twilio applications. Node.js 22 is currently in Active LTS until October 2025, then transitions to Maintenance LTS until April 2027. This version provides optimal compatibility with Next.js 15.5, NextAuth.js v5, and the Twilio Node.js SDK. Node.js 18 reaches end-of-life on April 30, 2025, and should not be used for new projects.
How do I handle SSL termination issues with Twilio webhook validation?
SSL termination issues occur when load balancers or reverse proxies terminate HTTPS connections before reaching your application, causing webhook URL protocol mismatches (Twilio sends to https:// but your code sees http://). This mismatch fails signature validation. Solutions include configuring your infrastructure to pass the original protocol header (X-Forwarded-Proto), ensuring your validation code uses the correct protocol, or in development with ngrok, using the HTTPS URL provided by ngrok which handles SSL correctly.
8. Conclusion
You've now built a complete SMS delivery tracking system with:
✅ Authenticated SMS sending using NextAuth.js v5 and Next.js 15 App Router
✅ Real-time status updates via Twilio webhooks with signature validation
✅ Persistent message logging in MongoDB with Mongoose schemas
✅ Secure webhook handling with HMAC-SHA1 signature verification
✅ Production-ready architecture with proper error handling and logging
Key Takeaways
- Always validate webhook signatures – Use
twilio.validateRequest()to prevent unauthorized status updates - Return HTTP 200 to webhooks – Even on errors, to prevent Twilio retry loops
- Use proper Node.js LTS versions – Node.js 22.x is currently in Active LTS (until October 2025)
- Implement comprehensive error handling – Log failures for later investigation while acknowledging receipt
- Test locally with ngrok – Essential for webhook development before production deployment
Next Steps
- Add a real-time UI update mechanism using WebSockets or Server-Sent Events to refresh message statuses without page reload
- Implement message history pagination for users with large message volumes
- Add filtering and search to the message log (by status, date range, recipient)
- Create a message retrieval API endpoint to replace the dummy data in the dashboard
- Implement automated tests for webhook handling and database operations
- Add delivery analytics – track delivery rates, failure reasons, and performance metrics
Additional Resources
- Next.js Documentation: https://nextjs.org/docs
- NextAuth.js (Auth.js) Documentation: https://authjs.dev/
- Twilio Webhooks Security: https://www.twilio.com/docs/usage/webhooks/webhooks-security
- Twilio SMS Status Callbacks: https://www.twilio.com/docs/sms/api/message-resource#message-status-values
- MongoDB Atlas: https://www.mongodb.com/cloud/atlas
- Mongoose Documentation: https://mongoosejs.com/docs/
Source Citations
Node.js & Next.js:
- Node.js LTS Schedule: https://endoflife.date/nodejs
- Node.js 22 LTS Announcement: https://nodesource.com/blog/Node.js-v22-Long-Term-Support-LTS
- Next.js 15 Official Release: https://nextjs.org/blog/next-15
- Next.js 15.5 Release Notes: https://medium.com/@onix_react/release-next-js-15-5-df0c53e3b79b
Authentication:
- NextAuth.js v5 Migration Guide: https://authjs.dev/getting-started/migrating-to-v5
- Auth.js Official Documentation: https://authjs.dev/
- NextAuth.js with Next.js 15: https://codevoweb.com/how-to-set-up-next-js-15-with-nextauth-v5/
Twilio:
- Twilio Node.js SDK GitHub: https://github.com/twilio/twilio-node
- Twilio Webhook Security Documentation: https://www.twilio.com/docs/usage/webhooks/webhooks-security
- Twilio Webhook Validation in Node.js: https://www.twilio.com/blog/how-to-secure-twilio-webhook-urls-in-nodejs
- Twilio SMS Message Status Values: https://www.twilio.com/docs/sms/api/message-resource#message-status-values
- Twilio Getting Started with Webhooks: https://www.twilio.com/docs/usage/webhooks/getting-started-twilio-webhooks
- Secure Express App with Twilio Validation: https://www.twilio.com/docs/usage/tutorials/how-to-secure-your-express-app-by-validating-incoming-twilio-requests
- Twilio SSL Termination Handling: https://www.twilio.com/en-us/blog/handle-ssl-termination-twilio-node-js-helper-library
- Track Outbound Message Status: https://www.twilio.com/docs/messaging/guides/track-outbound-message-status