code examples

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

Twilio SMS with Fastify & Node.js: Build Two-Way Messaging (2025 Guide)

Complete guide to building two-way SMS with Twilio, Node.js, and Fastify. Learn webhook handling, TwiML responses, request validation, and production deployment with code examples.

Build Two-Way SMS with Twilio, Node.js & Fastify

Learn how to build two-way SMS messaging with Twilio and Fastify in this comprehensive Node.js tutorial. This guide covers everything from setting up Twilio webhooks and sending SMS messages to implementing request validation, TwiML responses, and production-ready security practices.

You'll master everything from initial project setup and core messaging logic to essential production considerations like security, error handling, deployment, and testing. By the end, you'll have a solid foundation for integrating two-way SMS communication into your services. While this guide covers core production considerations, true "production readiness" depends heavily on your specific application's scale, risk profile, and requirements – you may need deeper dives into databases, advanced monitoring, and scaling strategies beyond this foundational guide's scope.

Project Overview and Goals

What We're Building:

We will create a Node.js application using the Fastify framework that:

  1. Receives Inbound SMS: Listens for incoming SMS messages sent to a designated Twilio phone number via a webhook.
  2. Processes and Replies: Parses the incoming message and sends an automated reply back to the sender using Twilio's TwiML.
  3. Sends Outbound SMS: Exposes a secure API endpoint to programmatically send SMS messages to specified recipients via the Twilio REST API.

Problem Solved:

This application provides the core infrastructure needed for various SMS-based features, such as:

  • Automated customer support bots.
  • Two-factor authentication (2FA) code delivery.
  • Appointment reminders and notifications.
  • Marketing alerts (ensure compliance).
  • Interactive SMS campaigns.

Technologies Used:

  • Node.js: A JavaScript runtime environment ideal for building scalable, event-driven network applications. Learn more about Node.js server frameworks.
  • Fastify: A high-performance, low-overhead web framework for Node.js focused on developer experience and speed. Compare Fastify vs Express for API development.
  • Twilio Programmable Messaging: A cloud communications platform providing REST APIs and tools for sending and receiving SMS messages globally. Explore Twilio SMS API documentation.
  • twilio Node.js Helper Library: Simplifies interaction with the Twilio API.
  • dotenv / @fastify/env: Manages environment variables securely and efficiently.
  • ngrok (for development): A tool to expose local development servers to the internet for webhook testing.

System Architecture:

text
graph LR
    subgraph ""User's Phone""
        U(User)
    end

    subgraph ""Twilio Cloud""
        T_API(Twilio API)
        T_NUM(Twilio Phone Number)
        T_WH(Twilio Webhook)
    end

    subgraph ""Your Infrastructure""
        subgraph ""Development (Local Machine)""
            NG(ngrok Tunnel)
            APP_DEV(Fastify App - Dev)
        end
        subgraph ""Production (Cloud/Server)""
            APP_PROD(Fastify App - Prod)
            LB(Load Balancer/Firewall)
        end
    end

    subgraph ""API Client""
        API_C(Your Service/App)
    end

    %% Inbound Flow
    U -- Sends SMS --> T_NUM
    T_NUM -- Triggers Webhook --> T_WH

    %% Development Inbound
    T_WH -- Forwards Request --> NG
    NG -- Relays Request --> APP_DEV

    %% Production Inbound
    T_WH -- Sends Request --> LB
    LB -- Forwards Request --> APP_PROD

    %% Reply Flow (Common)
    subgraph ""Fastify Application (Dev or Prod)""
        FASTIFY(Fastify Instance)
        IN_ROUTE(Inbound Route /webhooks/sms/twilio)
        OUT_ROUTE(Outbound Route /api/send-sms)
        TW_CLIENT(Twilio Client)
    end

    APP_DEV --> IN_ROUTE
    APP_PROD --> IN_ROUTE
    IN_ROUTE -- Generates TwiML --> T_WH
    T_WH -- Sends Reply SMS --> U

    %% Outbound Flow
    API_C -- POST Request --> OUT_ROUTE
    OUT_ROUTE -- Uses Twilio Client --> TW_CLIENT
    TW_CLIENT -- Calls API --> T_API
    T_API -- Sends SMS --> U

    %% Link components within Fastify App
    IN_ROUTE -- Processes --> FASTIFY
    OUT_ROUTE -- Processes --> FASTIFY
    FASTIFY -- Loads Config --> TW_CLIENT

Prerequisites:

  • Node.js and npm (or yarn): Installed on your system. Node.js 20.x (Active LTS) or Node.js 22.x (Active LTS until October 2025) recommended for production. Node.js 18.x reached end-of-life on April 30, 2025 and should not be used for new projects. Download Node.js
  • Twilio Account: A free or paid Twilio account (Sign up for Twilio here).
  • Twilio Phone Number: An SMS-capable phone number purchased within your Twilio account.
  • Twilio Account SID and Auth Token: Found on your main Twilio Console dashboard.
  • ngrok (Required for local webhook testing): Installed and authenticated (ngrok website).
  • Basic Terminal/Command Line Knowledge.
  • Text Editor or IDE: Such as VS Code.

Expected Outcome:

A functional Fastify application running locally (exposed via ngrok) or deployed, capable of receiving SMS messages, replying automatically, and sending messages via an API endpoint, complete with basic security and error handling.

Setting Up Your Twilio Fastify Project

Let's initialize our Node.js project and install the necessary dependencies.

  1. Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.

    bash
    mkdir fastify-twilio-sms
    cd fastify-twilio-sms
  2. Initialize Node.js Project: This creates a package.json file.

    bash
    npm init -y
  3. Install Dependencies: We need Fastify, its environment variable handler, the Twilio helper library, dotenv (as a fallback and for clarity), raw-body for request validation, and pino-pretty for development logging.

    bash
    npm install fastify @fastify/env twilio dotenv raw-body pino-pretty
    • fastify: The core web framework.
    • @fastify/env: Loads and validates environment variables based on a schema.
    • twilio: Official Node.js library for the Twilio API.
    • dotenv: Loads environment variables from a .env file into process.env. @fastify/env can use this too.
    • raw-body: Needed to capture the raw request body for Twilio signature validation.
    • pino-pretty: Development dependency to format Fastify's default Pino logs nicely.
  4. Set up Environment Variables: Create a file named .env in the root of your project. Never commit this file to version control. Populate it with your Twilio credentials and application settings:

    dotenv
    # .env
    
    # Twilio Credentials - Get from https://www.twilio.com/console
    TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    TWILIO_AUTH_TOKEN=your_auth_token_xxxxxxxxxxxxxx
    TWILIO_PHONE_NUMBER=+15551234567 # Your purchased Twilio number
    
    # Application Settings
    PORT=3000
    HOST=0.0.0.0 # Listen on all available network interfaces
    NODE_ENV=development # Set to 'production' when deployed
    LOG_LEVEL=info # Pino log level (trace, debug, info, warn, error, fatal)
    
    # Add this later when using ngrok for webhook validation (Section 7)
    # Make sure this matches EXACTLY the URL configured in Twilio Console
    # TWILIO_WEBHOOK_URL=https://<your-ngrok-subdomain>.ngrok.io/webhooks/sms/twilio
    • Replace placeholders with your actual Account SID, Auth Token, and Twilio phone number.
    • HOST=0.0.0.0 is important for running inside containers or VMs.
  5. Configure .gitignore: Create a .gitignore file in the project root to prevent sensitive information and unnecessary files from being committed to Git.

    text
    # .gitignore
    
    # Dependencies
    node_modules/
    
    # Environment Variables
    .env
    .env.*
    !.env.example
    
    # Logs
    logs/
    *.log
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    
    # Build output
    dist/
    build/
    
    # OS generated files
    .DS_Store
    Thumbs.db
  6. Create Server File (server.js): This file will define how to build the Fastify app and optionally start it.

    javascript
    // server.js
    'use strict';
    
    const Fastify = require('fastify');
    const fastifyEnv = require('@fastify/env');
    const twilio = require('twilio');
    const getRawBody = require('raw-body');
    const { validateRequest } = require('twilio');
    
    // Define schema for environment variables
    const envSchema = {
      type: 'object',
      required: [
        'PORT',
        'HOST',
        'TWILIO_ACCOUNT_SID',
        'TWILIO_AUTH_TOKEN',
        'TWILIO_PHONE_NUMBER',
      ],
      properties: {
        PORT: { type: 'string', default: 3000 },
        HOST: { type: 'string', default: '0.0.0.0' },
        NODE_ENV: { type: 'string', default: 'development' }, // Default is sufficient
        LOG_LEVEL: { type: 'string', default: 'info' },
        TWILIO_ACCOUNT_SID: { type: 'string' },
        TWILIO_AUTH_TOKEN: { type: 'string' },
        TWILIO_PHONE_NUMBER: { type: 'string' },
        TWILIO_WEBHOOK_URL: { type: 'string' } // Required for reliable webhook validation
      },
    };
    
    // Function to build the Fastify app instance
    async function buildApp (opts = {}) {
      // Configure Fastify logger based on environment
      const loggerConfig =
        process.env.NODE_ENV === 'development'
          ? {
              transport: {
                target: 'pino-pretty',
                options: {
                  translateTime: 'HH:MM:ss Z',
                  ignore: 'pid,hostname',
                },
              },
              level: process.env.LOG_LEVEL || 'info',
            }
          : { level: process.env.LOG_LEVEL || 'info' }; // Basic JSON logs in production
    
      const fastify = Fastify({
        logger: loggerConfig,
        ...opts // Pass any additional options for testing, etc.
      });
    
      // Register @fastify/env to load and validate environment variables
      await fastify.register(fastifyEnv, {
        confKey: 'config', // Access environment variables via `fastify.config`
        schema: envSchema,
        dotenv: true, // Load .env file using dotenv
      });
    
      // Add hook to capture raw body *before* Fastify parses it for the webhook
      fastify.addHook('preParsing', async (request, reply, payload) => {
        if (request.routerPath === '/webhooks/sms/twilio') {
          const contentType = request.headers['content-type'];
          if (contentType) {
            try {
              // Capture raw body as a string for validation
              request.rawBodyString = await getRawBody(payload, {
                length: request.headers['content-length'],
                limit: '1mb', // Adjust limit as needed
                encoding: true // Let raw-body detect encoding (should be utf-8 from Twilio)
              });
            } catch (err) {
               fastify.log.error({ err }, 'Failed to capture raw body');
               // Let Fastify handle the error downstream, maybe return error here?
               // For now, just log it. Validation will likely fail.
            }
          }
        }
        // IMPORTANT: Return the original payload stream for Fastify to continue parsing
        return payload;
      });
    
      // Decorate Fastify instance with Twilio client (initialized onReady)
      let twilioClient;
      fastify.decorate('twilioClient', null);
      fastify.addHook('onReady', async () => {
          twilioClient = twilio(fastify.config.TWILIO_ACCOUNT_SID, fastify.config.TWILIO_AUTH_TOKEN);
          fastify.twilioClient = twilioClient; // Make client available via fastify.twilioClient
          fastify.log.info('Twilio client initialized.');
      });
    
      // --- Register Routes ---
      registerRoutes(fastify);
    
      return fastify;
    }
    
    
    // --- Route Definitions ---
    function registerRoutes(fastify) {
    
        // === Inbound SMS Webhook ===
        fastify.post('/webhooks/sms/twilio', async (request, reply) => {
          fastify.log.info(
            { body: request.body, headers: request.headers },
            'Received inbound SMS webhook from Twilio'
          );
    
          // --- Security Validation (See Section 7) ---
          const twilioSignature = request.headers['x-twilio-signature'];
          const webhookUrl = fastify.config.TWILIO_WEBHOOK_URL; // Must be set in config for reliable validation
    
          if (!webhookUrl) {
              fastify.log.error('TWILIO_WEBHOOK_URL environment variable is not set. Cannot validate request.');
              return reply.status(500).send('Webhook URL configuration error.');
          }
          if (!twilioSignature) {
               fastify.log.warn('Request received without X-Twilio-Signature.');
               return reply.status(400).send('Missing Twilio Signature');
          }
          if (!request.rawBodyString) {
              fastify.log.error('Raw request body was not captured. Cannot validate request.');
              return reply.status(500).send('Internal server error processing request body.');
          }
    
          const isValid = validateRequest(
            fastify.config.TWILIO_AUTH_TOKEN,
            twilioSignature,
            webhookUrl,
            request.rawBodyString // Pass the raw body STRING to the validator
          );
    
          if (!isValid) {
            fastify.log.warn({ signature: twilioSignature, url: webhookUrl }, 'Invalid Twilio signature received.');
            return reply.status(403).send('Invalid Twilio Signature');
          }
          fastify.log.info('Twilio signature validated successfully.');
          // --- End Security Validation ---
    
          // Extract details from Twilio's request payload
          const sender = request.body.From;
          const recipient = request.body.To; // Your Twilio Number
          const messageBody = request.body.Body;
          const messageSid = request.body.MessageSid;
    
          fastify.log.info(
            `Message SID ${messageSid} from ${sender}: ""${messageBody}""`
          );
    
          // Create a TwiML response using the Twilio helper library
          const twiml = new twilio.twiml.MessagingResponse();
    
          // Simple auto-reply logic
          const replyText = `Thanks for messaging! You said: ""${messageBody}""`;
          twiml.message(replyText);
    
          // Send the TwiML response back to Twilio
          reply.type('text/xml').send(twiml.toString());
    
          // Fastify handles sending the response when reply.send() is called. No explicit return needed.
        });
    
    
        // === Outbound SMS API ===
        const sendSmsSchema = {
          body: {
            type: 'object',
            required: ['to', 'body'],
            properties: {
              to: { type: 'string', description: 'E.164 formatted phone number', pattern: '^\\+[1-9]\\d{1,14}$' }, // ITU-T E.164: max 15 digits (1-3 country code + max 12 subscriber number)
              body: { type: 'string', minLength: 1, maxLength: 1600 },
            },
          },
          response: { // Optional: Define expected response structure
            200: {
                type: 'object',
                properties: {
                    success: { type: 'boolean' },
                    messageSid: { type: 'string' },
                    status: { type: 'string' }
                }
            },
            // Add error responses if needed (e.g., 4xx, 5xx)
          }
        };
        // SMS Character Limits (Twilio Official Documentation - 2025):
        // - Maximum message body: 1600 characters (enforced by Twilio API)
        // - Single segment limits:
        //   * GSM-7 encoding: 160 characters per segment
        //   * UCS-2 encoding (Unicode): 70 characters per segment
        // - Concatenated message segments (multi-part):
        //   * GSM-7: 153 characters per segment (7 chars reserved for concatenation header)
        //   * UCS-2: 67 characters per segment (3 chars reserved for concatenation header)
        // - Billing: Each segment is billed individually
        // - Recommendation: Keep messages ≤320 characters for best deliverability
        // See: https://www.twilio.com/docs/glossary/what-sms-character-limit
    
        // Endpoint to send an outbound SMS
        fastify.post('/api/send-sms', { schema: sendSmsSchema }, async (request, reply) => {
          const { to, body } = request.body;
    
          fastify.log.info(`Attempting to send SMS to ${to}: ""${body}""`);
    
          try {
            // Use the Twilio client decorated onto the fastify instance
            const message = await fastify.twilioClient.messages.create({
              body: body,
              from: fastify.config.TWILIO_PHONE_NUMBER, // Your Twilio number
              to: to, // Recipient's number
            });
    
            fastify.log.info(
              `SMS sent successfully! SID: ${message.sid}, Status: ${message.status}`
            );
            return reply.status(200).send({
              success: true,
              messageSid: message.sid,
              status: message.status, // e.g., 'queued', 'sending', 'sent'
            });
    
          } catch (error) {
            fastify.log.error(
              { err: { message: error.message, code: error.code, status: error.status } }, // Log Twilio error details
              `Failed to send SMS to ${to}`
            );
            // Customize error response based on Twilio error codes if needed
            return reply.status(error.status || 500).send({
              success: false,
              message: error.message || 'Failed to send SMS',
              code: error.code || null, // Twilio specific error code
            });
          }
        });
    
    
        // === Health Check Endpoint ===
        fastify.get('/health', async (request, reply) => {
          // Add checks for DB connection, Twilio client status, etc. if needed
          return { status: 'ok', timestamp: new Date().toISOString() };
        });
    
    } // End registerRoutes
    
    
    // --- Server Start Logic ---
    // Only run the server start if this script is executed directly
    if (require.main === module) {
      buildApp().then(fastify => {
        fastify.listen({
          port: fastify.config.PORT,
          host: fastify.config.HOST,
        }, (err, address) => {
          if (err) {
            fastify.log.error(err);
            process.exit(1);
          }
          // Logger already logs listening info if configured correctly
          fastify.log.info(
            `Twilio configured with SID: ${fastify.config.TWILIO_ACCOUNT_SID.substring(0, 5)}... and Phone: ${fastify.config.TWILIO_PHONE_NUMBER}`
          );
        });
      }).catch(err => {
        console.error('Error building or starting the application:', err);
        process.exit(1);
      });
    }
    
    // Export the build function for testing or programmatic use
    module.exports = buildApp;
    • Refactoring: The code is now wrapped in buildApp which returns the fastify instance. The server only starts if the file is run directly (node server.js). buildApp is exported.
    • envSchema: NODE_ENV removed from required.
    • Webhook Validation: Uses request.rawBodyString captured via preParsing and getRawBody. Removed fallback for webhookUrl and added checks for its presence and the signature/raw body.
    • Twilio Client: Now decorated onto fastify instance in onReady hook for better access.
    • Health Check: Added /health route (from Section 10) here for completeness.
    • Regex Fix: Corrected regex pattern in sendSmsSchema to include $ anchor: ^\\+[1-9]\\d{1,14}$.
    • Logging Fix: Replaced triple quotes """""" with standard double quotes "" in log messages.
  7. Add Run Script to package.json: Modify your package.json to include convenient scripts for running the server:

    json
    // package.json (add/update scripts section)
    ""scripts"": {
      ""start"": ""node server.js"",
      ""dev"": ""node server.js""
    },
  8. Initial Run: You should now be able to start your basic server:

    bash
    npm run dev

    If successful, you'll see log output indicating the server is listening and confirming your Twilio SID/Number were loaded. Press Ctrl+C to stop it.

Handling Inbound SMS Messages with Twilio Webhooks

The webhook endpoint /webhooks/sms/twilio is now defined within server.js (in the registerRoutes function). It includes:

  • Listening for POST requests.
  • Logging incoming data.
  • Security Validation: Implemented directly within the route handler (using validateRequest with the raw body string).
  • Parsing sender, recipient, and message body.
  • Generating a TwiML response using twilio.twiml.MessagingResponse.
  • Sending the text/xml response back to Twilio.

Testing with ngrok and Twilio Console:

  1. Start ngrok: Open a new terminal window and run:

    bash
    ngrok http 3000 # Use the PORT defined in your .env

    Copy the https forwarding URL (e.g., https://<unique-subdomain>.ngrok.io).

  2. Set TWILIO_WEBHOOK_URL: Open your .env file, uncomment the TWILIO_WEBHOOK_URL line, and paste your full ngrok https URL including the path:

    dotenv
    # .env (update this line)
    TWILIO_WEBHOOK_URL=https://<unique-subdomain>.ngrok.io/webhooks/sms/twilio

    Important: Restart your Fastify server after changing .env for the new value to be loaded.

  3. Start your Fastify server: In your original terminal:

    bash
    npm run dev
  4. Configure Twilio Webhook:

    • Go to your Twilio Console.
    • Navigate to Phone Numbers > Manage > Active Numbers.
    • Click on your Twilio phone number.
    • Scroll to ""Messaging"".
    • Under ""A MESSAGE COMES IN"", select ""Webhook"".
    • Paste your exact ngrok https URL (including /webhooks/sms/twilio) into the box.
    • Ensure the method is HTTP POST.
    • Click ""Save"".
  5. Send a Test SMS: Send an SMS from your phone to your Twilio number.

  6. Check Logs & Reply: Observe your Fastify server logs. You should see the incoming request logged, the ""Twilio signature validated successfully"" message, and details of the message. You should receive the auto-reply on your phone. If validation fails, check the logs for errors (e.g., URL mismatch, missing signature, incorrect Auth Token).

Sending Outbound SMS Messages via API

The API endpoint /api/send-sms is also defined in server.js (within registerRoutes). It includes:

  • Listening for POST requests.
  • Schema Validation: Uses Fastify's schema (sendSmsSchema) to validate the request body (to, body). Invalid requests get a 400 response automatically.
  • Using the initialized fastify.twilioClient to call messages.create.
  • Sending the SMS via the Twilio API.
  • Handling success and errors, returning JSON responses with appropriate status codes and details (including Twilio error codes on failure).
  • SMS Length Clarification: A comment near the schema explains the implications of the 1600 character limit (concatenation and billing).

Testing the Outbound API:

Use curl or Postman (ensure your server is running):

bash
curl -X POST http://localhost:3000/api/send-sms \
     -H ""Content-Type: application/json"" \
     -d '{
           ""to"": ""+15559876543"",
           ""body"": ""Hello from Fastify and Twilio!""
         }'

Expected Output (Success):

json
{
  ""success"": true,
  ""messageSid"": ""SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"",
  ""status"": ""queued""
}

Check the target phone for the SMS and your server logs for details.

Configuring Your Twilio Phone Number and Webhooks

  • API Keys (Account SID & Auth Token): Used for authenticating requests to Twilio (sending SMS). Stored in .env.
  • Twilio Phone Number: Used as From for outbound and target for inbound. Stored in .env.
  • Webhook URL Configuration: Tells Twilio where to send inbound SMS events. Configured in Twilio Console. Must match TWILIO_WEBHOOK_URL in your .env for validation to work. Use ngrok URL for development, public deployed URL for production. Method: HTTP POST.

Implementing Error Handling and Logging for SMS

  • Error Handling:
    • Fastify handles schema validation errors (400).
    • try...catch blocks handle Twilio API errors (outbound). Log error.code, error.status, error.message.
    • Webhook security validation returns 403/500 on failure.
    • Graceful handling of TwiML generation/sending errors (ensure valid XML or error response).
    • Consider a Fastify global error handler (fastify.setErrorHandler) for unhandled exceptions.
  • Logging:
    • pino-pretty in development, JSON in production.
    • Controlled by LOG_LEVEL.
    • Log relevant context (SIDs, numbers, errors).
  • Retries:
    • Twilio retries failed webhooks. Ensure your endpoint is idempotent if actions aren't safe to repeat.
    • Implement custom retries for outbound API calls only if necessary (e.g., for critical messages and transient errors), potentially using a queue.

Storing SMS Messages: Database Schema Examples

While this basic guide doesn't implement a database, real-world applications often need to store message history, user data, or conversation state.

  • When Needed: Storing logs, tracking conversations, linking to users.

  • Schema Ideas (Example using Prisma):

    prisma
    // schema.prisma
    datasource db { provider = ""postgresql"" url = env(""DATABASE_URL"") }
    generator client { provider = ""prisma-client-js"" }
    
    model MessageLog {
      id          String   @id @default(cuid())
      createdAt   DateTime @default(now())
      updatedAt   DateTime @updatedAt
      direction   String   // ""inbound"" or ""outbound""
      twilioSid   String   @unique // Twilio's Message SID
      accountSid  String
      fromNumber  String
      toNumber    String
      body        String?  @db.Text
      status      String?  // Twilio status
      errorCode   Int?     // Twilio error code
      errorMessage String?
      // userId      String?
      // user        User?   @relation(fields: [userId], references: [id])
    }
  • Implementation: Choose DB, use ORM (Prisma, Sequelize), install drivers, define schema, run migrations, integrate client into Fastify (e.g., via fastify-plugin).

Securing Twilio Webhooks with Request Validation

Security is critical.

  1. Twilio Request Validation (Implemented in Section 1/2):

    • Uses raw-body via preParsing hook to capture the raw request body string.
    • Requires TWILIO_WEBHOOK_URL to be correctly set in .env and loaded via @fastify/env. Reminder: Ensure this variable is uncommented and set to your correct ngrok (dev) or public (prod) URL.
    • Uses twilio.validateRequest with Auth Token, signature header, exact URL, and the raw body string.
    • Returns 403 Forbidden if validation fails. Returns 500 if configuration (URL) or body capture fails.
  2. API Key / Token Authentication (for /api/send-sms):

    • Recommendation: Protect the outbound API.
    • Options: Simple static API key (check in preHandler), JWT (@fastify/jwt), OAuth 2.0 (fastify-oauth2). Implementation not shown in this guide.
  3. Rate Limiting:

    • Recommendation: Prevent abuse of the outbound API.

    • Implementation: Use @fastify/rate-limit.

      bash
      npm install @fastify/rate-limit
      javascript
      // server.js (register the plugin within buildApp)
      await fastify.register(require('@fastify/rate-limit'), {
        max: 100, // Max requests per time window per IP
        timeWindow: '1 minute'
      });
  4. Input Validation:

    • Handled for /api/send-sms via Fastify schema. Ensure schemas are strict.
  5. Helmet (Security Headers):

    • Recommendation: Add standard security headers.

    • Implementation: Use @fastify/helmet.

      bash
      npm install @fastify/helmet
      javascript
      // server.js (register the plugin within buildApp)
      await fastify.register(require('@fastify/helmet'));

Understanding SMS Character Limits and International Messaging

  • Message Concatenation: Messages exceeding single-segment limits are automatically split by Twilio into multiple segments. Single segment: 160 GSM-7 chars or 70 UCS-2 chars. Concatenated segments: 153 GSM-7 chars or 67 UCS-2 chars per segment due to header overhead. Each segment is billed individually. Inbound concatenated messages arrive as one payload with the full message body reassembled.
  • Character Encoding: Use UTF-8 in your application. Twilio automatically detects and applies GSM-7 or UCS-2 encoding based on message content. Non-GSM-7 characters (emojis, accented characters) trigger UCS-2 encoding with reduced character limits.
  • International Numbers: Always use E.164 format (+1..., +44..., etc.). Country code is required for international delivery.
  • Opt-Outs (STOP/HELP): Twilio automatically handles standard English opt-out keywords (STOP, UNSTOP, START, HELP, INFO) on long codes and Toll-Free numbers by default. Respect opt-outs to maintain compliance. For custom keyword handling or non-English keywords, implement logic in your webhook handler. See: Twilio Opt-Out Documentation
  • Non-SMS Channels: TwiML structure and webhook payloads differ for WhatsApp, Facebook Messenger, and other channels. Consult channel-specific Twilio documentation for integration.

Optimizing Node.js Performance for SMS Applications

  • Asynchronous Operations: Use async/await for all I/O (Twilio calls, DB).
  • Payload Size: Keep TwiML/API responses concise.
  • Caching: Consider Redis (@fastify/redis) for frequent lookups if needed.
  • Load Testing: Use k6, autocannon.
  • Node.js Version: Use Node.js 20.x (Active LTS) or 22.x (Active LTS) for production. Avoid Node.js 18.x (EOL April 30, 2025).
  • Profiling: Use Node.js profiler or 0x.

Monitoring Your Twilio SMS Integration

  • Health Checks: /health endpoint implemented (see server.js).
  • Twilio Debugger & Logs: Check Twilio Console Debugger and Message Logs.
  • Application Logging: Aggregate production JSON logs (Datadog, ELK). Set up alerts.
  • Metrics: Track message counts, latency, errors using prom-client/fastify-metrics (for Prometheus) or push to Datadog.
  • Error Tracking: Use Sentry (@sentry/node) or Bugsnag.

Troubleshooting Twilio SMS Webhooks and API Errors

  • Webhook Failures (Twilio Debugger): 11200 (unreachable/timeout), 12100/12200 (bad TwiML), 12300 (bad Content-Type), 11205 (SSL issue).
  • ngrok Issues: Tunnel expired, HTTP vs HTTPS mismatch, URL typo.
  • Request Validation Failure (403 Invalid Twilio Signature): Incorrect Auth Token, incorrect TWILIO_WEBHOOK_URL (must match exactly), body modified before validation (check raw body capture), validating non-Twilio request.
  • Outbound Sending Failures (Check error.code): 21211 (bad 'To'), 21610 (STOP), 21614 (not SMS capable), 3xxxx (carrier/delivery issues), 20003 (low balance). Full List.
  • Environment Variables: Ensure .env loaded or variables set in production.
  • Firewalls: Allow traffic from Twilio IPs if restricting.

Deploying Your Fastify SMS Application to Production

  • Platform: PaaS (Heroku, Render), IaaS (EC2, GCE), Containers (Docker, Kubernetes).
  • Production Config: NODE_ENV=production, secure environment variables (use platform's secrets management), HOST=0.0.0.0, update Twilio webhook URL.
  • Build Step: If needed (tsc, Babel).
  • Process Manager: pm2 recommended (pm2 start server.js -i max).
  • CI/CD: GitHub Actions, GitLab CI, etc. (Install -> Lint -> Test -> Build -> Deploy).
  • Rollback: Have a plan.

Twilio SMS with Fastify: Frequently Asked Questions

What Node.js version should I use for Twilio SMS with Fastify?

Use Node.js 20.x (Active LTS) or Node.js 22.x (Active LTS until October 2025) for production applications. Node.js 18.x reached end-of-life on April 30, 2025 and should not be used for new projects.

How many characters can I send in a Twilio SMS message?

Twilio supports up to 1,600 characters per message through automatic concatenation. Single segments support 160 characters (GSM-7) or 70 characters (UCS-2/Unicode). Concatenated messages use 153 characters (GSM-7) or 67 characters (UCS-2) per segment due to header overhead. Each segment is billed individually. For best deliverability, keep messages under 320 characters.

What is TwiML and why do I need it for inbound SMS?

TwiML (Twilio Markup Language) is Twilio's XML-based instruction language. When your webhook receives an inbound SMS, you respond with TwiML XML to tell Twilio what action to take – such as sending an auto-reply using the <Message> verb. The twilio.twiml.MessagingResponse() helper generates valid TwiML automatically.

How do I validate that webhooks are actually from Twilio?

Twilio signs all webhook requests with the X-Twilio-Signature header using HMAC-SHA1. Use twilio.validateRequest() with your Auth Token, the signature header, the exact webhook URL, and the raw request body to verify authenticity. This prevents spoofed requests from malicious actors.

Why do I need ngrok for local development?

Twilio's servers need to reach your local development machine to deliver inbound SMS webhooks. ngrok creates a secure tunnel that exposes your localhost:3000 to the internet with a public HTTPS URL that Twilio can access. In production, you'll use your deployed application's public URL instead.

What's the difference between GSM-7 and UCS-2 encoding?

GSM-7 supports basic Latin characters, numbers, and common symbols – allowing 160 characters per SMS segment. UCS-2 (Unicode) supports all characters including emojis, accented characters, and non-Latin alphabets – but reduces capacity to 70 characters per segment. Twilio automatically selects the encoding based on your message content.

How do I handle SMS opt-outs (STOP messages)?

Twilio automatically handles standard English opt-out keywords (STOP, UNSTOP, START, HELP, INFO) on long codes and Toll-Free numbers. When someone texts STOP, Twilio blocks future messages to that number automatically. Check error code 21610 when sending to detect opted-out recipients. For custom keyword handling, implement logic in your webhook handler.

Can I use Fastify instead of Express for Twilio webhooks?

Yes! Fastify offers superior performance and developer experience compared to Express. This guide demonstrates production-ready Twilio integration with Fastify, including proper webhook validation using the preParsing hook to capture raw request bodies required for signature verification.

What Twilio error codes should I handle in my application?

Key error codes to handle:

  • 21211: Invalid 'To' phone number format
  • 21610: Recipient has opted out (STOP)
  • 21614: 'To' number is not SMS-capable (landline)
  • 20003: Authentication failed (check credentials)
  • 11200: Webhook unreachable/timeout
  • 12300: Invalid Content-Type in webhook response

See the official Twilio error dictionary for the complete list.

How much does Twilio SMS cost?

Twilio pricing varies by country and message type. US SMS typically costs $0.0079 per segment sent. Remember that concatenated messages count as multiple segments – a 200-character message uses 2 segments and costs 2× the base price. Check Twilio's pricing page for current rates in your target countries.

Testing Your Twilio SMS Integration

Ensure correctness.

  • Manual Verification Checklist:

      • Server starts dev/prod.
      • ngrok active, TWILIO_WEBHOOK_URL set, Twilio Console URL matches.
      • Inbound SMS -> Check logs (valid request, signature OK), check reply received.
      • Outbound API (/api/send-sms) -> Check success response, check SMS received.
      • Outbound API (invalid body) -> Check 400 error.
      • Outbound API (invalid number) -> Check Twilio error logged/returned.
      • Check Twilio Debugger.
      • Check /health endpoint.
  • Automated Testing:

    • Unit Tests: Test functions in isolation. Mock twilio client (jest.fn(), sinon).

    • Integration Tests: Test route handlers interacting with services (mock externals like Twilio). Use fastify.inject(). Example (Conceptual Jest with refactored server.js):

      javascript
      // tests/outbound.test.js
      const buildApp = require('../server'); // Import the app builder
      const twilio = require('twilio');
      
      // Mock the Twilio client constructor and methods
      const mockMessagesCreate = jest.fn();
      jest.mock('twilio', () => {
          // Mock the constructor to return an object with a messages mock
          return jest.fn().mockImplementation(() => ({
              messages: { create: mockMessagesCreate }
          }));
      });
      
      let app;
      
      // Build a new app instance for tests
      beforeAll(async () => {
          // Optionally override config for tests if needed
          // process.env.SOME_VAR = 'test_value';
          app = await buildApp();
          await app.ready(); // Ensure plugins, hooks (like onReady) are loaded
      });
      
      afterAll(async () => {
          await app.close();
      });
      
      beforeEach(() => {
          // Reset mocks before each test
          mockMessagesCreate.mockClear();
          // Reset Twilio constructor mock if needed
          twilio.mockClear();
      });
      
      test('POST /api/send-sms should send SMS successfully', async () => {
          const mockResponse = { sid: 'SMtest', status: 'queued' };
          mockMessagesCreate.mockResolvedValue(mockResponse);
      
          const response = await app.inject({
              method: 'POST',
              url: '/api/send-sms',
              payload: {
                  to: '+15551112222',
                  body: 'Test message'
              }
          });
      
          expect(response.statusCode).toBe(200);
          expect(JSON.parse(response.payload)).toEqual({
              success: true,
              messageSid: mockResponse.sid,
              status: mockResponse.status
          });
          expect(twilio).toHaveBeenCalledTimes(1); // Check constructor called
          expect(mockMessagesCreate).toHaveBeenCalledTimes(1);
          expect(mockMessagesCreate).toHaveBeenCalledWith({
              to: '+15551112222',
              body: 'Test message',
              from: app.config.TWILIO_PHONE_NUMBER // Check correct 'from' number used
          });
      });
      
      test('POST /api/send-sms should handle Twilio API errors', async () => {
          const mockError = new Error('Twilio Test Error');
          mockError.code = 21211; // Example error code
          mockError.status = 400;
          mockMessagesCreate.mockRejectedValue(mockError);
      
          const response = await app.inject({
              method: 'POST',
              url: '/api/send-sms',
              payload: {
                  to: '+15551112222', // Invalid number for this test case
                  body: 'Test message causing error'
              }
          });
      
          expect(response.statusCode).toBe(400);
          expect(JSON.parse(response.payload)).toEqual({
              success: false,
              message: 'Twilio Test Error',
              code: 21211
          });
          expect(mockMessagesCreate).toHaveBeenCalledTimes(1);
      });
      
      test('POST /api/send-sms should return 400 for invalid payload', async () => {
          const response = await app.inject({
              method: 'POST',
              url: '/api/send-sms',
              payload: {
                  // Missing 'to' field
                  body: 'Test message'
              }
          });
      
          expect(response.statusCode).toBe(400);
          // Check for Fastify's validation error structure
          const payload = JSON.parse(response.payload);
          expect(payload.message).toContain(""body must have required property 'to'"");
          expect(mockMessagesCreate).not.toHaveBeenCalled();
      });
      
      // Add tests for the inbound webhook (/webhooks/sms/twilio)
      // - Mock validateRequest
      // - Inject POST request with valid/invalid signature
      // - Check TwiML response

Frequently Asked Questions

How to send SMS messages with Twilio and Node.js?

Use the Twilio Programmable Messaging API with a Node.js library like `twilio`. This allows you to send outbound SMS messages via a REST API by providing the recipient's number and message body in your API request. The provided code example demonstrates setting up a secure API endpoint ('/api/send-sms') using Fastify to programmatically send messages via Twilio.

What is Fastify and why use it with Twilio?

Fastify is a high-performance Node.js web framework known for its speed and developer-friendly experience. Its efficiency makes it ideal for handling the real-time, event-driven nature of SMS communication with the Twilio API. The provided code example uses Fastify to create both inbound and outbound SMS routes and leverages hooks for initialization.

How to receive inbound SMS messages with Twilio?

Set up a webhook URL in your Twilio Console that points to your application's endpoint (e.g., '/webhooks/sms/twilio'). Twilio will send an HTTP POST request to this URL whenever a message is sent to your Twilio number. The example code provides a comprehensive route handler to securely process these requests and respond with TwiML.

What is a TwiML response and how is it used?

TwiML (Twilio Markup Language) is an XML-based language used to instruct Twilio on how to handle incoming messages or calls. In the provided example, TwiML is used to generate automated replies to inbound SMS messages. The `twilio` Node.js helper library simplifies creating TwiML responses.

How to validate Twilio webhook requests for security?

Use the `twilio.validateRequest` function with your Auth Token, the request signature, webhook URL, and the raw request body. This ensures the request originated from Twilio. The example code demonstrates how to use a 'preParsing' hook with `raw-body` to capture the request body before Fastify processes it, allowing validation of the signature before handling the request content.

How to set up ngrok for local Twilio webhook testing?

Run 'ngrok http <your-port>' to create a public tunnel to your local server. Use the generated HTTPS URL as your webhook URL in the Twilio console and your .env file's 'TWILIO_WEBHOOK_URL' variable. This ensures that Twilio's requests are routed correctly to your local development server. Remember to restart your server after updating .env.

Why does the code use @fastify/env and dotenv?

@fastify/env handles environment variables securely using a schema. dotenv loads environment variables from a .env file which is useful in development for clarity, but never commit your .env to source control. The example code combines these two using @fastify/env's dotenv option.

What is the recommended database schema for storing SMS messages?

While not strictly required for basic functionality, the guide suggests a relational model with fields such as `id`, `direction`, `twilioSid`, `fromNumber`, `toNumber`, `body`, `status`, `errorCode`, and `errorMessage`. This facilitates storing message history and tracking conversations, and optionally linking to other data such as users.

How to improve the security of my Twilio SMS application?

Beyond validating webhook signatures, implement API key/token authentication for your outbound SMS endpoint, use rate limiting to prevent abuse, validate all user inputs strictly, and use a security header package like `@fastify/helmet` to add appropriate headers for added protection against common web vulnerabilities.

When should I implement retries for sending outbound SMS messages?

Twilio handles retries for webhook requests. Implement custom retries for outbound API calls only for essential messages or transient errors. Consider using a message queue for reliable retry mechanisms in cases of more persistent issues. Avoid retries for errors like invalid recipient numbers.

How to troubleshoot Twilio webhook request validation failures?

Common causes include an incorrect Auth Token, mismatched webhook URLs, or modifying the request body before validation. Double-check your .env file's `TWILIO_WEBHOOK_URL` against the URL set in your Twilio Console. Make sure that the URL used in your webhook handler and in the Twilio Console are identical, including the path. Ensure you are using the raw request body string for validation.

What are common Twilio error codes and what do they mean?

Error codes like '21211' (invalid 'To' number), '21610' (user opted out with STOP), and '20003' (insufficient funds) indicate specific issues with your requests to Twilio. Refer to the Twilio error code documentation for comprehensive explanations and resolution steps.

How to handle long SMS messages with Twilio?

Twilio automatically concatenates messages longer than 160 GSM-7 characters (or 70 UCS-2). While inbound messages arrive as a single combined message, outbound messages are split and billed as multiple segments. This should be factored into cost considerations, especially for international messaging.

Can I use a different Node.js framework besides Fastify?

Yes, although the provided example leverages Fastify's performance and features, you can adapt the principles and core logic to other frameworks like Express.js or NestJS. You'll need to implement similar routing, webhook handling, and Twilio API integration within your chosen framework.

What are some performance optimization strategies for Twilio SMS applications?

Use `async/await` for all I/O operations, minimize payload sizes, consider caching for frequent lookups if applicable, and perform load testing to identify bottlenecks. Using a recent LTS version of Node.js and profiling your application can also help optimize performance.