Sent logo
Sent TeamMar 8, 2026 / tools / Article

Build Bulk SMS Broadcasting with Node.js, Fastify, and Vonage Messages API

Complete guide to building a production-ready bulk SMS service using Fastify, Vonage Messages API, and Node.js. Includes rate limiting, error handling, webhooks, and A2P 10DLC compliance.

Build a robust bulk SMS messaging service using Node.js with the Fastify framework and the Vonage Messages API. This comprehensive guide covers everything from initial project setup to deployment and monitoring, focusing on creating a scalable and reliable solution capable of handling significant message volumes.

You'll build a functional Fastify application with an API endpoint that accepts a list of phone numbers and a message, then efficiently sends SMS messages via Vonage. The tutorial incorporates essential production considerations: rate limiting, error handling, security, and monitoring.

Target Audience: Developers familiar with Node.js and basic API concepts looking to implement reliable bulk SMS functionality.

Technologies Used (Verified January 2025):

  • Node.js: JavaScript runtime environment (v20 or later required for Fastify v5).
  • Fastify: High-performance, low-overhead Node.js web framework (v5.x targeting Node.js v20+). Chosen for its speed, extensibility, and excellent developer experience (DX) – especially its built-in validation and logging.
  • Vonage Messages API: A powerful API for sending messages across various channels, including SMS. Chosen for its global reach, reliability, and comprehensive features.
  • @vonage/server-sdk: The official Vonage Node.js SDK (v3.24.1 as of January 2025) for interacting with the API.
  • dotenv: Module for loading environment variables from a .env file.
  • p-limit: A lightweight utility to limit concurrent promise execution – crucial for managing API rate limits.
  • fastify-rate-limit: Fastify plugin for implementing rate limiting on API routes.

System Architecture:

The core architecture follows this flow:

  1. Client: Sends a POST request to the Fastify API endpoint (/bulk-sms) containing a list of recipient phone numbers and the message text. Note: In a production system, the client must authenticate itself (e.g., via API Key).
  2. Fastify Application:
    • Receives the request.
    • (CRITICAL) Authenticates/Authorizes the Client. (Implementation required – see Security section).
    • Validates the input using Fastify's schema validation.
    • Applies rate limiting to the endpoint.
    • Uses the p-limit library to process the recipients list, sending SMS messages via the Vonage SDK while respecting Vonage's concurrency limits (default 30 requests/second per API key).
    • Handles potential errors from the Vonage API.
    • Logs relevant information (requests, successes, errors, status updates).
    • Listens for status webhooks from Vonage to track message delivery asynchronously (requires webhook signature verification).
    • Listens for inbound webhooks from Vonage to handle opt-out replies (e.g., STOP). (Implementation required – see Special Cases section).
    • Returns a response to the client indicating the status of the bulk send operation.
  3. Vonage Messages API: Receives requests from the Fastify application and delivers the SMS messages to the recipients. Sends status and inbound message updates back to configured webhook endpoints.

(Note: Mermaid diagram rendering should be verified on the target publishing platform.)

mermaid
sequenceDiagram
    participant Client
    participant FastifyApp as Fastify Application
    participant VonageAPI as Vonage Messages API

    Client->>+FastifyApp: POST /bulk-sms (recipients, message, Auth Header)
    FastifyApp->>FastifyApp: Authenticate/Authorize Client (e.g., API Key Check)
    alt Authentication Successful
        FastifyApp->>FastifyApp: Validate Request Schema
        FastifyApp->>FastifyApp: Apply Rate Limiting
        loop For each recipient batch (controlled by p-limit)
            FastifyApp->>+VonageAPI: Send SMS Request (to, from, text)
            VonageAPI-->>-FastifyApp: SMS Send Response (message_uuid, status)
            FastifyApp->>FastifyApp: Log Send Attempt/Result
        end
        FastifyApp-->>-Client: Response (e.g., { accepted: true, jobId: 'xyz' })
    else Authentication Failed
        FastifyApp-->>-Client: 401 Unauthorized / 403 Forbidden
    end

    Note over VonageAPI, FastifyApp: Asynchronously
    VonageAPI->>+FastifyApp: POST /webhooks/status (signed payload: message_uuid, status, timestamp)
    FastifyApp->>FastifyApp: Verify Webhook Signature (CRITICAL)
    alt Signature Valid
        FastifyApp->>FastifyApp: Log/Process Message Status Update
        FastifyApp-->>-VonageAPI: 200 OK
    else Signature Invalid
        FastifyApp-->>-VonageAPI: 400 Bad Request / Ignore
    end

    Note over VonageAPI, FastifyApp: Asynchronously (for Opt-Outs)
    VonageAPI->>+FastifyApp: POST /webhooks/inbound (signed payload: msisdn, text="STOP", etc.)
    FastifyApp->>FastifyApp: Verify Webhook Signature (CRITICAL)
    alt Signature Valid
        FastifyApp->>FastifyApp: Process Opt-Out Request (e.g., Add to blocklist)
        FastifyApp-->>-VonageAPI: 200 OK
    else Signature Invalid
        FastifyApp-->>-VonageAPI: 400 Bad Request / Ignore
    end

Prerequisites:

  • Node.js: Version 20.x or later required (Fastify v5 targets Node.js v20+). Note: Node.js v18 reaches end of Long Term Support on April 30, 2025 – upgrade to v20 LTS is recommended for continued security updates.
  • npm or yarn: Package manager for Node.js.
  • Vonage API Account:
    • Sign up at Vonage.com.
    • Obtain your API Key and API Secret from the Vonage API Dashboard.
    • Purchase at least one Vonage virtual number capable of sending SMS. Find this under "Numbers" > "Your numbers".
    • Create a Vonage Application:
      • Go to "Applications" > "Create a new application".
      • Give it a name (e.g., "Fastify Bulk SMS Service").
      • Enable the Messages capability.
      • Configure the Status URL under Messages. For local development, you'll use an ngrok URL (e.g., https://<your-ngrok-id>.ngrok.io/webhooks/status). For production, use your public server URL. Set the HTTP method to POST.
      • Configure the Inbound URL similarly (e.g., https://<your-ngrok-id>.ngrok.io/webhooks/inbound) if you need to handle replies or opt-outs (like STOP messages) – this is crucial for compliance in many regions. Set the HTTP method to POST.
      • Click Generate public and private key. Save the private.key file securely – you'll need its path. Do not commit this file to version control.
      • Note the Application ID generated for this application.
      • Link your purchased Vonage virtual number(s) to this application at the bottom of the application settings page.
    • IMPORTANT: In your Vonage Dashboard -> Settings, ensure Messages API is selected as the default under "API keys" -> "SMS settings".
    • (Critical for US Sending) A2P 10DLC Registration: If sending SMS to US numbers using standard 10-digit long codes (10DLC), you must register your Brand and Campaign via the Vonage dashboard. This process is mandatory for compliance and deliverability with the following requirements:
      • Enforcement Date: As of February 1, 2025, unregistered 10DLC numbers are blocked by US carriers.
      • Registration Process: Two-part registration – (1) Brand registration (your organization entity), (2) Campaign registration (message purpose and type).
      • Timeline: Expect 1-3 weeks total. Brand registration typically takes a few minutes to 2 days if information matches IRS records. Campaign registration typically takes 3-7 business days.
      • Requirements: Valid business tax ID (EIN) required for identity verification.
      • Impact: Affects your allowed sending throughput based on brand trust scores and carrier-specific limits.
      • Restricted Industries: Cannabis/hemp, firearms, payday loans, and third-party collections are not eligible.
      • Consequences: Non-compliance results in delivery failures, high fees, limited rates, or service suspension.
      • Start Early: Begin registration process immediately to avoid service disruption.
  • (Optional) ngrok: For exposing your local development server to receive webhooks from Vonage. (Download ngrok)

1. Setting up the Project

Let's create the project structure and install dependencies.

  1. Create Project Directory:

    bash
    mkdir fastify-vonage-bulk-sms
    cd fastify-vonage-bulk-sms
  2. Initialize Node.js Project:

    bash
    npm init -y

    This creates a package.json file.

  3. Install Dependencies:

    bash
    # Fastify and core dependencies
    npm install fastify @fastify/sensible
    
    # Vonage SDK and environment variables handler
    npm install @vonage/server-sdk dotenv
    
    # Utility for limiting concurrency
    npm install p-limit
    
    # Fastify plugin for rate limiting
    npm install @fastify/rate-limit
    
    # Development dependency for automatically restarting the server
    npm install --save-dev nodemon
  4. Set up Project Structure: Create the following directories and files:

    fastify-vonage-bulk-sms/ ├── src/ │ ├── routes/ │ │ └── sms.js # API route handlers for SMS │ ├── services/ │ │ └── vonage.js # Vonage SDK initialization and helpers │ ├── plugins/ │ │ └── rate-limit.js # Rate limit plugin configuration │ └── app.js # Fastify application setup ├── .env # Environment variables (DO NOT COMMIT) ├── .gitignore # Git ignore file ├── private.key # Your downloaded Vonage private key (DO NOT COMMIT) ├── server.js # Entry point to start the server └── package.json
  5. Configure .gitignore: Create a .gitignore file in the root directory and add the following to prevent committing sensitive information and unnecessary files:

    text
    # Dependencies
    node_modules/
    
    # Environment variables
    .env
    .env.*
    *.env.local
    *.env.development.local
    *.env.test.local
    *.env.production.local
    
    # Vonage private key
    private.key
    
    # Logs
    logs
    *.log
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    pids
    *.pid
    *.seed
    *.pid.lock
    
    # Optional build/dist folders
    dist/
    build/
    
    # Optional Editor directories
    .idea
    .vscode
    *.suo
    *.ntvs*
    *.njsproj
    *.sln
    *.sw?
    
    # OS generated files
    .DS_Store
    Thumbs.db
  6. Configure Environment Variables (.env): Create a .env file in the root directory. WARNING: The placeholder values below (like YOUR_VONAGE_API_KEY) MUST be replaced with your actual Vonage credentials and settings. Do NOT run the application with these placeholder values. Ensure this file is listed in your .gitignore and never committed to version control.

    dotenv
    # --- Vonage API Credentials ---
    # Found in Vonage Dashboard -> API Settings
    # **REPLACE THESE WITH YOUR ACTUAL CREDENTIALS**
    VONAGE_API_KEY=YOUR_VONAGE_API_KEY
    VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
    
    # --- Vonage Application Settings ---
    # Found in Vonage Dashboard -> Applications -> Your Application
    # **REPLACE THESE WITH YOUR ACTUAL APPLICATION DETAILS**
    VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
    # Relative path from project root to your downloaded private key
    # **ENSURE THIS PATH IS CORRECT**
    VONAGE_APPLICATION_PRIVATE_KEY_PATH=./private.key
    
    # --- Vonage Number ---
    # Your purchased Vonage virtual number in E.164 format (e.g., 14155550100)
    # This number MUST be linked to your Vonage Application
    # **REPLACE WITH YOUR ACTUAL VONAGE NUMBER**
    VONAGE_FROM_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER
    
    # --- Application Settings ---
    PORT=3000
    LOG_LEVEL=info # e.g., 'fatal', 'error', 'warn', 'info', 'debug', 'trace'
    
    # --- Rate Limiting (Vonage Specific) ---
    # Max concurrent requests to Vonage API.
    # **Vonage Rate Limit:** 30 requests/second per API key (verified January 2025)
    # Keep slightly below the actual limit (e.g., 25-28) as a safety margin.
    # Contact Vonage to potentially increase this limit if needed.
    # **ADJUST BASED ON YOUR ACCOUNT LIMITS**
    VONAGE_CONCURRENCY_LIMIT=25
    
    # --- Rate Limiting (API Endpoint) ---
    # Max requests per window per user (IP address) to your /bulk-sms endpoint
    API_RATE_LIMIT_MAX=100
    # Time window in milliseconds
    API_RATE_LIMIT_WINDOW_MS=60000 # 1 minute
    
    # --- Security (Required for Production) ---
    # Example: Define an API key for clients calling your /bulk-sms endpoint
    # CLIENT_API_KEY=your_secret_api_key_for_clients
    # For webhook signature verification (use either shared secret or JWT)
    # VONAGE_SIGNATURE_SECRET=your_webhook_shared_secret # If using shared secret method
    • Purpose: Using .env keeps sensitive credentials out of your codebase, making it more secure and easier to manage configurations across different environments (development, staging, production).
  7. Configure nodemon (Optional but Recommended for Development): Add a dev script to your package.json's scripts section:

    json
    // package.json
    {
      // ... other configurations
      "scripts": {
        "start": "node server.js",
        "dev": "nodemon server.js",
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      // ... other configurations
    }

    Now you can run npm run dev to start the server, which will automatically restart when you save changes.


2. Implementing Core Functionality (Bulk Sending Logic)

We'll set up the Vonage SDK client and create the core function to send messages in bulk while respecting concurrency limits.

  1. Initialize Vonage SDK (src/services/vonage.js): This file initializes the Vonage client using credentials from environment variables.

    javascript
    // src/services/vonage.js
    import { Vonage } from '@vonage/server-sdk';
    import { Messages } from '@vonage/messages';
    import dotenv from 'dotenv';
    import path from 'path';
    import { fileURLToPath } from 'url';
    
    // Load environment variables
    dotenv.config();
    
    // Helper to get the directory name in ES module scope
    const __filename = fileURLToPath(import.meta.url);
    const __dirname = path.dirname(__filename);
    
    // Resolve the private key path relative to the project root
    const privateKeyPath = path.resolve(
      __dirname,
      '..', // Move up from 'services'
      '..', // Move up from 'src'
      process.env.VONAGE_APPLICATION_PRIVATE_KEY_PATH || './private.key' // Add default for safety
    );
    
    let vonage;
    let messages;
    
    try {
      // Validate essential config before initializing
      if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET || !process.env.VONAGE_APPLICATION_ID || !process.env.VONAGE_APPLICATION_PRIVATE_KEY_PATH) {
          throw new Error('Missing required Vonage credentials in environment variables (API Key, Secret, App ID, Private Key Path).');
      }
    
      vonage = new Vonage({
        apiKey: process.env.VONAGE_API_KEY,
        apiSecret: process.env.VONAGE_API_SECRET,
        applicationId: process.env.VONAGE_APPLICATION_ID,
        privateKey: privateKeyPath, // Use the resolved absolute path
      }, {
        // Optional: Add custom user agent for easier identification in logs/support
        appendToUserAgent: 'fastify-bulk-sms-guide/1.0.0'
      });
      messages = new Messages(vonage.auth);
    
      console.info('Vonage SDK initialized successfully.');
    
    } catch (error) {
      console.error('Failed to initialize Vonage SDK:', error.message);
      console.error('Ensure VONAGE_API_KEY, VONAGE_API_SECRET, VONAGE_APPLICATION_ID are set and VONAGE_APPLICATION_PRIVATE_KEY_PATH points to the correct private key file.');
      // Exit gracefully - the application cannot function without the SDK.
      process.exit(1);
    }
    
    
    export { vonage, messages }; // Export the initialized clients
    • Why: Encapsulates Vonage SDK initialization. Using environment variables keeps credentials secure. Resolving the private key path ensures it works correctly regardless of where the script is run from. Exiting on failure prevents the app from running in a broken state. Added explicit check for required env vars.
  2. Implement Bulk Send Logic (src/services/vonage.js continued): Add the function to handle sending to multiple recipients using p-limit.

    javascript
    // src/services/vonage.js
    // ... (previous code: imports, dotenv, vonage/messages initialization) ...
    
    import pLimit from 'p-limit';
    
    // Get the concurrency limit from environment variables, default to 25 (aligns with .env example)
    // Adjust this based on your specific Vonage account limits.
    const concurrency = parseInt(process.env.VONAGE_CONCURRENCY_LIMIT || '25', 10);
    const limit = pLimit(concurrency);
    
    const fromNumber = process.env.VONAGE_FROM_NUMBER;
    if (!fromNumber) {
        console.warn('WARNING: VONAGE_FROM_NUMBER is not set in environment variables. SMS sending will fail.');
    }
    
    /**
     * Sends an SMS message to a single recipient using the Vonage Messages API.
     * Handles basic error logging.
     * @param {string} to - Recipient phone number in E.164 format.
     * @param {string} text - The message content.
     * @param {object} logger - Fastify logger instance.
     * @returns {Promise<{success: boolean, recipient: string, messageId: string|null, error: string|null}>}
     */
    async function sendSingleSms(to, text, logger) {
      if (!fromNumber) {
        const errorMsg = 'Server configuration error: Missing VONAGE_FROM_NUMBER.';
        logger.error(errorMsg);
        return { success: false, recipient: to, messageId: null, error: errorMsg };
      }
    
      try {
        logger.info(`Attempting to send SMS to ${to} via Vonage`);
        const resp = await messages.send({
          message_type: 'text',
          text: text,
          to: to,
          from: fromNumber,
          channel: 'sms',
          // Optional: client_ref for correlating status webhooks back to your internal IDs
          // client_ref: `job_${jobId}_recipient_${recipientId}`
        });
    
        logger.info({ recipient: to, messageId: resp.message_uuid, from: fromNumber }, `SMS submitted successfully to ${to}`);
        return { success: true, recipient: to, messageId: resp.message_uuid, error: null };
    
      } catch (err) {
        // Log detailed error information from Vonage if available
        let errorMessage = `Failed to send SMS to ${to}.`;
        if (err.response && err.response.data) {
           // Vonage API often returns structured errors
           const { type, title, detail, instance } = err.response.data;
           errorMessage += ` Vonage Error: ${title || 'Unknown error'} (Type: ${type || 'N/A'}). Detail: ${detail || JSON.stringify(err.response.data)}. Instance: ${instance || 'N/A'}`;
           logger.error({ recipient: to, errorData: err.response.data, statusCode: err.response.status }, errorMessage);
        } else {
           // Fallback for network errors or unexpected issues
           errorMessage += ` Error: ${err.message}`;
           logger.error({ recipient: to, error: err }, errorMessage);
        }
        return { success: false, recipient: to, messageId: null, error: errorMessage };
      }
    }
    
    /**
     * Sends SMS messages to multiple recipients concurrently, respecting the Vonage API rate limits.
     * @param {string[]} recipients - Array of phone numbers in E.164 format.
     * @param {string} message - The message text to send.
     * @param {object} logger - Fastify logger instance for contextual logging.
     * @returns {Promise<Array<{success: boolean, recipient: string, messageId: string|null, error: string|null}>>} - Array of results for each recipient.
     */
    async function sendBulkSms(recipients, message, logger) {
      logger.info(`Initiating bulk SMS send to ${recipients.length} recipients with Vonage concurrency limit ${concurrency}`);
    
      // Create an array of promises, each wrapped by p-limit
      const tasks = recipients.map(recipient => {
        // limit() returns a function that acts like the original async function (sendSingleSms)
        // but respects the concurrency limit. We pass the logger to each individual send task.
        return limit(() => sendSingleSms(recipient, message, logger));
      });
    
      // Execute all tasks concurrently (up to the limit)
      const results = await Promise.allSettled(tasks); // Use allSettled to get results even if some promises reject
    
      // Process results from Promise.allSettled
      const finalResults = results.map((result, index) => {
        if (result.status === 'fulfilled') {
          return result.value; // Contains {success: boolean, recipient: string, ...}
        } else {
          // Handle unexpected errors within the limit wrapper or sendSingleSms itself
          logger.error({ recipient: recipients[index], error: result.reason }, `Unexpected error processing recipient ${recipients[index]} in bulk job.`);
          return { success: false, recipient: recipients[index], messageId: null, error: `Internal processing error: ${result.reason.message || result.reason}` };
        }
      });
    
    
      const successCount = finalResults.filter(r => r.success).length;
      const failureCount = finalResults.length - successCount;
      logger.info(`Bulk SMS send processing completed. Submitted: ${successCount}, Failed Submission: ${failureCount}`);
    
      return finalResults;
    }
    
    export { vonage, messages, sendBulkSms }; // Export the new function
    • Why p-limit? Vonage (and most APIs) have rate limits specifying how many requests you can make concurrently or per second. Sending thousands of requests instantly in a simple loop (for...of + await) is slow and inefficient. Promise.all alone would bombard the API, leading to errors (e.g., 429 Too Many Requests). p-limit ensures that only a specified number (VONAGE_CONCURRENCY_LIMIT) of sendSingleSms promises are active simultaneously, respecting the API's limits while maximizing throughput.
    • Why sendSingleSms? Breaking down the logic makes it testable and reusable. It clearly defines the operation for one recipient.
    • Error Handling: The catch block in sendSingleSms specifically tries to extract detailed error messages from the Vonage SDK's response, which is crucial for debugging. It logs this detailed info. Using Promise.allSettled in sendBulkSms ensures that one failed promise doesn't stop the entire batch, and we can collect results for all attempts.
    • Logging: Injecting the logger allows using Fastify's contextual logging within the service layer.
    • Concurrency Default: Changed the default concurrency in the code parseInt(process.env.VONAGE_CONCURRENCY_LIMIT || '25', 10) to match the .env example and common recommendation.

3. Building the API Layer

Now, let's create the Fastify route that will accept bulk SMS requests.

  1. Define API Route Schema and Handler (src/routes/sms.js):

    javascript
    // src/routes/sms.js
    import { sendBulkSms } from '../services/vonage.js';
    
    // Define schema for request validation and response serialization
    const bulkSmsSchema = {
      // Add security schema for API Key (Example)
      // security: [{ apiKey: [] }],
      // headers: {
      //   type: 'object',
      //   properties: {
      //     'X-API-KEY': { type: 'string' } // Or 'Authorization': { type: 'string' } for Bearer
      //   },
      //   required: ['X-API-KEY'] // Or 'Authorization'
      // },
      body: {
        type: 'object',
        required: ['recipients', 'message'],
        properties: {
          recipients: {
            type: 'array',
            items: {
              type: 'string',
              description: 'Recipient phone number in E.164 format (e.g., +14155550100). E.164 is the ITU-T Recommendation defining international public telecommunication numbering: maximum 15 digits including country code (1-3 digits), prefixed with + sign. No spaces, parentheses, or dashes allowed.',
              // E.164 format: + sign (optional in regex but required in practice), country code 1-3 digits starting with 1-9,
              // followed by remaining digits (national destination code + subscriber number) for total max 15 digits.
              // Pattern breakdown: ^ start, \\+? optional plus, [1-9] country code starts 1-9, \\d{1,14} up to 14 more digits, $ end
              pattern: '^\\+?[1-9]\\d{1,14}$',
              examples: ['+12025550101', '+441234567890', '+8613800138000']
            },
            minItems: 1,
            maxItems: 1000 // Set a reasonable max limit per API request
          },
          message: {
            type: 'string',
            minLength: 1,
            maxLength: 1600, // Max SMS length (Vonage handles segmentation, but be mindful of cost)
            description: 'The text content of the SMS message.',
            examples: ['Hello from our service!']
          }
        },
        additionalProperties: false
      },
      response: {
        202: { // Use 202 Accepted as the job is submitted but not fully complete
          description: 'Bulk SMS job successfully accepted for processing.',
          type: 'object',
          properties: {
            status: { type: 'string', example: 'Accepted' },
            message: { type: 'string', example: 'Bulk SMS job accepted for processing 100 recipients.' },
            totalRecipients: { type: 'integer', example: 100 },
            // Optionally return a job ID for tracking later if using a DB/queue
            // jobId: { type: 'string', format: 'uuid', example: 'a1b2c3d4-e5f6-7890-1234-567890abcdef' }
          }
        },
        400: {
            description: 'Bad Request - Invalid input format or parameters.',
            type: 'object',
            properties: {
                statusCode: { type: 'integer', example: 400 },
                error: { type: 'string', example: 'Bad Request' },
                message: { type: 'string', example: 'body/recipients/0 must match pattern "^\\+?[1-9]\\d{1,14}$"' }
            }
        },
        401: { description: 'Unauthorized - Missing or invalid API Key.', $ref: '#/components/schemas/ErrorResponse' },
        403: { description: 'Forbidden - API Key is valid but lacks permission.', $ref: '#/components/schemas/ErrorResponse' },
        429: { description: 'Too Many Requests - API rate limit exceeded.', $ref: '#/components/schemas/ErrorResponse' },
        500: { description: 'Internal Server Error.', $ref: '#/components/schemas/ErrorResponse' }
        // Define ErrorResponse schema in app.js or shared schemas if needed
      }
    };
    
    // Define schema for the status webhook
    const statusWebhookSchema = {
       // Add security definition if using signature verification
       // security: [{ vonageSignatureAuth: [] }],
       body: {
           type: 'object',
           properties: {
               message_uuid: { type: 'string', format: 'uuid' },
               status: { type: 'string', enum: ['submitted', 'delivered', 'expired', 'failed', 'rejected', 'accepted', 'buffered', 'unknown'] },
               to: { type: 'string', pattern: '^\\+?[1-9]\\d{1,14}$' }, // E.164
               from: { type: 'string', pattern: '^\\+?[1-9]\\d{1,14}$' }, // E.164 or Alphanumeric Sender ID
               timestamp: { type: 'string', format: 'date-time' },
               error: {
                  type: 'object',
                  properties: {
                      code: { type: 'integer', description: 'Vonage error code' },
                      reason: { type: 'string', description: 'Description of the error' }
                  },
                  nullable: true // Error object might not be present
               },
               client_ref: { type: 'string', nullable: true, description: 'Your client reference string, if provided during send.' },
               // Add other potential fields based on Vonage documentation if needed (e.g., price, network_code)
           },
           required: ['message_uuid', 'status', 'to', 'timestamp'] // `from` might not always be present depending on config/errors
       },
       response: {
           200: {
               description: 'Webhook received successfully.',
               type: 'object', // Even an empty object or null body is fine, just need 200 status
               properties: {}
           },
           400: { description: 'Bad Request - Invalid webhook payload or signature.', $ref: '#/components/schemas/ErrorResponse' },
           500: { description: 'Internal Server Error processing webhook.', $ref: '#/components/schemas/ErrorResponse' }
       }
    };
    
    async function smsRoutes(fastify, options) {
    
      // --- Security Hooks (Example - NEEDS IMPLEMENTATION) ---
      // Add a preHandler hook to verify API keys for the /bulk-sms route
      // fastify.addHook('preHandler', async (request, reply) => {
      //   if (request.routerPath === '/bulk-sms') { // Apply only to this route
      //     const apiKey = request.headers['x-api-key']; // Or from Authorization header
      //     if (!apiKey || apiKey !== process.env.CLIENT_API_KEY) { // Simple check (use secure comparison)
      //       reply.code(401).send({ error: 'Unauthorized', message: 'Missing or invalid API Key' });
      //       return; // Stop processing
      //     }
      //   }
      // });
      // Add a preHandler hook to verify webhook signatures (more complex)
      // fastify.addHook('preHandler', async (request, reply) => {
      //   if (request.routerPath.startsWith('/webhooks/')) {
      //      // Implement Vonage signature verification logic here
      //      // See: https://developer.vonage.com/en/concepts/signed-webhooks
      //      const isValid = verifyVonageSignature(request.headers, request.body, process.env.VONAGE_SIGNATURE_SECRET); // Example function
      //      if (!isValid) {
      //         reply.code(400).send({ error: 'Bad Request', message: 'Invalid webhook signature' });
      //         return;
      //      }
      //   }
      // });
    
      // POST /bulk-sms - Endpoint to submit bulk SMS jobs
      // Apply schema and potentially security hooks defined above
      fastify.post('/bulk-sms', { schema: bulkSmsSchema /*, preHandler: [fastify.auth([fastify.verifyApiKey])] // Example if using fastify.auth */ }, async (request, reply) => {
        const { recipients, message } = request.body;
        const logger = request.log; // Use request-specific logger with unique request ID
    
        // **CRITICAL SECURITY WARNING**
        // This endpoint currently lacks client authentication/authorization.
        // In a production environment, you MUST implement a mechanism (e.g., API Key check, JWT validation)
        // in a preHandler hook to ensure only authorized clients can submit bulk SMS requests.
        // Failure to do so poses a significant security risk and could lead to API abuse and unexpected costs.
        logger.warn('SECURITY WARNING: /bulk-sms endpoint called without client authentication. Implement verification!');
    
    
        logger.info(`Received bulk SMS request for ${recipients.length} recipients.`);
    
        // Don't wait for all messages to be sent. Acknowledge the request quickly.
        // Run the bulk sending in the background.
        // **PRODUCTION NOTE:** For robust handling, use a dedicated job queue (e.g., BullMQ) instead of fire-and-forget!
        sendBulkSms(recipients, message, logger)
          .then(results => {
            // Log final submission results asynchronously
            const successes = results.filter(r => r.success).length;
            const failures = results.length - successes;
            logger.info(`Async Bulk Send Submission Result: ${successes} submitted, ${failures} failed submission.`);
            // Here you could update a database record with the final status if using job tracking
          })
          .catch(error => {
            // Catch unexpected errors from the sendBulkSms orchestrator itself
            logger.error({ err: error }, "Critical error during background bulk SMS processing initiation.");
          });
    
        // Immediately return 202 Accepted
        reply.code(202).send({
          status: 'Accepted',
          message: `Bulk SMS job accepted for processing ${recipients.length} recipients. Check logs or status webhooks for delivery details.`,
          totalRecipients: recipients.length
        });
      });
    
       // POST /webhooks/status - Endpoint to receive message status updates from Vonage
       fastify.post('/webhooks/status', { schema: statusWebhookSchema }, async (request, reply) => {
          const statusUpdate = request.body;
          const logger = request.log; // Request-specific logger
    
          // **CRITICAL SECURITY WARNING**
          // This endpoint currently lacks webhook signature verification.
          // In a production environment, you MUST implement signature verification
          // using Vonage's documented methods (JWT or Shared Secret) in a preHandler hook.
          // This ensures the webhook genuinely came from Vonage and wasn't forged.
          // See: https://developer.vonage.com/en/concepts/signed-webhooks
          logger.warn('SECURITY WARNING: /webhooks/status received without signature verification. Implement verification!');
    
    
          // Log the received status update
          // In production, you would typically find the corresponding message record in your DB
          // using statusUpdate.message_uuid and update its status.
          logger.info({ statusUpdate }, `Received Vonage status update for message ${statusUpdate.message_uuid} to ${statusUpdate.to}: ${statusUpdate.status}`);
    
          // Acknowledge receipt of the webhook
          reply.code(200).send({ received: true });
       });
    
       // POST /webhooks/inbound - Endpoint to receive inbound messages (e.g., STOP replies)
       fastify.post('/webhooks/inbound', { /* Add schema similar to statusWebhookSchema if needed */ }, async (request, reply) => {
           const inboundMsg = request.body;
           const logger = request.log;
    
           // **CRITICAL SECURITY WARNING**
           // Implement signature verification here as well!
           logger.warn('SECURITY WARNING: /webhooks/inbound received without signature verification. Implement verification!');
    
           // Log the inbound message
           logger.info({ inboundMsg }, `Received inbound message from ${inboundMsg.msisdn}`);
    
           // **Handle Opt-Outs (Example)**
           // This is a simplified example. Production systems need robust opt-out list management.
           if (inboundMsg.text && inboundMsg.text.trim().toUpperCase() === 'STOP') {
               logger.warn(`Processing STOP request from ${inboundMsg.msisdn}. Add to opt-out list.`);
               // TODO: Implement logic to add inboundMsg.msisdn to your opt-out database/list
               // Ensure future sends to this number are blocked.
           } else {
               // Handle other types of inbound messages if necessary
               logger.info(`Received other inbound text from ${inboundMsg.msisdn}: "${inboundMsg.text}"`);
           }
    
           // Acknowledge receipt
           reply.code(200).send({ received: true });
       });
    
    }
    
    export default smsRoutes;
    • Schema: Defines the expected structure for requests (/bulk-sms) and webhooks (/webhooks/status). This enables Fastify's automatic validation, improving robustness and providing clear error messages for invalid requests. The E.164 regex was corrected.
    • Asynchronous Processing: The /bulk-sms endpoint immediately returns 202 Accepted after validating the request and initiating the sendBulkSms function. The actual sending happens in the background. This prevents the client from waiting potentially long periods for thousands of SMS messages to be submitted to Vonage.
    • Logging: Uses request.log for contextual logging, which includes a unique request ID per incoming request, making it easier to trace operations.
    • Security Placeholders: Includes commented-out sections and CRITICAL WARNINGS emphasizing the need for client authentication on /bulk-sms and signature verification on webhook endpoints in production. These are essential security measures.
    • Webhook Handling: Basic handlers for status and inbound webhooks are included, logging the received data and acknowledging receipt with a 200 OK. Includes a placeholder for STOP message handling.
    • Error Response Schemas: Uses $ref for common error responses, assuming a shared schema definition (not shown here but typical in larger Fastify apps).

Frequently Asked Questions About Bulk SMS with Vonage and Fastify

What is the Vonage Messages API rate limit?

The Vonage Messages API has a default rate limit of 30 requests per second per API key (verified January 2025). This means your application can submit up to 30 SMS send requests per second. The tutorial uses p-limit set to 25 concurrent requests as a safety margin below the actual limit. Contact Vonage support to potentially increase this limit for high-volume applications.

Do I need A2P 10DLC registration for bulk SMS to US numbers?

Yes. A2P 10DLC registration is mandatory for sending SMS to US phone numbers using 10-digit long codes. As of February 1, 2025, unregistered numbers are blocked by US carriers. The registration process includes:

  • Brand registration (your organization)
  • Campaign registration (message purpose)
  • Timeline: 1-3 weeks total
  • Requirements: Valid business tax ID (EIN)

Failure to register results in delivery failures, high fees, or service suspension.

Why use Fastify instead of Express for bulk SMS?

Fastify offers several advantages for bulk SMS applications:

  • Performance: Lower overhead and faster request handling than Express
  • Built-in schema validation: Automatic request/response validation without additional middleware
  • Better logging: Request-scoped logging with unique request IDs out of the box
  • Plugin architecture: Clean separation of concerns (rate limiting, authentication, etc.)
  • TypeScript support: Better type safety for production applications

Fastify v5 requires Node.js v20+ for optimal performance.

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

Implement an inbound webhook handler at /webhooks/inbound that:

  1. Verifies the Vonage webhook signature (security critical)
  2. Checks if the message text is "STOP" (case-insensitive)
  3. Adds the sender's phone number to your opt-out database/list
  4. Ensures future bulk sends skip opted-out numbers

This is legally required in many jurisdictions. The tutorial includes a basic example that you must extend with proper database storage.

What is E.164 phone number format?

E.164 is the ITU-T international standard for phone number formatting. Requirements:

  • Maximum 15 digits including country code
  • Country code: 1-3 digits (e.g., +1 for US, +44 for UK)
  • Prefixed with + sign
  • No spaces, parentheses, or dashes
  • Example: +14155550100 (US number)

All Vonage Messages API requests require phone numbers in E.164 format for proper international routing.

How many SMS messages can I send per minute with Vonage?

At the default rate limit of 30 requests/second, you can theoretically send 1,800 SMS messages per minute. However, actual throughput depends on:

  • Your Vonage account limits
  • A2P 10DLC brand trust score (for US numbers)
  • Network conditions and API response times
  • Your application's concurrency configuration

For higher volumes, contact Vonage to increase your rate limits and consider implementing a job queue system (e.g., BullMQ).

What Node.js version do I need for this tutorial?

Node.js v20 or later is required. Fastify v5 targets Node.js v20+, and Node.js v18 reaches end of Long Term Support on April 30, 2025. Upgrade to Node.js v20 LTS for:

  • Continued security updates
  • Better performance
  • Full Fastify v5 compatibility

How do I verify Vonage webhook signatures?

Vonage signs all webhook requests (status updates, inbound messages) to prevent forgery. Implement signature verification using:

  • JWT method: Verify the JWT token in the Authorization header using your private key
  • Shared secret method: Verify the signature using a shared secret

The tutorial includes placeholders for this implementation. See Vonage's signed webhooks documentation for complete implementation details. This is critical for production security.

Can I use this setup for WhatsApp or other channels?

Yes. The Vonage Messages API supports multiple channels beyond SMS:

  • WhatsApp
  • Facebook Messenger
  • Viber
  • MMS

Change the channel parameter in the messages.send() call from 'sms' to your desired channel. Each channel has specific requirements (e.g., WhatsApp requires business account verification). The rate limiting and bulk processing logic remains the same.

What happens if Vonage API returns an error during bulk sending?

The tutorial's error handling approach:

  1. Each individual send is wrapped in a try-catch block
  2. Errors are logged with detailed Vonage error information
  3. Promise.allSettled ensures one failure doesn't stop the entire batch
  4. Results array includes success/failure status for each recipient
  5. The endpoint returns 202 Accepted immediately, then processes asynchronously

For production, implement additional error handling:

  • Retry logic for transient failures
  • Dead letter queue for permanently failed messages
  • Alert system for high error rates
  • Store results in database for auditing

How do I test bulk SMS without sending to real numbers?

For development and testing:

  1. Use Vonage sandbox environment: Limited to 1 message/second, 100 messages/month
  2. Purchase test numbers: Buy a few Vonage numbers to test message delivery
  3. ngrok for webhooks: Use ngrok to expose your local server for webhook testing
  4. Mock the Vonage SDK: Create a mock implementation of messages.send() for unit tests
  5. Reduce batch size: Test with small batches (5-10 recipients) initially

The tutorial's configuration in .env makes it easy to switch between development and production settings.


Next Steps

After building your bulk SMS service with Fastify and Vonage:

  1. Implement production security: Add API key authentication and webhook signature verification
  2. Set up monitoring: Use tools like Datadog, New Relic, or Prometheus to track API performance and error rates
  3. Add job queue: Implement BullMQ or similar for reliable background processing
  4. Configure database: Store message history, opt-outs, and delivery status
  5. Deploy to production: Use platforms like Heroku, AWS, or DigitalOcean with proper environment variable management
  6. Complete A2P 10DLC registration: If targeting US numbers, start the registration process immediately
  7. Set up alerts: Configure notifications for high error rates or rate limit violations

Resources:

Verified as of January 2025. API specifications, rate limits, and compliance requirements may change. Check official Vonage documentation for the most current information.