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.envfile.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:
- 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). - 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-limitlibrary 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.
- 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.)
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
endPrerequisites:
- 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
ngrokURL (e.g.,https://<your-ngrok-id>.ngrok.io/webhooks/status). For production, use your public server URL. Set the HTTP method toPOST. - 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 toPOST. - Click Generate public and private key. Save the
private.keyfile 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.
-
Create Project Directory:
bashmkdir fastify-vonage-bulk-sms cd fastify-vonage-bulk-sms -
Initialize Node.js Project:
bashnpm init -yThis creates a
package.jsonfile. -
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 -
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 -
Configure
.gitignore: Create a.gitignorefile 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 -
Configure Environment Variables (
.env): Create a.envfile in the root directory. WARNING: The placeholder values below (likeYOUR_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.gitignoreand 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
.envkeeps sensitive credentials out of your codebase, making it more secure and easier to manage configurations across different environments (development, staging, production).
- Purpose: Using
-
Configure
nodemon(Optional but Recommended for Development): Add adevscript to yourpackage.json'sscriptssection: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 devto 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.
-
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.
-
Implement Bulk Send Logic (
src/services/vonage.jscontinued): Add the function to handle sending to multiple recipients usingp-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.allalone would bombard the API, leading to errors (e.g., 429 Too Many Requests).p-limitensures that only a specified number (VONAGE_CONCURRENCY_LIMIT) ofsendSingleSmspromises 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
catchblock insendSingleSmsspecifically tries to extract detailed error messages from the Vonage SDK's response, which is crucial for debugging. It logs this detailed info. UsingPromise.allSettledinsendBulkSmsensures that one failed promise doesn't stop the entire batch, and we can collect results for all attempts. - Logging: Injecting the
loggerallows 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.envexample and common recommendation.
- Why
3. Building the API Layer
Now, let's create the Fastify route that will accept bulk SMS requests.
-
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-smsendpoint immediately returns202 Acceptedafter validating the request and initiating thesendBulkSmsfunction. 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.logfor 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-smsand 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
$reffor common error responses, assuming a shared schema definition (not shown here but typical in larger Fastify apps).
- Schema: Defines the expected structure for requests (
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:
- Verifies the Vonage webhook signature (security critical)
- Checks if the message text is "STOP" (case-insensitive)
- Adds the sender's phone number to your opt-out database/list
- 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:
- 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:
- Each individual send is wrapped in a try-catch block
- Errors are logged with detailed Vonage error information
Promise.allSettledensures one failure doesn't stop the entire batch- Results array includes success/failure status for each recipient
- 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:
- Use Vonage sandbox environment: Limited to 1 message/second, 100 messages/month
- Purchase test numbers: Buy a few Vonage numbers to test message delivery
- ngrok for webhooks: Use ngrok to expose your local server for webhook testing
- Mock the Vonage SDK: Create a mock implementation of
messages.send()for unit tests - 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:
- Implement production security: Add API key authentication and webhook signature verification
- Set up monitoring: Use tools like Datadog, New Relic, or Prometheus to track API performance and error rates
- Add job queue: Implement BullMQ or similar for reliable background processing
- Configure database: Store message history, opt-outs, and delivery status
- Deploy to production: Use platforms like Heroku, AWS, or DigitalOcean with proper environment variable management
- Complete A2P 10DLC registration: If targeting US numbers, start the registration process immediately
- Set up alerts: Configure notifications for high error rates or rate limit violations
Resources:
- Vonage Messages API Documentation
- Fastify Official Documentation
- A2P 10DLC Registration Guide
- Vonage Node.js SDK GitHub
Verified as of January 2025. API specifications, rate limits, and compliance requirements may change. Check official Vonage documentation for the most current information.