Build SMS Marketing Campaigns with Vonage API, Node.js & Fastify
A guide on creating a Node.js SMS marketing application using Fastify and the Vonage Messages API, covering setup, sending/receiving SMS, webhooks, and deployment considerations.
Build a robust Node.js application using Fastify and the Vonage Messages API to send and receive SMS messages for marketing campaigns. This guide covers project setup, Vonage configuration, core features, error handling, security, and deployment.
You'll build a functional backend system capable of sending targeted SMS messages and processing inbound replies – the foundation of an SMS marketing platform. This guide prioritizes production readiness with secure configuration, comprehensive error handling, and deployment considerations.
Legal Compliance and Regulations
Before building SMS marketing campaigns, understand the legal requirements that govern commercial text messaging.
TCPA Compliance (US)
The Telephone Consumer Protection Act (TCPA), with updates effective April 11, 2025, requires:
Prior Express Written Consent: Obtain explicit written consent before sending marketing messages
Clear Opt-Out Language: Include clear instructions to opt out in every message (e.g., "Reply STOP to unsubscribe")
Immediate Opt-Out Processing: Honor opt-out requests within 24 hours (maximum 10 business days per FCC regulations)
Multiple Opt-Out Keywords: Recognize STOP, END, CANCEL, UNSUBSCRIBE, QUIT, and informal requests like "Leave me alone"
Penalties: Violations cost $500 – $1,500 per message
International Regulations
GDPR (EU): Requires explicit consent, right to access data, and right to erasure
CASL (Canada): Requires express consent and clear identification of sender
Other Markets: Research local regulations for your target markets
10DLC Registration (US)
For US marketing campaigns using standard phone numbers:
Register your brand with The Campaign Registry
Register your campaign use case
Wait for approval (can take 1 – 2 weeks)
Expect higher throughput limits after approval
Without 10DLC registration, carriers may filter or block your messages.
You are building a Node.js backend service using the Fastify framework. This service will:
Expose an API endpoint to trigger sending SMS messages via Vonage
Expose a webhook endpoint to receive incoming SMS messages sent to a Vonage number
Integrate with the Vonage Messages API for SMS functionality
Manage credentials and configurations securely
Include comprehensive logging and error handling
Process opt-out requests for TCPA compliance
Problem Solved:
This system provides the core infrastructure needed to programmatically send SMS messages (e.g., for marketing alerts, notifications, promotions) and handle potential replies from recipients, enabling two-way SMS communication within a larger application or marketing workflow.
Technologies Used:
Node.js: A JavaScript runtime environment for building server-side applications
Fastify: A high-performance, low-overhead Node.js web framework (capable of 70,000 – 80,000 requests per second), chosen for its speed, extensibility, and developer-friendly features like built-in validation and logging
Vonage Messages API: A powerful communications API (v1.0) enabling applications to send and receive messages across various channels, including SMS. We use it for its unified approach and reliability
@vonage/server-sdk: The official Vonage Node.js SDK (v3.24.1 as of January 2025) for easy integration with Vonage APIs
dotenv: A module to load environment variables from a .env file into process.env
ngrok (for development): A tool to expose local servers to the internet, necessary for testing Vonage webhooks
System Architecture:
Text
+-----------------+ +-----------------+ +----------------+ +---------------+
| User / Admin |----->| Your API Client|----->| Fastify App |----->| Vonage API |-----> SMS Network
| (e.g., via UI)| | (e.g., Postman) | | (Node.js) | | (Messages API)|
+-----------------+ +-----------------+ +-------+--------+ +-------+-------+
| |
|<------- Webhook --------| (Incoming SMS)
| (via ngrok in dev) |
| |
+-------v--------+
| Logging/Database|
| (Optional) |
+----------------+
Prerequisites:
Node.js: Installed (Node.js 22 LTS recommended as of 2025, supported until April 2027). Download from nodejs.org
npm or yarn: Package manager for Node.js (usually included with Node.js)
Vonage API Account: Sign up for free at Vonage API Dashboard. You'll get free credits to start
ngrok: Installed for local development webhook testing. Download from ngrok.com. A free account is sufficient (note: free tier has 2-hour session limits)
Basic Terminal/Command Line Knowledge: Familiarity with navigating directories and running commands
Set up Environment Variables (.env):
Open the .env file and add the following placeholders. Populate these values in the next section.
.env
# Vonage Credentials
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root
# Vonage Number
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # In E.164 format, e.g., 14155550100
# Application Port
PORT=3000
# Development Webhook URL (from ngrok)
DEV_WEBHOOK_BASE_URL=YOUR_NGROK_FORWARDING_URL
Purpose: Using environment variables keeps sensitive data out of the codebase and allows for different configurations per environment (development, staging, production)
E.164 Format: Phone numbers must follow the ITU-T Recommendation E.164 international standard (maximum 15 digits, format: +[country code][subscriber number], no spaces or special characters). Example: +14155550100 for a US number
2. Vonage Configuration
Before writing code, configure your Vonage account and application.
Your API Key and API Secret are displayed prominently on the main dashboard page
Copy these values and paste them into your .env file for VONAGE_API_KEY and VONAGE_API_SECRET
Buy a Vonage Number:
You need a virtual number capable of sending and receiving SMS.
Choosing the Right Number Type:
Number Type
Best For
Throughput
Cost
Long Code (Local)
Small campaigns, local presence
1 message/second
$1 – $2/month
Toll-Free
Customer service, transactional
3 messages/second
$2 – $3/month
Short Code
High-volume campaigns
100 messages/second
$1,000+/month
For marketing campaigns, use a 10DLC-registered long code or toll-free number.
In the Vonage Dashboard, navigate to Numbers → Buy numbers
Select your country, ensure SMS capability is selected (and Voice if needed later), search for available numbers, and purchase one
Copy the purchased number (in E.164 format, e.g., 14155550100) and paste it into your .env file for VONAGE_NUMBER
10DLC Registration (Required for US Marketing):
After purchasing a US long code for marketing:
Navigate to Compliance → 10DLC Registration in the dashboard
Register your business brand (requires EIN or business verification)
Register your campaign with use case details
Wait for approval (typically 1 – 2 weeks)
Approval increases your throughput from 1 to 60 – 240 messages/second
Create a Vonage Application:
Vonage Applications act as containers for your communication configurations, including webhooks and security keys.
Navigate to Applications → Create a new application
Give your application a descriptive name (e.g., "Fastify SMS Campaign App")
Click Generate public and private key. This will automatically download a file named private.key. Save this file securely in the root directory of your project (the same level as package.json). The path ./private.key in your .env file assumes it's here. Vonage stores the public key
Enable the Messages capability
Inbound URL: We'll fill this shortly with your ngrok URL. Placeholder: http://localhost:3000/webhooks/inbound (will be updated)
Status URL: Also requires the ngrok URL. Placeholder: http://localhost:3000/webhooks/status (will be updated)
Click Generate new application
On the application details page, copy the Application ID and paste it into your .env file for VONAGE_APPLICATION_ID
Link Your Number: Scroll down to the Linked numbers section and link the Vonage number you purchased earlier to this application. This ensures messages sent to this number are routed through this application's configuration (specifically, to the Inbound URL)
Set Default SMS API (Important):
Vonage has older and newer APIs. Ensure the Messages API is the default for SMS.
Navigate to API Settings in the left-hand menu
Under SMS Settings, ensure Messages API is selected as the "Default SMS Setting"
Click Save changes
Set up ngrok and Update Webhook URLs:
Webhooks allow Vonage to send data (like incoming messages) to your application. Since your app runs locally during development, ngrok creates a public URL that tunnels requests to your local machine.
Open a new terminal window (keep the first one for running the app later)
Run ngrok, telling it to forward to the port your Fastify app will run on (defined in .env as PORT=3000)
Bash
ngrok http 3000
ngrok will display forwarding URLs (e.g., https://randomstring.ngrok.io). Copy the HTTPS URL. This is your DEV_WEBHOOK_BASE_URL
Paste the HTTPS ngrok URL into your .env file for DEV_WEBHOOK_BASE_URL. Ensure it includes https://
Go back to your Vonage Application settings (Applications → Your App Name → Edit)
Status URL:YOUR_NGROK_HTTPS_URL/webhooks/status (e.g., https://randomstring.ngrok.io/webhooks/status)
Click Save changes
Why HTTPS? Vonage requires secure HTTPS URLs for webhooks in production and it's best practice even in development. ngrok provides this automatically
Why Status URL? This webhook receives delivery receipts (DLRs) indicating if a message was successfully delivered to the handset. Essential for tracking campaign success
3. Implementing Core Functionality: Sending SMS
Write the code to initialize Fastify and the Vonage SDK, and create an endpoint to send SMS messages.
File: src/server.js
JavaScript
// src/server.js'use strict';
// Load environment variablesrequire('dotenv').config();
// Import dependenciesconstFastify = require('fastify');
const { Vonage } = require('@vonage/server-sdk');
const path = require('path');
// --- Configuration ---// Validate essential environment variablesconst requiredEnv = [
'VONAGE_API_KEY',
'VONAGE_API_SECRET',
'VONAGE_APPLICATION_ID',
'VONAGE_PRIVATE_KEY_PATH',
'VONAGE_NUMBER',
'PORT',
];
for (const variable of requiredEnv) {
if (!process.env[variable]) {
console.error(`Error: Missing required environment variable ${variable}`);
process.exit(1); // Exit if essential config is missing
}
}
constPORT = process.env.PORT || 3000;
constVONAGE_NUMBER = process.env.VONAGE_NUMBER;
// Construct the absolute path to the private key// .env stores a relative path, but SDK might need absolute depending on execution contextconst privateKeyPath = path.resolve(process.cwd(), process.env.VONAGE_PRIVATE_KEY_PATH);
// --- Initialize Fastify ---const fastify = Fastify({
logger: true, // Enable built-in Pino logger
});
// --- Initialize Vonage SDK ---// Using Application ID and Private Key is recommended for Messages APIconst vonage = newVonage({
apiKey: process.env.VONAGE_API_KEY, // Keep API Key/Secret for potential fallback or other API usageapiSecret: process.env.VONAGE_API_SECRET,
applicationId: process.env.VONAGE_APPLICATION_ID,
privateKey: privateKeyPath,
});
// --- Routes ---// Simple health check route
fastify.get('/health', async (request, reply) => {
return { status: 'ok', timestamp: newDate().toISOString() };
});
/**
* @route POST /send-sms
* @description Sends a single SMS message.
* @body {object} { to: string, text: string } - 'to' number in E.164 format, 'text' message content.
*/
fastify.post('/send-sms', {
schema: { // Basic input validationbody: {
type: 'object',
required: ['to', 'text'],
properties: {
to: { type: 'string', description: 'Recipient phone number in E.164 format (e.g., 14155550101)' },
text: { type: 'string', minLength: 1, description: 'The content of the SMS message' },
},
},
response: {
200: {
type: 'object',
properties: {
message_uuid: { type: 'string' },
status: { type: 'string' },
},
},
500: {
type: 'object',
properties: {
error: { type: 'string' },
details: { type: 'object' } // Or string, depending on error
}
}
}
}
}, async (request, reply) => {
const { to, text } = request.body;
request.log.info(`Attempting to send SMS to ${to}`);
try {
const resp = await vonage.messages.send({
channel: 'sms',
message_type: 'text',
to: to,
from: VONAGE_NUMBER, // Use the Vonage number from .envtext: text,
});
request.log.info({ msg: 'SMS submitted successfully', response: resp });
reply.status(200).send({
message_uuid: resp.message_uuid,
status: 'submitted',
});
} catch (error) {
request.log.error({ msg: 'Error sending SMS', error: error?.response?.data || error.message, to });
// Provide a more structured error response
reply.status(500).send({
error: 'Failed to send SMS',
details: error?.response?.data || { message: error.message }, // Include details from Vonage if available
});
}
});
// --- Start Server ---conststart = async () => {
try {
await fastify.listen({ port: PORT, host: '0.0.0.0' }); // Listen on all network interfaces
fastify.log.info(`Server listening on port ${PORT}`);
fastify.log.info(`Vonage App ID: ${process.env.VONAGE_APPLICATION_ID}`);
fastify.log.info(`Vonage Number: ${VONAGE_NUMBER}`);
if (process.env.NODE_ENV !== 'production' && process.env.DEV_WEBHOOK_BASE_URL) {
fastify.log.info(`Development Webhook Base URL (ngrok): ${process.env.DEV_WEBHOOK_BASE_URL}`);
fastify.log.info(`Expected Inbound Webhook: ${process.env.DEV_WEBHOOK_BASE_URL}/webhooks/inbound`);
fastify.log.info(`Expected Status Webhook: ${process.env.DEV_WEBHOOK_BASE_URL}/webhooks/status`);
} elseif (process.env.NODE_ENV !== 'production') {
fastify.log.warn('DEV_WEBHOOK_BASE_URL not set in .env – ngrok webhook testing may fail.');
}
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Explanation:
Environment Variables: Load .env using require('dotenv').config(). Validate that critical variables exist
Private Key Path: Construct an absolute path to private.key using path.resolve. This makes path resolution more robust regardless of where you run the script
Fastify Initialization: Create a Fastify instance with logger: true to enable automatic request logging via Pino
Vonage SDK Initialization: Initialize the SDK using the Application ID and the path to the private key, which is the standard authentication method for the Messages API. Include API Key/Secret for completeness
/health Route: A simple endpoint to check if the server is running
/send-sms Route (POST):
Schema Validation: Fastify's built-in schema validation ensures the request body contains to (string) and text (non-empty string). If validation fails, Fastify automatically returns a 400 Bad Request error
Logging: Log the attempt using request.log.info. Fastify injects the logger into the request object
vonage.messages.send(): This is the core SDK method. Specify:
channel: 'sms'
message_type: 'text'
to: Recipient number from the request body. Must be in E.164 format
from: Your Vonage virtual number from .env
text: Message content from the request body
Async/Await: The SDK call is asynchronous, so use async/await
Success Response: If the API call is accepted by Vonage (doesn't mean delivered yet), it returns a message_uuid. Log success and send a 200 OK response with the UUID
Error Handling: A try...catch block handles potential errors from the Vonage API (e.g., invalid number, insufficient funds). Log the error using request.log.error and return a 500 Internal Server Error with details from the Vonage error if available (error?.response?.data)
Server Start: The start function listens on the configured PORT and 0.0.0.0 (to be accessible outside localhost, e.g., by ngrok). It logs key configuration details on startup
Common Vonage API Error Codes:
Status Code
Error Type
Meaning
Action
400
Bad Request
Invalid parameters (wrong format, missing fields)
Fix request format
401
Unauthorized
Invalid API credentials
Check API key/secret
402
Payment Required
Insufficient account balance
Add credits
422
Unprocessable Entity
Number not registered for 10DLC
Complete 10DLC registration
429
Rate Limit Exceeded
Too many requests
Implement rate limiting
500
Server Error
Vonage internal error
Retry with exponential backoff
Run the Application:
In your first terminal window (where you ran npm install), start the server:
Bash
node src/server.js
You should see log output indicating the server is listening and showing your configuration. Keep this running.
Add the webhook endpoints configured in Vonage to handle incoming messages and status updates.
Add the following routes to src/server.js (before the // --- Start Server --- section):
JavaScript
// src/server.js// ... (keep existing code above) ...// --- Webhook Routes ---/**
* @route POST /webhooks/inbound
* @description Handles incoming SMS messages from Vonage.
*/
fastify.post('/webhooks/inbound', async (request, reply) => {
const params = request.body;
request.log.info({ msg: 'Inbound SMS received', data: params });
// --- IMPORTANT: Acknowledge receipt immediately ---// Vonage expects a quick 200 OK response.// Process the message asynchronously if needed, but respond first.
reply.status(200).send();
// --- Process the incoming message ---console.log(`--- Received Message ---`);
console.log(`From: ${params.from}`);
console.log(`To: ${params.to}`); // Your Vonage numberconsole.log(`Text: ${params.text}`);
console.log(`Message ID: ${params.message_uuid}`);
console.log(`Timestamp: ${params.timestamp}`);
console.log(`-----------------------`);
// CRITICAL: TCPA Compliance – Opt-Out Detectionconst messageText = params.text?.toLowerCase() || '';
const standardOptOuts = ['stop', 'end', 'cancel', 'unsubscribe', 'quit'];
const informalOptOuts = ['leave me alone', 'remove me', 'no more', 'dont text', "don't text"];
const isOptOut = standardOptOuts.some(keyword => messageText.includes(keyword)) ||
informalOptOuts.some(phrase => messageText.includes(phrase));
if (isOptOut) {
request.log.warn({ msg: 'Opt-out request detected', from: params.from, text: params.text });
// TODO: Update database to mark contact as opted out// Example: await db.contacts.update({ phone: params.from }, { opted_out: true, opted_out_at: new Date() });// Send confirmation messagetry {
await vonage.messages.send({
channel: 'sms',
message_type: 'text',
to: params.from,
from: params.to,
text: 'You have been unsubscribed and will not receive further messages. Reply HELP for assistance or RESTART to resubscribe.'
});
request.log.info(`Opt-out confirmation sent to ${params.from}`);
} catch (error) {
request.log.error({ msg: 'Failed to send opt-out confirmation', error: error?.response?.data || error.message });
}
}
// Example: Auto-reply for specific keywords (use cautiously)/*
if (messageText.includes('hello')) {
try {
await vonage.messages.send({
channel: 'sms',
message_type: 'text',
to: params.from, // Reply to the sender
from: params.to, // Use the number the message was sent TO
text: 'Hi there! We received your message.'
});
request.log.info(`Auto-reply sent to ${params.from}`);
} catch (error) {
request.log.error({ msg: 'Failed to send auto-reply', error: error?.response?.data || error.message });
}
}
*/
});
/**
* @route POST /webhooks/status
* @description Handles delivery receipts (DLRs) and other message status updates from Vonage.
*/
fastify.post('/webhooks/status', async (request, reply) => {
const params = request.body;
request.log.info({ msg: 'Message status update received', data: params });
// --- IMPORTANT: Acknowledge receipt immediately ---
reply.status(200).send();
// --- Process the status update ---console.log(`--- Status Update ---`);
console.log(`Message ID: ${params.message_uuid}`);
console.log(`To: ${params.to}`);
console.log(`From: ${params.from}`);
console.log(`Status: ${params.status}`);
console.log(`Timestamp: ${params.timestamp}`);
if (params.error) {
console.error(`Error Code: ${params.error.code}`);
console.error(`Error Reason: ${params.error.reason}`);
}
console.log(`--------------------`);
// Example: Log failed messagesif (['failed', 'rejected', 'undeliverable'].includes(params.status?.toLowerCase())) {
request.log.warn({ msg: 'SMS delivery failed', details: params });
// Add logic here: notify admin, update CRM, etc.
} elseif (params.status?.toLowerCase() === 'delivered') {
request.log.info({ msg: 'SMS delivered successfully', uuid: params.message_uuid, to: params.to });
// Update analytics, mark campaign contact as reached
}
});
// --- Start Server ---// ... (keep existing start function) ...
Explanation:
/webhooks/inbound:
This route matches the Inbound URL configured in your Vonage application
It receives a POST request from Vonage whenever an SMS is sent to your linked Vonage number
request.log.info: Logs the entire incoming payload. Study this structure to see available fields (sender from, recipient to, text, message_uuid, etc.)
reply.status(200).send();: This is CRITICAL. Vonage expects a quick confirmation (within a few seconds) that you received the webhook. If it doesn't get a 200 OK, it will retry sending, potentially multiple times. Send the response before doing any heavy processing
Opt-Out Processing: The code detects standard keywords (STOP, END, CANCEL, UNSUBSCRIBE, QUIT) and informal opt-out requests as required by TCPA 2025 rules. It sends a confirmation message and logs the opt-out for database updates
Auto-Reply: The commented-out section shows how you could respond, but use automated replies cautiously to avoid loops and costs
Message Status Values:
Status
Meaning
Action
submitted
Vonage accepted the message
Wait for further updates
delivered
Message reached recipient's phone
Mark as successful in analytics
failed
Delivery failed permanently
Log error, investigate reason
rejected
Carrier rejected message
Check number validity, carrier filtering
undeliverable
Number not in service or unreachable
Remove from contact list
expired
Message expired before delivery
Carrier timeout, check throughput limits
/webhooks/status:
This route matches the Status URL
It receives POST requests from Vonage about the status of messages you sent (Delivery Receipts – DLRs)
It also logs the payload and immediately sends a 200 OK
Processing: The example code shows how to check the status field (delivered, failed, submitted, rejected, etc.) and message_uuid. Use the UUID to correlate this status update with the message you sent earlier (likely stored in your database). Handling failed or rejected statuses is crucial for campaign analysis and potentially retrying
Database Schema for Opt-Out Tracking:
SQL
CREATE TABLE contacts (
id SERIAL PRIMARY KEY,
phone VARCHAR(20) UNIQUENOT NULL,
opted_in BOOLEANDEFAULTfalse,
opted_out BOOLEANDEFAULTfalse,
opted_out_at TIMESTAMP,
consent_date TIMESTAMP,
consent_method VARCHAR(50), -- 'web_form', 'api', 'manual'
created_at TIMESTAMPDEFAULT NOW(),
updated_at TIMESTAMPDEFAULT NOW()
);
CREATE INDEX idx_contacts_opted_out ON contacts(opted_out);
CREATE INDEX idx_contacts_phone ON contacts(phone);
Restart the Application:
Stop the running server (Ctrl+C) and restart it to load the new webhook routes:
Bash
node src/server.js
Ensure your ngrok tunnel is still running in the other terminal window.
5. Building a Complete API Layer
Refine the API for production-ready marketing campaigns.
Refining /send-sms (Enhanced Validation):
Enhance the schema validation in src/server.js:
JavaScript
// Inside fastify.post('/send-sms', ...) schema definition:properties: {
to: {
type: 'string',
description: 'Recipient phone number in E.164 format (e.g., +14155550101)',
// Basic E.164 pattern check (adjust regex as needed for stricter validation)pattern: '^\\+?[1-9]\\d{1,14}'
},
text: {
type: 'string',
minLength: 1,
maxLength: 1600, // SMS technically has segment limits, but API handles longer messagesdescription: 'The content of the SMS message'
},
// Optional: Add a campaign ID for trackingcampaignId: { type: 'string', description: 'Optional identifier for the marketing campaign' }
},
SMS Message Segmentation and Costs:
Standard SMS messages are limited to 160 characters (GSM-7 encoding) or 70 characters (Unicode/UCS-2). The Vonage Messages API automatically splits longer messages into multiple segments and reassembles them on the recipient's device according to carrier specifications. Messages up to 1,600 characters are accepted, but will be billed as multiple message segments.
Message Length and Segment Calculation:
Encoding
Characters per Segment
Max Characters (Concatenated)
Example Cost (10k recipients @ $0.01/segment)
GSM-7 (standard)
160 / 153 (concatenated)
1,530 (10 segments)
$100 (1 segment), $200 (2 segments)
Unicode (emoji, special chars)
70 / 67 (concatenated)
670 (10 segments)
$100 (1 segment), $300 (3 segments)
Cost Optimization Tips:
Keep messages under 160 characters (GSM-7) or 70 characters (Unicode) to avoid multi-segment costs
Avoid emojis and special characters unless necessary (triggers Unicode encoding)
Use URL shorteners for links to save characters
Test message length before campaigns: each character over threshold doubles cost
Adding a Production-Ready /send-campaign Endpoint:
Use a message queue for scalable campaign sending:
Protect your API endpoints with API key authentication.
Add an API key to your .env:
.env
API_SECRET_KEY=your-super-secret-random-key
Add the hook in src/server.jsbefore defining your protected routes:
JavaScript
// src/server.js// ... after Fastify initialization ...constAPI_SECRET_KEY = process.env.API_SECRET_KEY;
if (!API_SECRET_KEY && process.env.NODE_ENV === 'production') {
fastify.log.warn('API_SECRET_KEY is not set in production environment! API is unprotected.');
// Optionally exit: process.exit(1);
}
// --- Hooks (Middleware) ---// Simple API Key Authentication Hook
fastify.addHook('onRequest', async (request, reply) => {
// Allow health check and webhooks without API keyif (request.url === '/health' || request.url.startsWith('/webhooks/')) {
return;
}
// Skip auth if no key is configured (useful for local dev without key)if (!API_SECRET_KEY) {
request.log.warn('Skipping API key check as API_SECRET_KEY is not set.');
return;
}
const apiKey = request.headers['x-api-key']; // Or 'Authorization: Bearer YOUR_KEY'if (!apiKey || apiKey !== API_SECRET_KEY) {
request.log.warn({ msg: 'Unauthorized API access attempt', ip: request.ip, url: request.url });
reply.code(401).send({ error: 'Unauthorized' });
thrownewError('Unauthorized');
}
request.log.info('API key validated successfully.');
});
// --- Routes ---// Define /send-sms, /send-campaign, etc. *after* the hook// ... rest of your routes ...
Note on Authentication: For high-security production environments, implement JWT tokens or OAuth2 instead of simple API keys. API keys in headers can be intercepted if not using HTTPS or if the key is exposed in client-side code.
curl -X POST http://localhost:3000/send-sms \
-H "Content-Type: application/json" \
-H "x-api-key: your-super-secret-random-key" \
-d '{
"to": "+14155550101",
"text": "Hello from Fastify and Vonage!"
}'# Expected (Success): {"message_uuid":"...","status":"submitted"}# Expected (Auth Failure): {"error":"Unauthorized"}# Expected (Validation Failure): {"statusCode":400,"error":"Bad Request","message":"body should have required property..."}
Test Inbound Webhook:
Send an SMS from your physical phone to your Vonage number. Check the running node src/server.js terminal output for logs from the /webhooks/inbound handler.
Test Status Webhook:
After successfully sending an SMS via the API, wait a few seconds/minutes. Check the running node src/server.js terminal output for logs from the /webhooks/status handler showing the delivery status.
6. Implementing Proper Error Handling, Logging, and Retry Mechanisms
Error Handling Strategy:
Use try...catch blocks around external calls (Vonage SDK)
Leverage Fastify's built-in schema validation for input errors (400 Bad Request)
Log errors with context (request ID, relevant data) using request.log.error
For webhook handlers, always return 200 OK quickly, handle processing errors asynchronously or log them thoroughly. Never let a processing error prevent the 200 OK response to Vonage
Logging:
Fastify's default logger (Pino) is production-ready. It logs requests, responses, and errors in JSON format, which integrates well with log aggregation tools (Datadog, Splunk, ELK).
Use different log levels: request.log.info() for general flow, request.log.warn() for potential issues, request.log.error() for failures
Include relevant context in logs (e.g., message_uuid, to number, campaignId)
Retry Mechanisms with p-retry:
Implement exponential backoff for transient failures:
JavaScript
// Install: npm install p-retryconst pRetry = require('p-retry');
// Wrap Vonage API call with retry logicconstsendSMSWithRetry = async (to, text) => {
returnpRetry(
async () => {
const resp = await vonage.messages.send({
channel: 'sms',
message_type: 'text',
to,
from: VONAGE_NUMBER,
text
});
return resp;
},
{
retries: 3,
factor: 2, // Exponential backoff factorminTimeout: 1000, // Start with 1 secondmaxTimeout: 10000, // Max 10 secondsonFailedAttempt: error => {
fastify.log.warn({
msg: 'SMS send attempt failed, retrying',
attempt: error.attemptNumber,
retriesLeft: error.retriesLeft,
error: error.message
});
},
// Only retry on specific errorsshouldRetry: error => {
// Don't retry on client errors (4xx) except rate limitingconst status = error?.response?.status;
if (status === 429) returntrue; // Rate limitif (status >= 400 && status < 500) returnfalse; // Other client errorsif (status >= 500) returntrue; // Server errorsreturntrue; // Network errors, timeouts
}
}
);
};
// Use in your route:
fastify.post('/send-sms', async (request, reply) => {
const { to, text } = request.body;
try {
const resp = awaitsendSMSWithRetry(to, text);
reply.status(200).send({
message_uuid: resp.message_uuid,
status: 'submitted'
});
} catch (error) {
reply.status(500).send({
error: 'Failed to send SMS after retries',
details: error?.response?.data || { message: error.message }
});
}
});
Error Classification:
Error Type
HTTP Status
Retry?
Action
Invalid credentials
401
No
Fix API keys
Insufficient balance
402
No
Add credits
Invalid number format
400
No
Validate input
Rate limit exceeded
429
Yes
Exponential backoff
Server error
500 – 504
Yes
Retry with backoff
Network timeout
–
Yes
Retry with backoff
Monitoring and Alerting:
Set up alerts for:
Failed message rate > 5%
API error rate > 1%
Webhook delivery failures
Low account balance
Rate limit hits
Popular monitoring tools:
Datadog (application monitoring)
Sentry (error tracking)
Grafana + Prometheus (metrics)
PagerDuty (alerting)
7. Security Best Practices
Webhook Signature Verification:
Verify that webhook requests actually come from Vonage:
JavaScript
const crypto = require('crypto');
// Add to your .env// VONAGE_SIGNATURE_SECRET=your-signature-secret-from-dashboardfunctionverifyVonageSignature(request) {
const signature = request.headers['x-nexmo-signature'] || request.headers['authorization'];
if (!signature) returnfalse;
const signatureSecret = process.env.VONAGE_SIGNATURE_SECRET;
if (!signatureSecret) {
fastify.log.warn('VONAGE_SIGNATURE_SECRET not configured, skipping signature verification');
returntrue; // Or return false to enforce
}
// Reconstruct signatureconst params = { ...request.body, ...request.query };
delete params.sig; // Remove signature from paramsconst sortedParams = Object.keys(params)
.sort()
.map(key =>`${key}=${params[key]}`)
.join('&');
const hash = crypto
.createHmac('sha256', signatureSecret)
.update(sortedParams)
.digest('hex');
return hash === signature;
}
// Use in webhook routes:
fastify.post('/webhooks/inbound', async (request, reply) => {
if (!verifyVonageSignature(request)) {
request.log.error('Invalid webhook signature');
return reply.code(401).send({ error: 'Unauthorized' });
}
// Continue processing...
});
Use the Vonage Messages API and the @vonage/server-sdk, along with a Fastify POST route. The route should handle requests containing the recipient's number and the message text, then use the Vonage SDK to submit the SMS. Remember to handle errors and log responses appropriately for monitoring and debugging.
The Vonage Messages API is a versatile communication platform that enables you to send and receive messages through different channels, including SMS. It provides a unified approach for managing messages, allowing developers to integrate SMS functionality into their applications easily and reliably.
Fastify is a high-performance Node.js web framework known for its speed and developer-friendly features. It offers built-in validation, logging, and extensibility, making it a suitable choice for building robust and efficient SMS applications.
ngrok is crucial during local development for testing Vonage webhooks. Because webhooks require a public URL, ngrok provides a secure tunnel to your local server, enabling Vonage to communicate with your application during testing.
First, obtain API Key and Secret from your Vonage Dashboard. Buy a Vonage virtual number with SMS capability and link it to the application. Next, create a Vonage Application, download the private.key file, and enable the 'Messages' capability, configuring inbound and status URLs. Configure the Messages API as the default SMS API in the API settings of the dashboard.
The private.key file contains security credentials unique to your Vonage application. It is generated alongside a public key stored by Vonage during the Vonage Application setup and is used to authenticate and authorize access to Vonage APIs, particularly with the recommended method for the Messages API, which is the Application ID and private key.
Set up webhook routes in your Fastify application that correspond to the Inbound and Status URLs configured in your Vonage application. The inbound webhook receives incoming messages, and the status webhook receives delivery receipts (DLRs). Always acknowledge webhook receipts with a 200 OK response immediately.
Dotenv loads environment variables from a .env file into process.env. This is crucial for managing sensitive credentials (API keys, secrets) securely, keeping them out of your codebase and allowing for different configurations per environment.
In the inbound webhook handler, implement logic to detect keywords like 'STOP' or 'UNSUBSCRIBE' in the incoming message text. When an opt-out is detected, update your database to mark the sender's number as opted out to ensure compliance with regulations.
The E.164 format is an international standard for phone numbers. It ensures consistent formatting, including the '+' sign and country code, for example, +14155550100. Using E.164 is essential for reliable SMS delivery with Vonage.
Validating environment variables ensures that your application has all the necessary configurations to run correctly. By checking for required variables at startup, you prevent unexpected errors and ensure proper functionality.
Use ngrok to create a public URL that tunnels requests to your local server. Configure your Vonage application's inbound and status webhook URLs to point to your ngrok URL. Then, you can send SMS messages to your Vonage number and observe the webhook requests in your local development environment.
The provided /send-campaign endpoint demonstrates a simplified approach. However, for production systems, sending messages individually in a loop is highly inefficient. Consider using Promise.allSettled with rate limiting or a message queue (e.g., BullMQ, RabbitMQ) for background processing and improved performance.
Implement authentication mechanisms to protect your API routes. A simple method is to use API keys via request headers like 'x-api-key'. For more robust security, consider industry-standard methods like JWT (JSON Web Tokens).
Build a robust Node.js application using Fastify and the Vonage Messages API to send and receive SMS messages for marketing campaigns. This guide covers project setup, Vonage configuration, core features, error handling, security, and deployment.
You'll build a functional backend system capable of sending targeted SMS messages and processing inbound replies – the foundation of an SMS marketing platform. This guide prioritizes production readiness with secure configuration, comprehensive error handling, and deployment considerations.
Legal Compliance and Regulations
Before building SMS marketing campaigns, understand the legal requirements that govern commercial text messaging.
TCPA Compliance (US)
The Telephone Consumer Protection Act (TCPA), with updates effective April 11, 2025, requires:
International Regulations
10DLC Registration (US)
For US marketing campaigns using standard phone numbers:
Without 10DLC registration, carriers may filter or block your messages.
Use Cases and Business Value
SMS marketing campaigns deliver measurable results:
Common Use Cases:
Key Benefits:
Project Overview and Goals
What You're Building:
You are building a Node.js backend service using the Fastify framework. This service will:
Problem Solved:
This system provides the core infrastructure needed to programmatically send SMS messages (e.g., for marketing alerts, notifications, promotions) and handle potential replies from recipients, enabling two-way SMS communication within a larger application or marketing workflow.
Technologies Used:
@vonage/server-sdk: The official Vonage Node.js SDK (v3.24.1 as of January 2025) for easy integration with Vonage APIsdotenv: A module to load environment variables from a.envfile intoprocess.envngrok(for development): A tool to expose local servers to the internet, necessary for testing Vonage webhooksSystem Architecture:
Prerequisites:
Estimated Costs:
Vonage SMS pricing varies by country:
A marketing campaign to 10,000 recipients costs approximately $75 – $100 (US).
1. Setting Up the Project
Initialize your Node.js project and install the necessary dependencies.
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir fastify-vonage-sms cd fastify-vonage-smsInitialize npm Project: This creates a
package.jsonfile to manage project dependencies and scripts.Install Dependencies: Install Fastify for the web server, the Vonage SDK, and
dotenvfor environment variables.Create Project Structure: Create directories and files for better maintainability.
mkdir src touch src/server.js touch .env touch .gitignoresrc/server.js: Contains your Fastify application logic.env: Stores sensitive credentials and configuration (API keys, phone numbers). Never commit this file to version control.gitignore: Specifies intentionally untracked files that Git should ignoreConfigure
.gitignore: Add the following lines to your.gitignorefile to prevent committing sensitive information and unnecessary files:Set up Environment Variables (
.env): Open the.envfile and add the following placeholders. Populate these values in the next section.2. Vonage Configuration
Before writing code, configure your Vonage account and application.
Get API Key and Secret:
.envfile forVONAGE_API_KEYandVONAGE_API_SECRETBuy a Vonage Number: You need a virtual number capable of sending and receiving SMS.
Choosing the Right Number Type:
For marketing campaigns, use a 10DLC-registered long code or toll-free number.
14155550100) and paste it into your.envfile forVONAGE_NUMBER10DLC Registration (Required for US Marketing):
After purchasing a US long code for marketing:
Create a Vonage Application: Vonage Applications act as containers for your communication configurations, including webhooks and security keys.
private.key. Save this file securely in the root directory of your project (the same level aspackage.json). The path./private.keyin your.envfile assumes it's here. Vonage stores the public keyhttp://localhost:3000/webhooks/inbound(will be updated)http://localhost:3000/webhooks/status(will be updated).envfile forVONAGE_APPLICATION_IDSet Default SMS API (Important): Vonage has older and newer APIs. Ensure the Messages API is the default for SMS.
Set up ngrok and Update Webhook URLs: Webhooks allow Vonage to send data (like incoming messages) to your application. Since your app runs locally during development,
ngrokcreates a public URL that tunnels requests to your local machine.Open a new terminal window (keep the first one for running the app later)
Run ngrok, telling it to forward to the port your Fastify app will run on (defined in
.envasPORT=3000)ngrok will display forwarding URLs (e.g.,
https://randomstring.ngrok.io). Copy the HTTPS URL. This is yourDEV_WEBHOOK_BASE_URLPaste the HTTPS ngrok URL into your
.envfile forDEV_WEBHOOK_BASE_URL. Ensure it includeshttps://Go back to your Vonage Application settings (Applications → Your App Name → Edit)
Update the Messages capability URLs:
YOUR_NGROK_HTTPS_URL/webhooks/inbound(e.g.,https://randomstring.ngrok.io/webhooks/inbound)YOUR_NGROK_HTTPS_URL/webhooks/status(e.g.,https://randomstring.ngrok.io/webhooks/status)Click Save changes
Why HTTPS? Vonage requires secure HTTPS URLs for webhooks in production and it's best practice even in development. ngrok provides this automatically
Why Status URL? This webhook receives delivery receipts (DLRs) indicating if a message was successfully delivered to the handset. Essential for tracking campaign success
3. Implementing Core Functionality: Sending SMS
Write the code to initialize Fastify and the Vonage SDK, and create an endpoint to send SMS messages.
File:
src/server.js// src/server.js 'use strict'; // Load environment variables require('dotenv').config(); // Import dependencies const Fastify = require('fastify'); const { Vonage } = require('@vonage/server-sdk'); const path = require('path'); // --- Configuration --- // Validate essential environment variables const requiredEnv = [ 'VONAGE_API_KEY', 'VONAGE_API_SECRET', 'VONAGE_APPLICATION_ID', 'VONAGE_PRIVATE_KEY_PATH', 'VONAGE_NUMBER', 'PORT', ]; for (const variable of requiredEnv) { if (!process.env[variable]) { console.error(`Error: Missing required environment variable ${variable}`); process.exit(1); // Exit if essential config is missing } } const PORT = process.env.PORT || 3000; const VONAGE_NUMBER = process.env.VONAGE_NUMBER; // Construct the absolute path to the private key // .env stores a relative path, but SDK might need absolute depending on execution context const privateKeyPath = path.resolve(process.cwd(), process.env.VONAGE_PRIVATE_KEY_PATH); // --- Initialize Fastify --- const fastify = Fastify({ logger: true, // Enable built-in Pino logger }); // --- Initialize Vonage SDK --- // Using Application ID and Private Key is recommended for Messages API const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, // Keep API Key/Secret for potential fallback or other API usage apiSecret: process.env.VONAGE_API_SECRET, applicationId: process.env.VONAGE_APPLICATION_ID, privateKey: privateKeyPath, }); // --- Routes --- // Simple health check route fastify.get('/health', async (request, reply) => { return { status: 'ok', timestamp: new Date().toISOString() }; }); /** * @route POST /send-sms * @description Sends a single SMS message. * @body {object} { to: string, text: string } - 'to' number in E.164 format, 'text' message content. */ fastify.post('/send-sms', { schema: { // Basic input validation body: { type: 'object', required: ['to', 'text'], properties: { to: { type: 'string', description: 'Recipient phone number in E.164 format (e.g., 14155550101)' }, text: { type: 'string', minLength: 1, description: 'The content of the SMS message' }, }, }, response: { 200: { type: 'object', properties: { message_uuid: { type: 'string' }, status: { type: 'string' }, }, }, 500: { type: 'object', properties: { error: { type: 'string' }, details: { type: 'object' } // Or string, depending on error } } } } }, async (request, reply) => { const { to, text } = request.body; request.log.info(`Attempting to send SMS to ${to}`); try { const resp = await vonage.messages.send({ channel: 'sms', message_type: 'text', to: to, from: VONAGE_NUMBER, // Use the Vonage number from .env text: text, }); request.log.info({ msg: 'SMS submitted successfully', response: resp }); reply.status(200).send({ message_uuid: resp.message_uuid, status: 'submitted', }); } catch (error) { request.log.error({ msg: 'Error sending SMS', error: error?.response?.data || error.message, to }); // Provide a more structured error response reply.status(500).send({ error: 'Failed to send SMS', details: error?.response?.data || { message: error.message }, // Include details from Vonage if available }); } }); // --- Start Server --- const start = async () => { try { await fastify.listen({ port: PORT, host: '0.0.0.0' }); // Listen on all network interfaces fastify.log.info(`Server listening on port ${PORT}`); fastify.log.info(`Vonage App ID: ${process.env.VONAGE_APPLICATION_ID}`); fastify.log.info(`Vonage Number: ${VONAGE_NUMBER}`); if (process.env.NODE_ENV !== 'production' && process.env.DEV_WEBHOOK_BASE_URL) { fastify.log.info(`Development Webhook Base URL (ngrok): ${process.env.DEV_WEBHOOK_BASE_URL}`); fastify.log.info(`Expected Inbound Webhook: ${process.env.DEV_WEBHOOK_BASE_URL}/webhooks/inbound`); fastify.log.info(`Expected Status Webhook: ${process.env.DEV_WEBHOOK_BASE_URL}/webhooks/status`); } else if (process.env.NODE_ENV !== 'production') { fastify.log.warn('DEV_WEBHOOK_BASE_URL not set in .env – ngrok webhook testing may fail.'); } } catch (err) { fastify.log.error(err); process.exit(1); } }; start();Explanation:
.envusingrequire('dotenv').config(). Validate that critical variables existprivate.keyusingpath.resolve. This makes path resolution more robust regardless of where you run the scriptlogger: trueto enable automatic request logging via Pino/healthRoute: A simple endpoint to check if the server is running/send-smsRoute (POST):to(string) andtext(non-empty string). If validation fails, Fastify automatically returns a 400 Bad Request errorrequest.log.info. Fastify injects the logger into the request objectvonage.messages.send(): This is the core SDK method. Specify:channel: 'sms'message_type: 'text'to: Recipient number from the request body. Must be in E.164 formatfrom: Your Vonage virtual number from.envtext: Message content from the request bodyasync/awaitmessage_uuid. Log success and send a 200 OK response with the UUIDtry...catchblock handles potential errors from the Vonage API (e.g., invalid number, insufficient funds). Log the error usingrequest.log.errorand return a 500 Internal Server Error with details from the Vonage error if available (error?.response?.data)startfunction listens on the configuredPORTand0.0.0.0(to be accessible outside localhost, e.g., by ngrok). It logs key configuration details on startupCommon Vonage API Error Codes:
Run the Application:
In your first terminal window (where you ran
npm install), start the server:You should see log output indicating the server is listening and showing your configuration. Keep this running.
4. Implementing Core Functionality: Receiving SMS (Webhooks)
Add the webhook endpoints configured in Vonage to handle incoming messages and status updates.
Add the following routes to
src/server.js(before the// --- Start Server ---section):// src/server.js // ... (keep existing code above) ... // --- Webhook Routes --- /** * @route POST /webhooks/inbound * @description Handles incoming SMS messages from Vonage. */ fastify.post('/webhooks/inbound', async (request, reply) => { const params = request.body; request.log.info({ msg: 'Inbound SMS received', data: params }); // --- IMPORTANT: Acknowledge receipt immediately --- // Vonage expects a quick 200 OK response. // Process the message asynchronously if needed, but respond first. reply.status(200).send(); // --- Process the incoming message --- console.log(`--- Received Message ---`); console.log(`From: ${params.from}`); console.log(`To: ${params.to}`); // Your Vonage number console.log(`Text: ${params.text}`); console.log(`Message ID: ${params.message_uuid}`); console.log(`Timestamp: ${params.timestamp}`); console.log(`-----------------------`); // CRITICAL: TCPA Compliance – Opt-Out Detection const messageText = params.text?.toLowerCase() || ''; const standardOptOuts = ['stop', 'end', 'cancel', 'unsubscribe', 'quit']; const informalOptOuts = ['leave me alone', 'remove me', 'no more', 'dont text', "don't text"]; const isOptOut = standardOptOuts.some(keyword => messageText.includes(keyword)) || informalOptOuts.some(phrase => messageText.includes(phrase)); if (isOptOut) { request.log.warn({ msg: 'Opt-out request detected', from: params.from, text: params.text }); // TODO: Update database to mark contact as opted out // Example: await db.contacts.update({ phone: params.from }, { opted_out: true, opted_out_at: new Date() }); // Send confirmation message try { await vonage.messages.send({ channel: 'sms', message_type: 'text', to: params.from, from: params.to, text: 'You have been unsubscribed and will not receive further messages. Reply HELP for assistance or RESTART to resubscribe.' }); request.log.info(`Opt-out confirmation sent to ${params.from}`); } catch (error) { request.log.error({ msg: 'Failed to send opt-out confirmation', error: error?.response?.data || error.message }); } } // Example: Auto-reply for specific keywords (use cautiously) /* if (messageText.includes('hello')) { try { await vonage.messages.send({ channel: 'sms', message_type: 'text', to: params.from, // Reply to the sender from: params.to, // Use the number the message was sent TO text: 'Hi there! We received your message.' }); request.log.info(`Auto-reply sent to ${params.from}`); } catch (error) { request.log.error({ msg: 'Failed to send auto-reply', error: error?.response?.data || error.message }); } } */ }); /** * @route POST /webhooks/status * @description Handles delivery receipts (DLRs) and other message status updates from Vonage. */ fastify.post('/webhooks/status', async (request, reply) => { const params = request.body; request.log.info({ msg: 'Message status update received', data: params }); // --- IMPORTANT: Acknowledge receipt immediately --- reply.status(200).send(); // --- Process the status update --- console.log(`--- Status Update ---`); console.log(`Message ID: ${params.message_uuid}`); console.log(`To: ${params.to}`); console.log(`From: ${params.from}`); console.log(`Status: ${params.status}`); console.log(`Timestamp: ${params.timestamp}`); if (params.error) { console.error(`Error Code: ${params.error.code}`); console.error(`Error Reason: ${params.error.reason}`); } console.log(`--------------------`); // Example: Log failed messages if (['failed', 'rejected', 'undeliverable'].includes(params.status?.toLowerCase())) { request.log.warn({ msg: 'SMS delivery failed', details: params }); // Add logic here: notify admin, update CRM, etc. } else if (params.status?.toLowerCase() === 'delivered') { request.log.info({ msg: 'SMS delivered successfully', uuid: params.message_uuid, to: params.to }); // Update analytics, mark campaign contact as reached } }); // --- Start Server --- // ... (keep existing start function) ...Explanation:
/webhooks/inbound:request.log.info: Logs the entire incoming payload. Study this structure to see available fields (senderfrom, recipientto,text,message_uuid, etc.)reply.status(200).send();: This is CRITICAL. Vonage expects a quick confirmation (within a few seconds) that you received the webhook. If it doesn't get a 200 OK, it will retry sending, potentially multiple times. Send the response before doing any heavy processingMessage Status Values:
submitteddeliveredfailedrejectedundeliverableexpired/webhooks/status:statusfield (delivered,failed,submitted,rejected, etc.) andmessage_uuid. Use the UUID to correlate this status update with the message you sent earlier (likely stored in your database). Handlingfailedorrejectedstatuses is crucial for campaign analysis and potentially retryingDatabase Schema for Opt-Out Tracking:
CREATE TABLE contacts ( id SERIAL PRIMARY KEY, phone VARCHAR(20) UNIQUE NOT NULL, opted_in BOOLEAN DEFAULT false, opted_out BOOLEAN DEFAULT false, opted_out_at TIMESTAMP, consent_date TIMESTAMP, consent_method VARCHAR(50), -- 'web_form', 'api', 'manual' created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_contacts_opted_out ON contacts(opted_out); CREATE INDEX idx_contacts_phone ON contacts(phone);Restart the Application:
Stop the running server (Ctrl+C) and restart it to load the new webhook routes:
Ensure your
ngroktunnel is still running in the other terminal window.5. Building a Complete API Layer
Refine the API for production-ready marketing campaigns.
Refining
/send-sms(Enhanced Validation):Enhance the schema validation in
src/server.js:// Inside fastify.post('/send-sms', ...) schema definition: properties: { to: { type: 'string', description: 'Recipient phone number in E.164 format (e.g., +14155550101)', // Basic E.164 pattern check (adjust regex as needed for stricter validation) pattern: '^\\+?[1-9]\\d{1,14}' }, text: { type: 'string', minLength: 1, maxLength: 1600, // SMS technically has segment limits, but API handles longer messages description: 'The content of the SMS message' }, // Optional: Add a campaign ID for tracking campaignId: { type: 'string', description: 'Optional identifier for the marketing campaign' } },SMS Message Segmentation and Costs:
Standard SMS messages are limited to 160 characters (GSM-7 encoding) or 70 characters (Unicode/UCS-2). The Vonage Messages API automatically splits longer messages into multiple segments and reassembles them on the recipient's device according to carrier specifications. Messages up to 1,600 characters are accepted, but will be billed as multiple message segments.
Message Length and Segment Calculation:
Cost Optimization Tips:
Adding a Production-Ready
/send-campaignEndpoint:Use a message queue for scalable campaign sending:
// Add this route in src/server.js // First, install BullMQ: npm install bullmq ioredis const { Queue, Worker } = require('bullmq'); const Redis = require('ioredis'); // Initialize Redis connection (required for BullMQ) const redisConnection = new Redis({ host: process.env.REDIS_HOST || 'localhost', port: process.env.REDIS_PORT || 6379, maxRetriesPerRequest: null, }); // Create message queue const smsQueue = new Queue('sms-campaign', { connection: redisConnection }); // Create worker to process queue const smsWorker = new Worker('sms-campaign', async (job) => { const { to, text, campaignId } = job.data; try { const resp = await vonage.messages.send({ channel: 'sms', message_type: 'text', to, from: VONAGE_NUMBER, text }); fastify.log.info({ msg: 'Campaign SMS sent', campaignId, to, uuid: resp.message_uuid }); return { status: 'submitted', message_uuid: resp.message_uuid, to }; } catch (error) { fastify.log.error({ msg: 'Campaign SMS failed', campaignId, to, error: error?.response?.data || error.message }); throw error; // BullMQ will handle retries } }, { connection: redisConnection, concurrency: 10, // Process 10 messages simultaneously limiter: { max: 10, // Max 10 jobs duration: 1000, // per 1 second (adjust based on your rate limit) } }); /** * @route POST /send-campaign * @description Sends the same message to multiple recipients using a job queue. * @body {object} { recipients: string[], text: string, campaignId?: string } */ fastify.post('/send-campaign', { schema: { body: { type: 'object', required: ['recipients', 'text'], properties: { recipients: { type: 'array', minItems: 1, maxItems: 10000, // Reasonable limit items: { type: 'string', pattern: '^\\+?[1-9]\\d{1,14}' // E.164 basic check } }, text: { type: 'string', minLength: 1, maxLength: 1600 }, campaignId: { type: 'string' } } }, response: { 200: { type: 'object', properties: { queued_count: { type: 'number' }, campaign_id: { type: 'string' }, estimated_completion_time: { type: 'string' } } } } } }, async (request, reply) => { const { recipients, text, campaignId } = request.body; const finalCampaignId = campaignId || `campaign-${Date.now()}`; request.log.info({ msg: 'Queueing campaign', campaignId: finalCampaignId, recipient_count: recipients.length }); // Add all messages to queue const jobs = recipients.map(to => ({ name: 'send-sms', data: { to, text, campaignId: finalCampaignId }, opts: { attempts: 3, // Retry failed messages 3 times backoff: { type: 'exponential', delay: 2000 // Start with 2 second delay, doubles each retry } } })); await smsQueue.addBulk(jobs); // Estimate completion time (10 messages/sec with rate limiter) const estimatedSeconds = Math.ceil(recipients.length / 10); const estimatedCompletion = new Date(Date.now() + estimatedSeconds * 1000).toISOString(); reply.status(200).send({ queued_count: recipients.length, campaign_id: finalCampaignId, estimated_completion_time: estimatedCompletion }); });Vonage API Rate Limits:
Authentication (Production-Ready API Key):
Protect your API endpoints with API key authentication.
.env:src/server.jsbefore defining your protected routes:// src/server.js // ... after Fastify initialization ... const API_SECRET_KEY = process.env.API_SECRET_KEY; if (!API_SECRET_KEY && process.env.NODE_ENV === 'production') { fastify.log.warn('API_SECRET_KEY is not set in production environment! API is unprotected.'); // Optionally exit: process.exit(1); } // --- Hooks (Middleware) --- // Simple API Key Authentication Hook fastify.addHook('onRequest', async (request, reply) => { // Allow health check and webhooks without API key if (request.url === '/health' || request.url.startsWith('/webhooks/')) { return; } // Skip auth if no key is configured (useful for local dev without key) if (!API_SECRET_KEY) { request.log.warn('Skipping API key check as API_SECRET_KEY is not set.'); return; } const apiKey = request.headers['x-api-key']; // Or 'Authorization: Bearer YOUR_KEY' if (!apiKey || apiKey !== API_SECRET_KEY) { request.log.warn({ msg: 'Unauthorized API access attempt', ip: request.ip, url: request.url }); reply.code(401).send({ error: 'Unauthorized' }); throw new Error('Unauthorized'); } request.log.info('API key validated successfully.'); }); // --- Routes --- // Define /send-sms, /send-campaign, etc. *after* the hook // ... rest of your routes ...Note on Authentication: For high-security production environments, implement JWT tokens or OAuth2 instead of simple API keys. API keys in headers can be intercepted if not using HTTPS or if the key is exposed in client-side code.
API Endpoint Testing (cURL):
curl http://localhost:3000/health # Expected: {"status":"ok","timestamp":"..."}curl -X POST http://localhost:3000/send-sms \ -H "Content-Type: application/json" \ -H "x-api-key: your-super-secret-random-key" \ -d '{ "to": "+14155550101", "text": "Hello from Fastify and Vonage!" }' # Expected (Success): {"message_uuid":"...","status":"submitted"} # Expected (Auth Failure): {"error":"Unauthorized"} # Expected (Validation Failure): {"statusCode":400,"error":"Bad Request","message":"body should have required property..."}node src/server.jsterminal output for logs from the/webhooks/inboundhandler.node src/server.jsterminal output for logs from the/webhooks/statushandler showing the delivery status.6. Implementing Proper Error Handling, Logging, and Retry Mechanisms
Error Handling Strategy:
try...catchblocks around external calls (Vonage SDK){ "error": "message", "details": {...} })request.log.errorLogging:
Fastify's default logger (Pino) is production-ready. It logs requests, responses, and errors in JSON format, which integrates well with log aggregation tools (Datadog, Splunk, ELK).
request.log.info()for general flow,request.log.warn()for potential issues,request.log.error()for failuresmessage_uuid,tonumber,campaignId)Retry Mechanisms with p-retry:
Implement exponential backoff for transient failures:
// Install: npm install p-retry const pRetry = require('p-retry'); // Wrap Vonage API call with retry logic const sendSMSWithRetry = async (to, text) => { return pRetry( async () => { const resp = await vonage.messages.send({ channel: 'sms', message_type: 'text', to, from: VONAGE_NUMBER, text }); return resp; }, { retries: 3, factor: 2, // Exponential backoff factor minTimeout: 1000, // Start with 1 second maxTimeout: 10000, // Max 10 seconds onFailedAttempt: error => { fastify.log.warn({ msg: 'SMS send attempt failed, retrying', attempt: error.attemptNumber, retriesLeft: error.retriesLeft, error: error.message }); }, // Only retry on specific errors shouldRetry: error => { // Don't retry on client errors (4xx) except rate limiting const status = error?.response?.status; if (status === 429) return true; // Rate limit if (status >= 400 && status < 500) return false; // Other client errors if (status >= 500) return true; // Server errors return true; // Network errors, timeouts } } ); }; // Use in your route: fastify.post('/send-sms', async (request, reply) => { const { to, text } = request.body; try { const resp = await sendSMSWithRetry(to, text); reply.status(200).send({ message_uuid: resp.message_uuid, status: 'submitted' }); } catch (error) { reply.status(500).send({ error: 'Failed to send SMS after retries', details: error?.response?.data || { message: error.message } }); } });Error Classification:
Monitoring and Alerting:
Set up alerts for:
Popular monitoring tools:
7. Security Best Practices
Webhook Signature Verification:
Verify that webhook requests actually come from Vonage:
const crypto = require('crypto'); // Add to your .env // VONAGE_SIGNATURE_SECRET=your-signature-secret-from-dashboard function verifyVonageSignature(request) { const signature = request.headers['x-nexmo-signature'] || request.headers['authorization']; if (!signature) return false; const signatureSecret = process.env.VONAGE_SIGNATURE_SECRET; if (!signatureSecret) { fastify.log.warn('VONAGE_SIGNATURE_SECRET not configured, skipping signature verification'); return true; // Or return false to enforce } // Reconstruct signature const params = { ...request.body, ...request.query }; delete params.sig; // Remove signature from params const sortedParams = Object.keys(params) .sort() .map(key => `${key}=${params[key]}`) .join('&'); const hash = crypto .createHmac('sha256', signatureSecret) .update(sortedParams) .digest('hex'); return hash === signature; } // Use in webhook routes: fastify.post('/webhooks/inbound', async (request, reply) => { if (!verifyVonageSignature(request)) { request.log.error('Invalid webhook signature'); return reply.code(401).send({ error: 'Unauthorized' }); } // Continue processing... });Input Sanitization:
Prevent injection attacks:
// Install: npm install validator const validator = require('validator'); fastify.addHook('onRequest', async (request, reply) => { // Sanitize text content to prevent script injection if (request.body?.text) { request.body.text = validator.escape(request.body.text); } });Rate Limiting:
Protect against API abuse:
// Install: npm install @fastify/rate-limit const rateLimit = require('@fastify/rate-limit'); await fastify.register(rateLimit, { max: 100, // 100 requests timeWindow: '1 minute', // per minute errorResponseBuilder: (request, context) => { return { error: 'Too many requests', limit: context.max, remaining: 0, reset: new Date(Date.now() + context.ttl).toISOString() }; } });8. Database Integration
Store contacts, campaigns, and message history:
// Install: npm install pg // PostgreSQL connection example const { Pool } = require('pg'); const pool = new Pool({ host: process.env.DB_HOST || 'localhost', port: process.env.DB_PORT || 5432, database: process.env.DB_NAME || 'sms_campaigns', user: process.env.DB_USER, password: process.env.DB_PASSWORD, }); // Store sent message async function logSentMessage(to, text, messageUuid, campaignId) { await pool.query( `INSERT INTO messages (to_number, message_text, message_uuid, campaign_id, status, created_at) VALUES ($1, $2, $3, $4, $5, NOW())`, [to, text, messageUuid, campaignId, 'submitted'] ); } // Update message status async function updateMessageStatus(messageUuid, status, errorDetails = null) { await pool.query( `UPDATE messages SET status = $1, error_details = $2, updated_at = NOW() WHERE message_uuid = $3`, [status, errorDetails, messageUuid] ); } // Check opt-out status before sending async function isOptedOut(phoneNumber) { const result = await pool.query( 'SELECT opted_out FROM contacts WHERE phone = $1', [phoneNumber] ); return result.rows[0]?.opted_out || false; } // Use in routes: fastify.post('/send-sms', async (request, reply) => { const { to, text } = request.body; // Check opt-out status if (await isOptedOut(to)) { return reply.code(400).send({ error: 'Recipient has opted out' }); } try { const resp = await vonage.messages.send({ channel: 'sms', message_type: 'text', to, from: VONAGE_NUMBER, text }); // Log to database await logSentMessage(to, text, resp.message_uuid, request.body.campaignId); reply.status(200).send({ message_uuid: resp.message_uuid, status: 'submitted' }); } catch (error) { reply.status(500).send({ error: 'Failed to send SMS' }); } }); // Update status from webhook fastify.post('/webhooks/status', async (request, reply) => { reply.status(200).send(); const { message_uuid, status, error } = request.body; await updateMessageStatus(message_uuid, status, error); });9. Deployment
Deploy your application to production:
Environment Setup:
Environment Variables: Set production environment variables in your hosting platform (Heroku, AWS, DigitalOcean, etc.):
Update Webhook URLs: In the Vonage Dashboard, update your application's webhook URLs to your production domain:
https://yourdomain.com/webhooks/inboundhttps://yourdomain.com/webhooks/statusHosting Options:
Docker Deployment:
Create
Dockerfile:Build and deploy:
docker build -t sms-campaign-api . docker run -p 8080:8080 --env-file .env sms-campaign-apiScaling Considerations:
10. Troubleshooting
Common Issues:
.envDebug Checklist:
Testing Webhooks Locally:
Use the Vonage CLI to forward webhooks:
npm install -g @vonage/cli vonage config:set --apiKey=YOUR_KEY --apiSecret=YOUR_SECRET vonage apps:link YOUR_APP_ID11. Campaign Analytics and Metrics
Track campaign performance:
// Campaign metrics endpoint fastify.get('/campaigns/:campaignId/metrics', async (request, reply) => { const { campaignId } = request.params; const metrics = await pool.query(` SELECT COUNT(*) as total_sent, SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) as delivered, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected, AVG(EXTRACT(EPOCH FROM (updated_at - created_at))) as avg_delivery_time_seconds FROM messages WHERE campaign_id = $1 `, [campaignId]); const stats = metrics.rows[0]; const deliveryRate = (stats.delivered / stats.total_sent * 100).toFixed(2); reply.send({ campaign_id: campaignId, total_sent: parseInt(stats.total_sent), delivered: parseInt(stats.delivered), failed: parseInt(stats.failed), rejected: parseInt(stats.rejected), delivery_rate: `${deliveryRate}%`, avg_delivery_time_seconds: parseFloat(stats.avg_delivery_time_seconds).toFixed(2) }); });Key Metrics to Track:
12. Cost Optimization
Strategies to Reduce Costs:
Message Length Optimization:
Segment Management:
Send Time Optimization:
Carrier Selection:
Summary
You've built a production-ready SMS marketing campaign system with:
Next Steps: