code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

Track Twilio SMS Delivery Status with Next.js Webhooks: Complete StatusCallback Guide

Implement Twilio delivery status callbacks in Next.js with webhook signature validation, Prisma database integration, and real-time SMS tracking. Complete tutorial with code examples.

Track Twilio SMS Delivery Status with Next.js Webhooks: Complete StatusCallback Guide

Track the delivery status of SMS and MMS messages sent via Twilio directly within your Next.js application. This guide provides a complete walkthrough for setting up a webhook endpoint to receive status updates, storing them in a database, and handling common scenarios.

Real-time status of sent messages – whether delivered, failed, or in transit – is crucial for applications relying on SMS for notifications, alerts, or two-factor authentication. Implementing Twilio's status callbacks provides this visibility, enabling better error handling, logging, and user experience.

Build a Next.js application that sends SMS via Twilio and uses a dedicated API route to listen for and record status updates pushed by Twilio. Use Prisma for database interaction and implement security, error handling, and deployment best practices.

Citation1. From source: https://www.twilio.com/docs/messaging/guides/track-outbound-message-status, Title: Twilio StatusCallback Webhook Parameters 2024, Text: The properties included in Twilio's request to the StatusCallback URL vary by messaging channel and event type and are subject to change. Twilio occasionally adds new parameters without advance notice. When integrating with status callback requests, your implementation should be able to accept and correctly run signature validation on an evolving set of parameters. Twilio strongly recommends using the signature validation methods provided in the SDKs.

Project Overview and Goals

What You'll Build:

  • A Next.js application with an API route (/api/twilio/status) acting as a webhook endpoint for Twilio status callbacks.
  • A mechanism to send SMS messages via the Twilio API, specifying your webhook endpoint URL in the statusCallback parameter.
  • A database schema (using Prisma) to store incoming message status updates (MessageSid, MessageStatus, ErrorCode).
  • Secure handling of Twilio webhooks using signature validation.

Problem Solved:

Gain real-time visibility into the delivery lifecycle of outbound Twilio messages, enabling robust tracking, debugging, and follow-up actions based on message status (e.g., retrying failed messages, logging delivery confirmations).

Technologies Used:

  • Next.js: React framework for building the frontend and API routes. Provides developer experience, performance features, and integrated API capabilities.
  • Twilio: Communications Platform as a Service (CPaaS) for sending SMS/MMS. Offers robust APIs and webhook features.
  • Prisma: Next-generation ORM for Node.js and TypeScript. Provides type safety, developer-friendly schema management, and migrations.
  • Node.js: JavaScript runtime environment.
  • (Optional) ngrok: Exposes the local development server to the internet, enabling Twilio to reach the webhook endpoint during development.

System Architecture:

The system operates as follows:

  1. A user or client triggers an action in the Next.js application.
  2. The Next.js API sends an SMS request to the Twilio API, including a StatusCallback URL pointing back to the application.
  3. Twilio sends the SMS to the end user via the carrier network.
  4. As the message status changes (e.g., sent, delivered, failed), Twilio sends an HTTP POST request containing the status update to the specified StatusCallback URL (must be publicly accessible via ngrok during development or the deployed application URL in production).
  5. The public URL forwards the request to the designated Next.js API route (/api/twilio/status).
  6. The API route validates the incoming request's signature to ensure it originates from Twilio.
  7. If valid, the route processes the status update and stores relevant information (MessageSid, MessageStatus) in the database using Prisma.
  8. The API route sends a 200 OK HTTP response back to Twilio to acknowledge receipt.

Prerequisites:

  • Node.js (LTS version recommended) and npm/yarn installed.
  • A Twilio account with an active phone number or Messaging Service SID. Find your Account SID and Auth Token in the Twilio Console.
  • Basic understanding of Next.js, React, and asynchronous JavaScript.
  • A tool to expose your local development server (like ngrok) if testing locally.
  • A database supported by Prisma (e.g., PostgreSQL, MySQL, SQLite). This guide uses SQLite for simplicity during development.

Final Outcome:

A functional Next.js application capable of sending SMS messages via Twilio and reliably receiving, validating, and storing delivery status updates pushed by Twilio to a secure webhook endpoint.

Setting up the Project

Initialize the Next.js project and install necessary dependencies.

  1. Create a new Next.js App: Open your terminal and run:

    bash
    npx create-next-app@latest twilio-status-callback-guide
    cd twilio-status-callback-guide

    Choose your preferred settings (TypeScript recommended). This guide assumes the app directory structure.

  2. Install Dependencies: Install the Twilio Node.js helper library and Prisma.

    bash
    npm install twilio @prisma/client
    npm install prisma --save-dev
    • twilio: Official library for interacting with the Twilio API.
    • @prisma/client: Prisma's database client.
    • prisma: Prisma's command-line tool for migrations and schema management.
  3. Initialize Prisma: Set up Prisma in your project. This creates a prisma directory with a schema.prisma file and a .env file for environment variables.

    bash
    npx prisma init --datasource-provider sqlite
    • --datasource-provider sqlite: Uses SQLite for simplicity. Change this (e.g., postgresql, mysql) for a different database and update the DATABASE_URL accordingly.
  4. Configure Environment Variables: Open the .env file created by Prisma and add your Twilio credentials and other necessary variables. Never commit this file to version control if it contains secrets.

    dotenv
    # .env
    
    # Database
    DATABASE_URL="file:./dev.db"
    
    # Twilio Credentials (Get from https://www.twilio.com/console)
    TWILIO_ACCOUNT_SID="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    TWILIO_AUTH_TOKEN="your_auth_token_xxxxxxxxxxxxxx" # Also used as Webhook Secret
    
    # Your Twilio Phone Number or Messaging Service SID
    TWILIO_PHONE_NUMBER_OR_MSG_SID="+15551234567" # Or MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    
    # The base URL where your app runs (for constructing callback URL)
    # During development with ngrok, use your ngrok URL
    # In production, use your deployed app's URL
    NEXT_PUBLIC_APP_BASE_URL="http://localhost:3000" # Update with ngrok/prod URL later
    • DATABASE_URL: Connection string for your database. Prisma set this up for SQLite.
    • TWILIO_ACCOUNT_SID: Your unique Twilio account identifier.
    • TWILIO_AUTH_TOKEN: Your secret Twilio token. Twilio uses this same token to sign webhook requests, serving as the webhook validation secret.
    • TWILIO_PHONE_NUMBER_OR_MSG_SID: The 'From' number for sending messages, or the SID of a configured Messaging Service.
    • NEXT_PUBLIC_APP_BASE_URL: The publicly accessible base URL of your application. While prefixed with NEXT_PUBLIC_ (allowing client-side access), it's used server-side in this guide to construct the full statusCallback URL. Update this when using ngrok or deploying.
  5. Project Structure: Your basic structure will look something like this (using app router):

    twilio-status-callback-guide/ ├── app/ │ ├── api/ │ │ ├── twilio/ │ │ │ └── status/ │ │ │ └── route.ts # Our webhook handler │ │ └── send-sms/ # API route to send SMS │ │ └── route.ts │ ├── lib/ │ │ └── prisma.ts # Prisma client instance │ └── page.tsx # Simple UI to send a message (optional) ├── prisma/ │ ├── schema.prisma # Database schema definition │ └── dev.db # SQLite database file (after migration) ├── .env # Environment variables (DO NOT COMMIT) ├── next.config.mjs ├── package.json └── tsconfig.json

Creating a Database Schema and Data Layer

Store incoming status updates in a database.

  1. Define the Prisma Schema: Open prisma/schema.prisma and define a model to log message statuses.

    prisma
    // prisma/schema.prisma
    
    generator client {
      provider = "prisma-client-js"
    }
    
    datasource db {
      provider = "sqlite" // Or your chosen provider
      url      = env("DATABASE_URL")
    }
    
    model MessageStatusLog {
      id          Int      @id @default(autoincrement())
      messageSid  String   @unique // Twilio's Message SID
      status      String   // e.g., 'queued', 'sent', 'delivered', 'failed'
      errorCode   String?  // Twilio error code if status is 'failed' or 'undelivered'
      rawPayload  Json     // Store the full webhook payload for debugging/future use
      timestamp   DateTime @default(now()) // When the log entry was created
    }
    • Store the messageSid (unique per message), the status, an optional errorCode, the full rawPayload as JSON for auditing, and a timestamp.
  2. Apply the Schema to the Database: Run the Prisma command to create the database file (for SQLite) and the MessageStatusLog table.

    bash
    npx prisma db push
    • This command synchronizes your database schema with your schema.prisma definition. For production workflows, use prisma migrate dev and prisma migrate deploy.
  3. Create a Prisma Client Instance: Create a reusable Prisma client instance. Create app/lib/prisma.ts:

    typescript
    // app/lib/prisma.ts
    import { PrismaClient } from '@prisma/client';
    
    const prismaClientSingleton = () => {
      return new PrismaClient({
        // Optional: Log Prisma queries during development
        // log: ['query', 'info', 'warn', 'error'],
      });
    };
    
    declare const globalThis: {
      prismaGlobal: ReturnType<typeof prismaClientSingleton>;
    } & typeof global;
    
    const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();
    
    export default prisma;
    
    if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma;
    • This pattern prevents creating multiple Prisma client instances during hot-reloading in development, maintaining a single connection pool to the database.

Citation2. From source: https://www.prisma.io/docs/orm/more/help-and-troubleshooting/help-articles/nextjs-prisma-client-dev-practices, Title: Prisma Client Singleton Pattern Next.js 2024, Text: The best practice is to instantiate a single instance of PrismaClient and save it on the globalThis object. This approach ensures only one instance of the client is created, preventing connection pool exhaustion during Next.js hot reloading in development mode. This pattern is specifically documented for Next.js 15 compatibility.

Implementing the Core Functionality (Webhook API Route)

Create the API route that receives POST requests from Twilio.

  1. Create the API Route File: Create the file app/api/twilio/status/route.ts.

  2. Implement the Webhook Handler:

    typescript
    // app/api/twilio/status/route.ts
    import { NextRequest, NextResponse } from 'next/server';
    import twilio from 'twilio';
    import prisma from '@/app/lib/prisma'; // Adjust path if needed
    import { URLSearchParams } from 'url'; // Node.js native URLSearchParams
    
    // Fetch environment variables
    const twilioAuthToken = process.env.TWILIO_AUTH_TOKEN || '';
    
    export async function POST(req: NextRequest) {
      if (!twilioAuthToken) {
        console.error('Twilio Auth Token not configured in environment variables.');
        return new NextResponse('Configuration error: Twilio Auth Token missing.', { status: 500 });
      }
    
      // Construct the full URL for validation
      const fullUrl = req.url;
      if (!fullUrl) {
         console.error('Could not determine request URL.');
         return new NextResponse('Internal Server Error: Cannot determine request URL.', { status: 500 });
      }
    
      // Get Twilio signature from headers
      const signature = req.headers.get('x-twilio-signature') || '';
    
      try {
        // Get the raw body text
        const rawBody = await req.text();
        // Parse the raw body into parameters
        const params = new URLSearchParams(rawBody);
        const paramsObject = Object.fromEntries(params.entries());
    
        // Validate the request signature
        const isValid = twilio.validateRequest(
          twilioAuthToken,
          signature,
          fullUrl,
          paramsObject
        );
    
        if (!isValid) {
          console.warn('Invalid Twilio signature received.');
          return new NextResponse('Invalid Twilio Signature', { status: 403 });
        }
    
        // --- Signature is valid, process the status update ---
    
        const messageSid = params.get('MessageSid');
        const messageStatus = params.get('MessageStatus');
        const errorCode = params.get('ErrorCode') || null;
    
        if (!messageSid || !messageStatus) {
          console.warn('Webhook received without MessageSid or MessageStatus.');
          return new NextResponse('Missing required parameters', { status: 400 });
        }
    
        console.log(`Received status update for SID: ${messageSid}, Status: ${messageStatus}, ErrorCode: ${errorCode}`);
    
        // Store the update in the database
        try {
          await prisma.messageStatusLog.create({
            data: {
              messageSid: messageSid,
              status: messageStatus,
              errorCode: errorCode,
              rawPayload: paramsObject,
            },
          });
          console.log(`Successfully logged status for SID: ${messageSid}`);
        } catch (dbError: any) {
           // Handle unique constraint violation if reprocessing
           if (dbError.code === 'P2002' && dbError.meta?.target?.includes('messageSid')) {
             console.warn(`Duplicate status update ignored for SID: ${messageSid}, Status: ${messageStatus}`);
             return new NextResponse('Status update acknowledged (duplicate)', { status: 200 });
           }
           console.error(`Database error logging status for SID ${messageSid}:`, dbError);
           return new NextResponse('Database error', { status: 500 });
        }
    
        // Respond to Twilio with 200 OK
        return new NextResponse(null, { status: 200 });
    
      } catch (error: any) {
        console.error('Error processing Twilio status webhook:', error);
        return new NextResponse('Webhook handler error', { status: 500 });
      }
    }

    Explanation:

    1. Environment Variables: Retrieve the TWILIO_AUTH_TOKEN.
    2. Access Raw Body: In Next.js App Router Route Handlers, access the raw body using req.text() for signature validation.
    3. Get Signature & URL: Extract the x-twilio-signature header and the full request URL. Twilio uses the full URL in signature calculation.
    4. Parse Body: Parse the application/x-www-form-urlencoded string into key-value pairs using URLSearchParams and convert to an object.
    5. Validate Signature: Use twilio.validateRequest with your Auth Token (acting as the secret), the signature, the full URL, and the parsed parameters object. This is the critical security step.
    6. Handle Invalid Signature: If validation fails, log a warning and return 403 Forbidden.
    7. Extract Data: If valid, extract MessageSid, MessageStatus, and ErrorCode from the parsed parameters.
    8. Store in Database: Use the Prisma client to save the information and the full payload.
    9. Handle DB Errors: Handle unique constraint violations (P2002) and log other DB errors.
    10. Respond 200 OK: Send an HTTP 200 OK response back to Twilio to acknowledge receipt.
    11. General Error Handling: A top-level try...catch logs unexpected errors and returns 500 Internal Server Error.

Integrating with Twilio (Sending the Message)

Now we need a way to send a message and tell Twilio where to send status updates.

  1. Create a Simple UI (Optional but helpful for testing): Let's add a basic button in app/page.tsx to trigger sending an SMS. Note that the example UI code includes a placeholder phone number (+15558675309) which must be replaced with a valid number you can send messages to for testing.

    tsx
    // app/page.tsx
    ""use client""; // This component needs to be a Client Component for onClick
    
    import { useState } from 'react';
    
    export default function HomePage() {
      const [messageSid, setMessageSid] = useState<string | null>(null);
      const [status, setStatus] = useState<string>('');
      const [error, setError] = useState<string | null>(null);
      const [isLoading, setIsLoading] = useState<boolean>(false);
    
      const handleSendMessage = async () => {
        setIsLoading(true);
        setMessageSid(null);
        setStatus('Sending...');
        setError(null);
    
        try {
          const response = await fetch('/api/send-sms', { // We'll create this API route next
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              // IMPORTANT: Replace with a valid E.164 formatted phone number you can test with
              to: '+15558675309',
              body: `Hello from Next.js! Testing status callbacks. [${Date.now()}]`,
            }),
          });
    
          const data = await response.json();
    
          if (!response.ok) {
            throw new Error(data.error || 'Failed to send message');
          }
    
          setMessageSid(data.sid);
          setStatus(`Message queued (SID: ${data.sid}). Check logs/DB for status updates.`);
    
        } catch (err: any) {
          console.error(""Send message error:"", err);
          setError(err.message || 'An unknown error occurred.');
          setStatus('Failed to initiate sending.');
        } finally {
           setIsLoading(false);
        }
      };
    
      return (
        <main style={{ padding: '2rem' }}>
          <h1>Twilio Status Callback Test</h1>
          <button onClick={handleSendMessage} disabled={isLoading}>
            {isLoading ? 'Sending...' : 'Send Test SMS'}
          </button>
          <div style={{ marginTop: '1rem' }}>
            <p><strong>Status:</strong> {status}</p>
            {messageSid && <p><strong>Message SID:</strong> {messageSid}</p>}
            {error && <p style={{ color: 'red' }}><strong>Error:</strong> {error}</p>}
          </div>
          <p style={{ marginTop: '2rem', fontStyle: 'italic' }}>
            After sending, check your application logs and database (`MessageStatusLog` table) for status updates received via the webhook. If testing locally, ensure ngrok is running and pointing to your development server.
          </p>
        </main>
      );
    }
  2. Create the Sending API Route: Create app/api/send-sms/route.ts to handle the actual Twilio API call.

    typescript
    // app/api/send-sms/route.ts
    import { NextRequest, NextResponse } from 'next/server';
    import twilio from 'twilio';
    
    // Fetch environment variables securely on the server
    const accountSid = process.env.TWILIO_ACCOUNT_SID;
    const authToken = process.env.TWILIO_AUTH_TOKEN;
    const fromNumber = process.env.TWILIO_PHONE_NUMBER_OR_MSG_SID;
    // Use the potentially public base URL variable from env
    const appBaseUrl = process.env.NEXT_PUBLIC_APP_BASE_URL;
    
    if (!accountSid || !authToken || !fromNumber || !appBaseUrl) {
      console.error(""Twilio credentials or App Base URL are not configured in environment variables."");
      // Do not expose which specific variable is missing to the client
      // Throwing an error here will result in a 500 response handled by Next.js
      throw new Error(""Server configuration error."");
    }
    
    const client = twilio(accountSid, authToken);
    
    export async function POST(req: NextRequest) {
      try {
        const body = await req.json();
        const to = body.to;
        const messageBody = body.body;
    
        if (!to || !messageBody) {
          return NextResponse.json({ error: 'Missing ""to"" or ""body"" parameter' }, { status: 400 });
        }
    
        // Construct the absolute StatusCallback URL
        const statusCallbackUrl = `${appBaseUrl}/api/twilio/status`;
    
        console.log(`Sending SMS to: ${to}`);
        console.log(`Using StatusCallback URL: ${statusCallbackUrl}`);
    
        const message = await client.messages.create({
          body: messageBody,
          from: fromNumber,
          to: to,
          // THIS IS THE KEY: Tell Twilio where to send status updates
          statusCallback: statusCallbackUrl,
          // Optional: Specify which events trigger a callback
          // statusCallbackEvent: ['sent', 'failed', 'delivered', 'undelivered'], // Default includes all terminal states
        });
    
        console.log('Message sent successfully, SID:', message.sid);
        return NextResponse.json({ sid: message.sid, status: message.status });
    
      } catch (error: any) {
        console.error('Error sending Twilio message:', error);
        // Avoid sending detailed internal errors to the client
        let errorMessage = 'Failed to send message.';
        // Example check for specific Twilio error
        // if (error.code === 21211) { // Invalid 'To' number
        //   errorMessage = ""Invalid recipient phone number."";
        // }
        // Return a generic error message, potentially logging the specific details server-side
        return NextResponse.json({ error: errorMessage, details: error.message }, { status: 500 });
      }
    }

    Explanation:

    • Initializes the Twilio client using environment variables.
    • Defines a POST handler expecting JSON with to and body.
    • Constructs the statusCallback URL using NEXT_PUBLIC_APP_BASE_URL and the webhook route path (/api/twilio/status).
    • Calls client.messages.create, passing recipient, sender, body, and the crucial statusCallback URL.
    • Returns the message.sid and initial status (e.g., queued).
    • Includes basic error handling.
  3. Local Development with ngrok: Twilio needs to send POST requests to your application, so your local server (http://localhost:3000) must be publicly accessible.

    • Install ngrok: Follow instructions at ngrok.com.
    • Run ngrok: Open a new terminal window (keep your Next.js dev server running) and execute:
      bash
      ngrok http 3000
    • Get Public URL: ngrok will display a ""Forwarding"" URL (e.g., https://<random-subdomain>.ngrok-free.app). This is your public URL.
    • Update .env: Copy the https://... ngrok URL and update NEXT_PUBLIC_APP_BASE_URL in your .env file:
      dotenv
      # .env (Example update)
      NEXT_PUBLIC_APP_BASE_URL=""https://<random-subdomain>.ngrok-free.app""
    • Restart Next.js Dev Server: Stop (Ctrl+C) and restart (npm run dev) your Next.js server to load the updated environment variable.

    Now, when sending a message:

    • The statusCallback URL sent to Twilio will use your public ngrok URL.
    • Twilio can send POST requests to this URL.
    • ngrok forwards these requests to http://localhost:3000/api/twilio/status.
    • Monitor requests in the ngrok terminal and your Next.js logs.

Implementing Proper Error Handling, Logging, and Retry Mechanisms

  • Error Handling (Webhook):
    • The /api/twilio/status route uses try...catch for validation, DB operations, and general processing.
    • Returning non-200 status codes (403, 500) signals issues to Twilio. Twilio webhook retry behavior varies by configuration.
    • Specific DB error handling (e.g., P2002 for duplicates) prevents noise if Twilio resends data.
  • Logging:
    • Use console.log, console.warn, console.error for basic logging. In production, adopt a structured logging library (e.g., pino, winston) and send logs to an aggregation service (e.g., Datadog, Logtail, Axiom).
    • Log key events: Webhook receipt, validation status, data extraction, DB operations. Include MessageSid for correlation.
  • Retry Mechanisms (Twilio's Side):
    • Twilio webhook retry behavior for SMS callbacks: By default, Twilio retries once on TCP connect or TLS handshake failures. For additional retry attempts (up to 5 times), configure connection overrides by appending parameters like #rc=5 to your webhook URL. Without configuration, Twilio does not automatically retry on 5xx HTTP errors for SMS webhooks.
    • Ensure your endpoint is idempotent: processing the same status update multiple times shouldn't cause errors. The DB logging handles duplicates gracefully by ignoring them and returning 200 OK.

Citation4. From source: https://www.twilio.com/docs/usage/webhooks/webhooks-connection-overrides and https://stackoverflow.com/questions/35449228/what-is-max-number-of-retry-attempts-for-twilio-sms-callback, Title: Twilio Webhook Retry Configuration 2024, Text: Twilio can retry webhooks up to 5 times on connection failures. By default, Twilio retries once on TCP connect or TLS handshake failures. You can configure additional retries (0-5 attempts) using connection overrides by adding #rc=5 to your webhook URL. For SMS/phone call webhooks, Twilio does not automatically retry to the same URL on 5xx errors without explicit configuration.

  • Testing Error Scenarios:
    • Invalid Signature: Temporarily change TWILIO_AUTH_TOKEN in .env, restart, send a message. Webhook validation should fail (403). Alternatively, use curl or Postman to send a request without a valid signature.
    • DB Error: Introduce a temporary error in the prisma.messageStatusLog.create call (e.g., misspell a field) to observe the 500 response and logs.
    • Missing Parameters: Use a tool like Postman to send a validly signed request but omit MessageSid. Verify the 400 Bad Request response.

Adding Security Features

  • Webhook Signature Validation: Primary security mechanism, implemented in /api/twilio/status using twilio.validateRequest. Prevents fake status updates.
  • HTTPS: Always use HTTPS for your webhook URL in production. ngrok provides HTTPS locally. Deployment platforms (Vercel, Netlify) enforce HTTPS. HTTPS encrypts traffic using TLS and prevents replay attacks.
  • Environment Variables: Secure Twilio credentials (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) using environment variables. Do not hardcode or commit them. Use platform secret management in production.
  • Input Validation: Basic checks (e.g., existence of MessageSid, MessageStatus) are implemented as good practice.
  • Rate Limiting (Optional): Consider rate limiting (e.g., upstash/ratelimit) for high-traffic public endpoints to prevent abuse, though less critical for specific webhooks unless targeted attacks are a concern.

Handling Special Cases Relevant to the Domain

  • Webhook Order: Twilio does not guarantee status callbacks arrive in the order events occurred. A sent callback might arrive after delivered.
    • Solution: Consider event timestamps or logical status progression if updating a single record per message. This approach of creating a new log entry for each status naturally preserves the arrival order and the status received at that time. The timestamp field in MessageStatusLog helps reconstruct history.

Citation5. From source: https://www.twilio.com/docs/messaging/guides/track-outbound-message-status, Title: Twilio Webhook Order and Timing 2024, Text: Status callback requests are HTTP requests subject to differences in latency caused by changing network conditions. There is no guarantee that status callback requests always arrive at your endpoint in the order they were sent. Applications should be designed to handle out-of-order webhook delivery.

  • Error Codes: When MessageStatus is failed or undelivered, check the ErrorCode field. Consult the Twilio Error Dictionary for meanings (e.g., 30003 – Unreachable, 30007 – Carrier Violation). Log these codes.
  • Different Channels: Payloads for channels like WhatsApp may have extra fields. Storing the rawPayload as JSON preserves this data.
  • DLR RawDlrDoneDate: SMS/MMS callbacks might include RawDlrDoneDate (YYMMDDhhmm) for carrier's final status timestamp. Parse and store if needed.

Implementing Performance Optimizations

  • Database Indexing: Prisma automatically indexes @id. The @unique constraint on messageSid also creates an index, ensuring efficient lookups/checks.
  • Async Operations: The webhook handler uses async/await correctly, avoiding blocking operations.
  • Webhook Response Time: Respond to Twilio quickly (within 15 seconds) with 200 OK after validation. Perform time-consuming tasks after responding or asynchronously (background jobs/queues) if needed. The current implementation (validate, DB write, respond) is typically fast enough.
  • Caching: Generally not applicable for receiving webhook status updates.

Adding Monitoring, Observability, and Analytics

  • Health Checks: Create a simple health check endpoint (e.g., /api/health) returning 200 OK.
  • Logging: Use structured logging and send logs to a centralized platform (see Section 5).
  • Error Tracking: Integrate a service like Sentry or Bugsnag to capture unhandled exceptions automatically.
    bash
    npm install @sentry/nextjs
    # Follow Sentry's Next.js setup guide
  • Metrics & Dashboards:
    • Log key metrics: Webhooks received, validation success/fail, DB write success/error, processing time.
    • Visualize metrics using your logging/monitoring platform.
    • Track MessageStatus distribution (delivered vs. failed) and common ErrorCode values.
  • Alerting: Configure alerts for:
    • High rate of 5xx or 403 errors from the webhook.
    • Spikes in specific ErrorCode values.
    • Health check failures.

Troubleshooting and Caveats

  • Callbacks Not Received:
    • Public URL? Verify ngrok or deployment URL is correct and accessible.
    • Correct statusCallback URL? Check the URL used in messages.create (including https://, domain, path /api/twilio/status). Verify logs from the sending API route.
    • Server Running? Ensure the Next.js server is running and reachable.
    • Twilio Debugger: Check Monitor > Logs > Error logs in the Twilio Console. Filter for webhook failures related to your URL. Look for HTTP response codes Twilio received from your endpoint.
    • Firewall/Network Issues: Ensure no firewalls are blocking Twilio's IPs (less common with standard hosting/ngrok).
  • Signature Validation Fails (403):
    • Correct TWILIO_AUTH_TOKEN? Ensure the TWILIO_AUTH_TOKEN in your .env exactly matches the Auth Token shown in your Twilio Console. Remember to restart the server after .env changes.
    • Correct URL used in validation? Ensure twilio.validateRequest uses the exact URL Twilio called, including protocol (https://) and any query parameters (though unlikely for status webhooks). req.url in Next.js App Router should provide this.
    • Correct Parameters used in validation? Ensure the parsed key-value pairs from the raw request body (paramsObject in the example) are passed to validateRequest. Do not pass the raw string or a re-serialized version.
  • Database Errors:
    • Schema Mismatch? Ensure prisma/schema.prisma matches the actual database structure (npx prisma db push or migrations applied).
    • Connection Issues? Verify DATABASE_URL is correct and the database is reachable.
    • Permissions? Ensure the application has write permissions to the database.
    • Unique Constraint (P2002)? This is expected if Twilio retries and sends the exact same MessageSid. The code handles this by logging a warning and returning 200 OK. If happening unexpectedly, investigate why duplicate SIDs are being processed.
  • Idempotency: If your processing logic beyond simple logging is complex, ensure it can handle receiving the same status update multiple times without causing incorrect side effects.
  • ngrok Session Expiry: Free ngrok sessions expire and provide a new URL each time you restart ngrok. Remember to update NEXT_PUBLIC_APP_BASE_URL and restart your Next.js server whenever the ngrok URL changes. Consider a paid ngrok plan for stable subdomains if needed frequently during development.

Frequently Asked Questions About Twilio Delivery Status Callbacks

How do I set up Twilio StatusCallback webhooks in Next.js?

Set up Twilio StatusCallback webhooks in Next.js by creating an API route at /api/twilio/status/route.ts and passing the webhook URL in the statusCallback parameter when sending messages via client.messages.create(). The webhook endpoint must be publicly accessible (use ngrok for local development) and validate incoming requests using twilio.validateRequest() with your Auth Token to ensure requests originate from Twilio. Store status updates in a database using Prisma for tracking delivery, failures, and error codes.

How does Twilio webhook signature validation work?

Twilio webhook signature validation uses HMAC-SHA1 to sign requests with your Auth Token as the secret key. Extract the x-twilio-signature header from incoming requests and validate it using twilio.validateRequest(authToken, signature, fullUrl, paramsObject) from the Twilio Node.js SDK. This prevents fake status updates from unauthorized sources. Always use the exact full URL Twilio called (including protocol and query parameters) and the parsed request body parameters for validation. Twilio strongly recommends using SDK validation methods rather than implementing custom validation.

What information does Twilio send in StatusCallback webhooks?

Twilio sends MessageSid (unique message identifier), MessageStatus (queued, sent, delivered, failed, undelivered), ErrorCode (if delivery failed), and channel-specific parameters in StatusCallback webhooks. The parameters vary by messaging channel (SMS, MMS, WhatsApp) and are subject to change without notice. Store the complete rawPayload as JSON to preserve all webhook data for debugging and future use. Common status values include 'queued' (accepted), 'sent' (dispatched to carrier), 'delivered' (confirmed by carrier), 'failed' (permanent failure), and 'undelivered' (temporary failure).

How do I handle failed SMS deliveries with Twilio webhooks?

Handle failed SMS deliveries by checking the MessageStatus field for 'failed' or 'undelivered' values in your webhook handler. Extract the ErrorCode field to determine the failure reason – consult the Twilio Error Dictionary for meanings (e.g., 30003 for unreachable numbers, 30007 for carrier violations). Log error codes to your database for analysis and implement retry logic for transient failures. For permanent failures (invalid numbers, blocked content), mark messages as undeliverable and notify users through alternative channels. Design your webhook endpoint to be idempotent to handle duplicate status updates.

Does Twilio automatically retry failed webhook deliveries?

Twilio does not automatically retry webhook deliveries on 5xx HTTP errors for SMS callbacks by default. By default, Twilio retries once on TCP connect or TLS handshake failures only. Configure additional retry attempts (up to 5 times) by appending connection override parameters like #rc=5 to your webhook URL. Ensure your endpoint responds with 200 OK within the timeout period and implements idempotent processing to handle duplicate status updates gracefully. Return 500 status codes for temporary errors where retry is appropriate, and 200 OK for successfully processed requests (including duplicates).

What database schema should I use for storing Twilio status updates?

Use a database schema with messageSid (unique identifier), status (current message state), errorCode (optional error details), rawPayload (full JSON webhook data), and timestamp (when status was received) fields. Set messageSid as unique to prevent duplicate entries. Store the complete webhook payload as JSON for debugging and accessing channel-specific fields. Create indexes on messageSid for efficient lookups and consider adding createdAt timestamps for time-series analysis. Use Prisma with the singleton pattern to prevent connection pool exhaustion during Next.js hot reloading in development.

How do I secure Twilio webhooks in production?

Secure Twilio webhooks by implementing signature validation using twilio.validateRequest(), hosting endpoints on HTTPS (prevents replay attacks via TLS encryption), storing credentials in environment variables (never hardcode), and optionally adding rate limiting for high-traffic scenarios. Always validate the x-twilio-signature header before processing webhook data. Use platform secret management (Vercel Environment Variables, AWS Secrets Manager) for production deployments. Consider adding IP allowlisting for Twilio's webhook source IPs and implement proper error handling to avoid exposing internal system details in error responses.

How do I test Twilio webhooks locally with ngrok?

Test Twilio webhooks locally by installing ngrok (ngrok.com/download), running ngrok http 3000 in a separate terminal to expose your local server, copying the HTTPS forwarding URL (e.g., https://abc123.ngrok-free.app), updating NEXT_PUBLIC_APP_BASE_URL in your .env file with the ngrok URL, and restarting your Next.js development server. The webhook endpoint will be accessible at https://abc123.ngrok-free.app/api/twilio/status. Monitor incoming requests in the ngrok terminal and your Next.js logs. Free ngrok sessions expire and generate new URLs on each restart, requiring environment variable updates.

Why are Twilio status callbacks arriving out of order?

Twilio status callbacks arrive out of order because they are HTTP requests subject to network latency variations. Twilio does not guarantee callbacks arrive in chronological order – a 'sent' callback might arrive after 'delivered'. Design your application to handle out-of-order delivery by creating a new log entry for each status update with timestamps rather than updating a single record. Use the timestamp field to reconstruct the actual event timeline. Consider implementing state machine logic if you need to enforce status progression rules, but accept that callbacks reflect network delivery timing rather than actual event sequence.

What Twilio error codes should I monitor in webhook callbacks?

Monitor critical Twilio error codes including 30003 (unreachable destination), 30004 (message blocked), 30005 (unknown destination), 30006 (landline or unreachable carrier), 30007 (carrier violation/spam filter), 30008 (unknown error), and 21610 (message not sent due to account suspension). Log error codes to your database with full context for analysis. Set up alerts for high error rates or specific codes indicating systemic issues. Consult the Twilio Error Dictionary for complete error code meanings and recommended remediation steps. Implement different handling strategies for temporary errors (retry) versus permanent failures (mark undeliverable).

Frequently Asked Questions

How to track Twilio SMS delivery status in Next.js?

Set up a webhook endpoint in your Next.js application using an API route. This endpoint will receive real-time status updates from Twilio as your messages are sent, delivered, or encounter issues. The guide recommends using Prisma to store these updates in a database for reliable tracking.

What is a Twilio status callback?

A Twilio status callback is an HTTP POST request sent by Twilio to your application. It contains information about the delivery status of your SMS/MMS messages, such as 'queued', 'sent', 'delivered', or 'failed'. This allows for real-time monitoring and handling of message delivery events.

Why use Prisma with Twilio status callbacks?

Prisma is a next-generation ORM that simplifies database interactions in Node.js and TypeScript. It's used in this guide to store the message status updates received from Twilio, ensuring type safety and efficient database operations. Prisma's schema management and migrations make it easy to manage database changes.

When to use ngrok with Twilio webhooks?

ngrok is essential for local development when your Next.js application isn't publicly accessible. ngrok creates a public tunnel to your localhost, allowing Twilio to reach your webhook endpoint during testing. You'll need to update your `NEXT_PUBLIC_APP_BASE_URL` environment variable with the ngrok URL.

Can I use a different database with Prisma for this?

Yes, Prisma supports various databases like PostgreSQL, MySQL, and SQLite. The guide uses SQLite for simplicity, but you can change the `datasource-provider` in your `schema.prisma` and update the `DATABASE_URL` environment variable accordingly. Choose the database best suited for your project's needs.

How to secure Twilio webhook endpoints in Next.js?

The most important security measure is validating the Twilio request signature. The provided code demonstrates how to use the `twilio.validateRequest` function to verify that incoming requests originate from Twilio and haven't been tampered with. Always use HTTPS in production.

What is the `statusCallback` parameter in Twilio?

The `statusCallback` parameter in the Twilio Messages API is the URL where Twilio will send status updates about your message. It should point to your Next.js API route, e.g., `/api/twilio/status`, which is set up to handle these updates. ngrok or your deployed application URL should be used for the public portion of the URL.

How to handle Twilio webhook errors in Next.js?

Proper error handling involves using try-catch blocks in your webhook route to catch potential errors during validation, database interactions, or request processing. Return appropriate status codes (e.g., 403 for invalid signatures, 500 for server errors) so Twilio knows to retry or stop. Logging these errors is crucial for debugging.

What if Twilio status callbacks arrive out of order?

Twilio doesn't guarantee status updates will arrive in order of occurrence. The guide suggests creating a new database log entry per update, preserving the received status and timestamp. This approach eliminates order dependency, and provides status update history via the database.

Where to find Twilio error codes explained?

The Twilio Error Dictionary (available on Twilio's website) provides detailed explanations of error codes you might encounter during message sending or delivery. Refer to it when you receive `ErrorCode` values in status callbacks to understand what went wrong and implement appropriate handling logic.

How to test Twilio status callbacks locally?

Use ngrok to expose your local development server, ensuring the `NEXT_PUBLIC_APP_BASE_URL` in your .env file is set to your ngrok URL. Send a test SMS through your app's interface, or use a tool like Postman to send requests directly to the webhook, observing logs for request processing and status updates.

Why store the full Twilio webhook payload?

Storing the `rawPayload` as JSON in your database captures all data sent by Twilio, including channel-specific details (like WhatsApp metadata) that might not be explicitly handled by your code. This provides valuable information for debugging, analysis, and adapting to future changes in Twilio's payload structure.

What does idempotent mean for Twilio webhooks?

Idempotency means your webhook can handle receiving the same status update multiple times without adverse effects. Twilio might retry sending callbacks if there are network issues, so ensure your logic (database updates, etc.) functions correctly even if a callback is processed more than once. The example code provides idempotency for database entries via a unique constraint.

What are best practices for logging Twilio status updates?

Log key events like webhook receipt, validation outcome, extracted data, and database operations. Include the `MessageSid` in your logs to correlate events related to a specific message. Use structured logging and a centralized logging platform (e.g., Datadog, Logtail) for production-level logging and analysis.