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.
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.
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.
ngrok or 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:
Bash
npx create-next-app@latest sms-status-tracker
Follow the prompts:
Would you like to use TypeScript?No (This guide uses JavaScript)
Would you like to use ESLint?Yes
Would you like to use Tailwind CSS?Yes
Would you like to use \src/` directory?` No (or Yes, adjust paths accordingly)
Would you like to use App Router? (recommended)Yes
Would you like to customize the default import alias?No
1.2 Navigate and Install Dependencies
Bash
cd sms-status-tracker
npm install next-auth @auth/mongodb-adapter mongodb mongoose twilio
next-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.
Text
# .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_SID and TWILIO_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 using openssl rand -base64 32 in your terminal.
NEXTAUTH_URL: The base URL of your application. Use http://localhost:3000 for local development. Update this when deploying.
TWILIO_WEBHOOK_SECRET: For validating incoming webhook requests. By default, Twilio uses your TWILIO_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:
We 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.
JavaScript
// app/lib/mongodb.jsimport { MongoClient } from'mongodb';
if (!process.env.DATABASE_URL) {
thrownewError('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 = newMongoClient(uri, options);
global._mongoClientPromise = client.connect();
}
clientPromise = global._mongoClientPromise;
} else {
// In production mode, it's best to not use a global variable.
client = newMongoClient(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.exportdefault clientPromise;
2.2 NextAuth.js Configuration
Create the NextAuth.js API route handler at app/api/auth/[...nextauth]/route.js.
JavaScript
// app/api/auth/[...nextauth]/route.jsimportNextAuthfrom'next-auth';
importEmailProviderfrom'next-auth/providers/email';
import { MongoDBAdapter } from'@auth/mongodb-adapter';
import clientPromise from'@/app/lib/mongodb'; // Use alias if configured, else adjust pathexportconst 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 datacallbacks: {
asyncsession({ 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 asGET, handler asPOST };
Note: You need to configure the EmailProvider with actual email sending credentials (like SendGrid API key, SMTP details) in your .env.local file 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.
// app/page.js'use client';
import { useSession, signIn, signOut } from'next-auth/react';
importLinkfrom'next/link';
exportdefaultfunctionHome() {
const { data: session, status } = useSession();
return (
<mainclassName=""flexmin-h-screenflex-colitems-centerjustify-centerp-24""><h1className=""text-4xlfont-boldmb-8"">SMS Status Tracker</h1>
{status === 'loading' && <p>Loading...</p>}
{status === 'authenticated' && session && (
<divclassName=""text-center""><pclassName=""mb-4"">Signed in as {session.user?.email}</p><Linkhref=""/dashboard""
className=""bg-blue-500hover:bg-blue-700text-whitefont-boldpy-2px-4roundedmr-2""
>
Go to Dashboard
</Link><buttononClick={() => 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
// Pointtothespecificproviderorthedefaultsign-inpageonClick={() => 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:
JavaScript
// app/auth/signin/page.js'use client';
import { useState } from'react';
import { signIn } from'next-auth/react';
exportdefaultfunctionSignIn() {
const [email, setEmail] = useState('');
consthandleSignIn = (e) => {
e.preventDefault();
// Redirect to the verify request page after email is sentsignIn('email', { email, callbackUrl: '/auth/verify-request' });
};
return (
<divclassName=""flexjustify-centeritems-centermin-h-screen""><formonSubmit={handleSignIn}className=""bg-whitep-8roundedshadow-mdw-fullmax-w-sm""
><h2className=""text-2xlfont-boldmb-6text-centertext-gray-800"">Sign In / Sign Up</h2><pclassName=""text-centertext-gray-600mb-6"">
Enter your email to receive a magic link.
</p><divclassName=""mb-4""><labelhtmlFor=""email"" className=""blocktext-gray-700text-smfont-boldmb-2"">
Email
</label><inputtype=""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><buttontype=""submit""
className=""bg-blue-500hover:bg-blue-700text-whitefont-boldpy-2px-4roundedfocus:outline-nonefocus:shadow-outlinew-full""
>
Send Magic Link
</button></form></div>
);
}
And a verification request page app/auth/verify-request/page.js:
JavaScript
// app/auth/verify-request/page.jsexportdefaultfunctionVerifyRequest() {
return (
<divclassName=""flexjustify-centeritems-centermin-h-screen""><divclassName=""bg-whitep-8roundedshadow-mdtext-center""><h2className=""text-2xlfont-boldmb-4text-gray-800"">Check your email</h2><pclassName=""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.
JavaScript
// app/lib/mongoose.jsimport mongoose from'mongoose';
constMONGODB_URI = process.env.DATABASE_URL;
if (!MONGODB_URI) {
thrownewError(
'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 };
}
asyncfunctiondbConnect() {
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(""MongooseConnected!""); // Log successful connectionreturn cached.conn;
}
exportdefault dbConnect;
3.2 Message Model
Create the Mongoose schema in app/models/Message.js.
JavaScript
// app/models/Message.jsimport mongoose from'mongoose';
constMessageSchema = new mongoose.Schema(
{
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User', // Reference to the User model created by NextAuth.js adapterrequired: 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 sendingtype: 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 statusenum: ['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 messagetype: String,
required: false,
}
},
{
timestamps: true, // Automatically add createdAt and updatedAt
}
);
// Avoid recompiling the model if it already existsexportdefault 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.
JavaScript
// app/api/send-sms/route.jsimport { NextResponse } from'next/server';
import { getServerSession } from'next-auth/next';
import { authOptions } from'@/app/api/auth/[...nextauth]/route'; // Adjust pathimport dbConnect from'@/app/lib/mongoose'; // Adjust pathimportMessagefrom'@/app/models/Message'; // Adjust pathimport twilio from'twilio';
// Initialize Twilio clientconst 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);
exportasyncfunctionPOST(request) {
// 1. Check Authenticationconst session = awaitgetServerSession(authOptions);
if (!session || !session.userId) {
returnNextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 2. Parse Request Bodylet recipient;
let messageBody;
try {
const body = await request.json();
recipient = body.to;
messageBody = body.body;
// Basic Input Validationif (!recipient || typeof recipient !== 'string' || !recipient.trim()) {
returnNextResponse.json({ error: 'Recipient phone number (`to`) is required.' }, { status: 400 });
}
if (!messageBody || typeof messageBody !== 'string' || !messageBody.trim()) {
returnNextResponse.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)) {
returnNextResponse.json({ error: 'Invalid recipient phone number format.' }, { status: 400 });
}
} catch (error) {
returnNextResponse.json({ error: 'Invalid request body.' }, { status: 400 });
}
// 3. Construct Status Callback URL// IMPORTANT: Use your production URL or ngrok URL here during developmentconst statusCallbackUrl = `${process.env.NEXTAUTH_URL}/api/sms-status`;
// 4. Send SMS via Twilio & Log to DBtry {
awaitdbConnect(); // Ensure DB connectionconst message = await client.messages.create({
body: messageBody,
from: twilioPhoneNumber,
to: recipient,
statusCallback: statusCallbackUrl, // Tell Twilio where to POST status updatesstatusCallbackMethod: 'POST', // Use POST method
});
console.log(`Twilio message sent: SID ${message.sid}, Status: ${message.status}`);
// 5. Store Message Log in MongoDBconst newMessageLog = newMessage({
userId: session.userId,
to: recipient,
body: messageBody,
twilioSid: message.sid, // Store the SID from Twilio's responsestatus: message.status, // Initial status from Twilio (e.g., 'queued', 'sending')
});
await newMessageLog.save();
console.log(`Message log created in DB for SID: ${message.sid}`);
returnNextResponse.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 neededconst 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 {
awaitdbConnect(); // Ensure connection for logging failureconst failedLog = newMessage({
userId: session.userId,
to: recipient,
body: messageBody,
twilioSid: `failed_${Date.now()}`, // Generate a temporary SID for failed attemptsstatus: '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);
}
returnNextResponse.json({ error: errorMessage, code: error.code }, { status: statusCode });
}
}
Explanation:
Authentication: Uses getServerSession to ensure only logged-in users can access this endpoint.
Input Parsing & Validation: Reads the to (recipient phone number) and body (message content) from the request. Includes basic validation.
Status Callback URL: Constructs the absolute URL pointing to our sms-status API route. Crucially, this must be a publicly accessible URL. For local development, use your ngrok URL. For production, use your deployed application's URL.
Twilio Interaction: Initializes the Twilio client and calls client.messages.create. We pass the statusCallback URL here.
Database Logging: Connects to MongoDB using dbConnect and creates a new Message document using the Message model, storing the twilioSid and initial status provided by Twilio.
Error Handling: Includes try...catch blocks 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.
JavaScript
// 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 RouterexportdefaultfunctionDashboard() {
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 authenticateduseEffect(() => {
if (status === 'unauthenticated') {
router.push('/'); // Redirect to home/login page
}
}, [status, router]);
// Function to fetch messages (using dummy data for this guide)constfetchMessages = () => {
// 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: newDate(Date.now() - 60000 * 5).toISOString()}, // 5 mins ago
{_id: ""2"", to: ""+1555000222"", body: ""Test message 2"", status: ""sent"", twilioSid: ""SMxxx2"", createdAt: newDate(Date.now() - 60000 * 2).toISOString()}, // 2 mins ago
{_id: ""3"", to: ""+1555000333"", body: ""Test message 3"", status: ""failed"", twilioSid: ""SMxxx3"", createdAt: newDate(Date.now() - 60000 * 1).toISOString(), errorMessage: ""Invalid number""}, // 1 min ago
].sort((a, b) =>newDate(b.createdAt) - newDate(a.createdAt))); // Sort newest first
};
// Call fetchMessages on component mount when authenticateduseEffect(() => {
if (status === 'authenticated') {
fetchMessages();
}
}, [status]);
consthandleSubmit = async (e) => {
e.preventDefault();
if (!recipient || !messageBody) {
setFeedback({ type: 'error', message: 'Please enter recipient and message.' });
return;
}
setIsSending(true);
setFeedback({ type: '', message: '' }); // Clear previous feedbacktry {
const response = awaitfetch('/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 formsetMessageBody('');
// 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<pclassName=""text-centermt-10"">Loading session...</p>;
}
if (status === 'unauthenticated') {
// Should be redirected by useEffect, but good practice to handle renderreturn<pclassName=""text-centermt-10"">Redirecting...</p>;
}
return (
<divclassName=""containermx-autop-4md:p-8""><h1className=""text-3xlfont-boldmb-6"">Send SMS</h1>
{feedback.message && (
<divclassName={`p-4mb-4rounded ${
feedback.type === 'success' ? 'bg-green-100text-green-800' : 'bg-red-100text-red-800'
}`}
>
{feedback.message}
</div>
)}
<formonSubmit={handleSubmit}className=""bg-whitep-6roundedshadow-mdmb-8""><divclassName=""mb-4""><labelhtmlFor=""recipient"" className=""blocktext-gray-700text-smfont-boldmb-2"">
Recipient Phone (E.164 format, e.g., +15551234567)
</label><inputtype=""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><divclassName=""mb-6""><labelhtmlFor=""messageBody"" className=""blocktext-gray-700text-smfont-boldmb-2"">
Message
</label><textareaid=""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><divclassName=""flexitems-centerjustify-between""><buttontype=""submit""
className=""bg-blue-500hover:bg-blue-700text-whitefont-boldpy-2px-4roundedfocus:outline-nonefocus:shadow-outlinedisabled:opacity-50""
disabled={isSending}
>
{isSending ? 'Sending...' : 'Send Message'}
</button></div></form>
{/* Display Sent Messages */}
<h2className=""text-2xlfont-boldmb-4"">Message Log (Demo Data)</h2><divclassName=""bg-whitep-6roundedshadow-md"">
{messages.length > 0 ? (
<ulclassName=""divide-ydivide-gray-200"">
{messages.map((msg) => (
<likey={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><spanclassName={`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 && <pclassName=""text-red-500text-sm""><strong>Error:</strong> {msg.errorMessage} (Code: {msg.errorCode || 'N/A'})</p>}
<pclassName=""text-xstext-gray-500"">SID: {msg.twilioSid}</p><pclassName=""text-xstext-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 sending
sending: Message in process of being sent
sent: Message sent to the carrier
delivered: Message successfully delivered to recipient
undelivered: Message failed to deliver
failed: 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.
JavaScript
// app/api/sms-status/route.jsimport { NextResponse } from'next/server';
import twilio from'twilio';
import dbConnect from'@/app/lib/mongoose'; // Adjust pathimportMessagefrom'@/app/models/Message'; // Adjust path// Twilio credentials for signature validationconst authToken = process.env.TWILIO_AUTH_TOKEN;
exportasyncfunctionPOST(request) {
// 1. Extract Request Dataconst url = newURL(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 updatesconst fullUrl = url.toString(); // Use the complete URL including query params// Twilio's webhook validationconst isValid = twilio.validateRequest(
authToken,
twilioSignature,
fullUrl,
params
);
if (!isValid) {
console.error('Invalid Twilio signature. Potential security breach.');
returnNextResponse.json(
{ error: 'Invalid signature' },
{ status: 403 }
);
}
console.log('Twilio signature validated successfully.');
// 3. Extract Message Details from Twilio Callbackconst messageSid = params.MessageSid || params.SmsSid; // Twilio uses bothconst 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.');
returnNextResponse.json(
{ error: 'Missing required parameters' },
{ status: 400 }
);
}
// 4. Update Message Status in Databasetry {
awaitdbConnect(); // Ensure DB connectionconst updatedMessage = awaitMessage.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 retryingreturnNextResponse.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 retryingreturnNextResponse.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 receiptreturnNextResponse.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 the X-Twilio-Signature header.
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 (or SmsSid), MessageStatus (or SmsStatus), and optional ErrorCode and ErrorMessage from the callback parameters.
Database Update: Connects to MongoDB and uses Message.findOneAndUpdate() to find the message by twilioSid and update its status (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:
Bash
npm run dev
Your app should be running on http://localhost:3000.
Expose localhost with ngrok:
Bash
ngrok http 3000
ngrok will provide a public URL like https://abc123def456.ngrok.io.
Update NEXTAUTH_URL in .env.local:
Text
NEXTAUTH_URL=https://abc123def456.ngrok.io
Restart 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.
Monitor Webhook Callbacks: Watch your terminal where your Next.js app is running. You should see console logs like:
Code
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 the messages collection. The message document should show the updated status field changing from queued to sent to delivered (or failed if 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:// vs http:// and any query parameters).
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:
JavaScript
// 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:
Rate Limit API Routes: Implement rate limiting on /api/send-sms to prevent abuse. Use packages like express-rate-limit or middleware solutions provided by your hosting platform.
Input Validation: Enhance phone number validation using libraries like libphonenumber-js to 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.log statements 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
Use Twilio's webhooks to receive real-time delivery updates. Set up a webhook URL in your Twilio account that points to an API endpoint in your application. Twilio will then send POST requests to this endpoint with status updates for each message.
The statusCallback URL is where Twilio sends delivery status updates for your SMS messages. It's crucial to set this URL when sending messages via the Twilio API so your application can track the message delivery in real-time.
NextAuth.js provides user authentication and session management in your Next.js application. This ensures that only authorized users can send SMS messages through your Twilio integration, adding a layer of security.
Create a dedicated API route in your Next.js app (e.g., /api/sms-status) to handle incoming webhook requests from Twilio. This route will receive POST requests containing the delivery status updates. Expose this route publicly with ngrok for development and secure it in production.
MongoDB stores the SMS message logs, including the message body, recipient, Twilio SID, and delivery status. This allows you to maintain a history of sent messages and their delivery outcomes, which helps in debugging and analysis.
Use the Twilio Node.js helper library within your API routes to interact with the Twilio API. When sending messages, include the statusCallback URL pointing to your Next.js API route responsible for receiving status updates.
Use ngrok during local development to create a publicly accessible URL for your webhook endpoint. Since Twilio needs to reach your local server, ngrok tunnels the requests, making your local server accessible to Twilio.
The TWILIO_WEBHOOK_SECRET enhances security by verifying that the webhook requests are actually originating from Twilio. You can use your auth token or create a webhook signing key in the Twilio Console for higher security.
Yes, the provided MongoDB schema includes fields for storing Twilio error codes (errorCode) and messages (errorMessage). This helps in identifying specific issues with message delivery. This can be useful for diagnostics and troubleshooting.
Use the Twilio Node.js library in your Next.js API route. Make a POST request to the Twilio API with the recipient's phone number, your Twilio phone number, the message body, and the statusCallback URL.
You need a Node.js environment, a Twilio account with an active phone number, a MongoDB database, and familiarity with Next.js, React, and JavaScript. ngrok is also recommended for local development.
Store the user ID, recipient number, message body, Twilio Message SID, delivery status, error codes (if any), and timestamps. This provides a comprehensive record of each message and its delivery outcome.
Real-time delivery status improves user experience by providing feedback on message delivery. It also enhances system reliability by allowing you to identify and address delivery issues promptly.
The user interacts with the Next.js frontend, which calls a Next.js API route to send the SMS via Twilio. Twilio sends the message to the recipient and updates the status to another Next.js API route via a webhook. The status is then updated in 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:
Technologies Used:
System Architecture:
Prerequisites:
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:
Follow 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?No1.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.localin the root of your project. Never commit this file to version control.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:
We 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.jsfor 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 };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.jsif using thepages.signInoption:// 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.jsto 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 deliverystatus, 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:
getServerSessionto ensure only logged-in users can access this endpoint.to(recipient phone number) andbody(message content) from the request. Includes basic validation.sms-statusAPI route. Crucially, this must be a publicly accessible URL. For local development, use yourngrokURL. For production, use your deployed application's URL.client.messages.create. We pass thestatusCallbackURL here.dbConnectand creates a newMessagedocument using theMessagemodel, storing thetwilioSidand initial status provided by Twilio.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.jswhere 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:
/api/send-sms.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
statusCallbackURL, 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
statusCallbackURL must be publicly accessible. Twilio signs all webhook requests with anX-Twilio-Signatureheader 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.jsto 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:
application/x-www-form-urlencoded). Retrieves theX-Twilio-Signatureheader.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.MessageSid(orSmsSid),MessageStatus(orSmsStatus), and optionalErrorCodeandErrorMessagefrom the callback parameters.Message.findOneAndUpdate()to find the message bytwilioSidand update itsstatus(and error details if present).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 seeshttp://. 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:
Your app should be running on
http://localhost:3000.Expose localhost with ngrok:
ngrok will provide a public URL like
https://abc123def456.ngrok.io.Update
NEXTAUTH_URLin.env.local:Restart your Next.js dev server after changing environment variables.
6.2 Configure Your Email Provider (NextAuth.js)
Ensure your
EmailProviderinapp/api/auth/[...nextauth]/route.jsis 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
https://abc123def456.ngrok.io), click "Sign In", enter your email, and check your inbox for the magic link. Click the link to authenticate.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.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:
TWILIO_AUTH_TOKEN: Ensure it's correct and matches your Twilio account.https://vshttp://and any query parameters).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_SERVER_HOST,EMAIL_SERVER_PORT, etc.)7.2 Webhook URL Configuration
Update your
statusCallbackURL inapp/api/send-sms/route.jsto use your production domain:const statusCallbackUrl = `${process.env.NEXTAUTH_URL}/api/sms-status`;Since
NEXTAUTH_URLis 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
userIdandcreatedAtfor efficient user-specific message queries:MessageSchema.index({ userId: 1, createdAt: -1 });7.4 Rate Limiting and Security
/api/send-smsto prevent abuse. Use packages likeexpress-rate-limitor middleware solutions provided by your hosting platform.libphonenumber-jsto ensure E.164 format compliance.7.5 Monitoring and Logging
console.logstatements with a proper logging solution (e.g., Winston, Pino, or your platform's logging service).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
statusCallbackparameter, Twilio tracks the message through its lifecycle (queued, sending, sent, delivered, or failed) and posts updates to your endpoint. Each callback includes theMessageSid, currentMessageStatus, 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 theX-Twilio-Signatureheader 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), andfailed(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 3000to create a tunnel, then update yourNEXTAUTH_URLenvironment 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 seeshttp://). 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
twilio.validateRequest()to prevent unauthorized status updatesNext Steps
Additional Resources
Source Citations
Node.js & Next.js:
Authentication:
Twilio: