code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / twilio

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:

  1. A Next.js application with user authentication.
  2. Functionality to send SMS messages via Twilio from authenticated user sessions.
  3. A database schema to log sent messages and their statuses.
  4. A secure API endpoint to receive status callbacks from Twilio.
  5. Logic to update message statuses in your database based on Twilio callbacks.
  6. 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:

plaintext
+-----------------+     +--------------------+     +-----------------+     +----------------------+     +-----------------+
|  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.
  • 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.

plaintext
# .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:

plaintext
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.js

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

javascript
// 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 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.

Create app/components/Provider.js:

javascript
// 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:

javascript
// 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>
  );
}

Create simple sign-in/out buttons. Update app/page.js:

javascript
// 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:

javascript
// 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:

javascript
// 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.

javascript
// 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.

javascript
// 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.

javascript
// 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:

  1. Authentication: Uses getServerSession to ensure only logged-in users can access this endpoint.
  2. Input Parsing & Validation: Reads the to (recipient phone number) and body (message content) from the request. Includes basic validation.
  3. 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.
  4. Twilio Interaction: Initializes the Twilio client and calls client.messages.create. We pass the statusCallback URL here.
  5. 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.
  6. 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 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 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.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:

  1. Extract Request Data: Parses the incoming POST request from Twilio (sent as application/x-www-form-urlencoded). Retrieves the X-Twilio-Signature header.
  2. 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.
  3. Extract Status Information: Retrieves MessageSid (or SmsSid), MessageStatus (or SmsStatus), and optional ErrorCode and ErrorMessage from the callback parameters.
  4. Database Update: Connects to MongoDB and uses Message.findOneAndUpdate() to find the message by twilioSid and update its status (and error details if present).
  5. 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

  1. Download ngrok: Visit ngrok.com and sign up for a free account. Download and install ngrok.

  2. Start Your Next.js Application:

    bash
    npm run dev

    Your app should be running on http://localhost:3000.

  3. Expose localhost with ngrok:

    bash
    ngrok http 3000

    ngrok will provide a public URL like https://abc123def456.ngrok.io.

  4. Update NEXTAUTH_URL in .env.local:

    plaintext
    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

  1. 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.
  2. 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.
  3. Check Twilio Logs: Open your Twilio Console Messaging Logs to verify the message was sent.
  4. 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
  5. 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).
  • 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_URL accordingly 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_SID
  • TWILIO_AUTH_TOKEN (keep secret)
  • TWILIO_PHONE_NUMBER
  • DATABASE_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:

javascript
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:

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:

javascript
MessageSchema.index({ userId: 1, createdAt: -1 });

7.4 Rate Limiting and Security

  • 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

  1. Always validate webhook signatures – Use twilio.validateRequest() to prevent unauthorized status updates
  2. Return HTTP 200 to webhooks – Even on errors, to prevent Twilio retry loops
  3. Use proper Node.js LTS versions – Node.js 22.x is currently in Active LTS (until October 2025)
  4. Implement comprehensive error handling – Log failures for later investigation while acknowledging receipt
  5. 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


Source Citations

Node.js & Next.js:

Authentication:

Twilio: