This guide provides a complete walkthrough for building a robust Node.js application using the Fastify framework to send and receive SMS messages for marketing campaigns via the Vonage Messages API. We'll cover everything from project setup and Vonage configuration to implementing core features, handling errors, ensuring security, and deploying your application.
By the end of this guide, you will have a functional backend system capable of sending targeted SMS messages and processing inbound replies, forming the foundation of an SMS marketing platform. We prioritize production readiness, including aspects like secure configuration, error handling, and deployment considerations.
Project Overview and Goals
What We're Building:
We 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 basic logging and error handling.
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, chosen for its speed, extensibility, and developer-friendly features like built-in validation and logging.
- Vonage Messages API: A powerful communications API 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 for easy integration with Vonage APIs.dotenv
: A module to load environment variables from a.env
file intoprocess.env
.ngrok
(for development): A tool to expose local servers to the internet, necessary for testing Vonage webhooks.
System Architecture:
+-----------------+ +-----------------+ +----------------+ +---------------+
| 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) |
+----------------+
(Note: ASCII diagram rendering may vary. A graphical representation might be clearer in some viewers.)
Prerequisites:
- Node.js: Installed (LTS version recommended). 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.
- Basic Terminal/Command Line Knowledge: Familiarity with navigating directories and running commands.
- Code Editor: Like VS Code_ Sublime Text_ etc.
1. Setting Up the Project
Let's initialize our 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-sms
-
Initialize npm Project: This creates a
package.json
file to manage project dependencies and scripts.npm init -y
-
Install Dependencies: We need Fastify for the web server_ the Vonage SDK_ and
dotenv
for environment variables.npm install fastify @vonage/server-sdk dotenv
-
Create Project Structure: A good structure helps maintainability. Let's create some basic directories and files.
mkdir src touch src/server.js touch .env touch .gitignore
src/server.js
: Will contain our 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 ignore.
-
Configure
.gitignore
: Add the following lines to your.gitignore
file to prevent committing sensitive information and unnecessary files:# Dependencies node_modules/ # Environment variables .env # Logs *.log # OS generated files .DS_Store Thumbs.db
-
Set up Environment Variables (
.env
): Open the.env
file and add the following placeholders. We will populate these values in the next section.# 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).
2. Vonage Configuration
Before writing code_ we need to configure our Vonage account and application.
-
Get API Key and Secret:
- Log in to your Vonage API Dashboard.
- Your API Key and API Secret are displayed prominently on the main dashboard page.
- Copy these values and paste them into your
.env
file forVONAGE_API_KEY
andVONAGE_API_SECRET
.
-
Buy a Vonage Number:
- You need a virtual number capable of sending and receiving SMS.
- 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 forVONAGE_NUMBER
.
-
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 aspackage.json
). The path./private.key
in your.env
file assumes it's here. The public key is stored by Vonage. - Enable the Messages capability.
- Inbound URL: We'll fill this shortly with our 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).
- Inbound URL: We'll fill this shortly with our ngrok URL. Placeholder:
- Click Generate new application.
- On the application details page, copy the Application ID and paste it into your
.env
file forVONAGE_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
asPORT=3000
).ngrok http 3000
-
ngrok will display forwarding URLs (e.g.,
https://randomstring.ngrok.io
). Copy the HTTPS URL. This is yourDEV_WEBHOOK_BASE_URL
. -
Paste the HTTPS ngrok URL into your
.env
file forDEV_WEBHOOK_BASE_URL
. Ensure it includeshttps://
. -
Go back to your Vonage Application settings (Applications -> Your App Name -> Edit).
-
Update the Messages capability URLs:
- Inbound URL:
YOUR_NGROK_HTTPS_URL/webhooks/inbound
(e.g.,https://randomstring.ngrok.io/webhooks/inbound
) - Status URL:
YOUR_NGROK_HTTPS_URL/webhooks/status
(e.g.,https://randomstring.ngrok.io/webhooks/status
)
- Inbound URL:
-
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
Let's 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:
- Environment Variables: We load
.env
usingrequire('dotenv').config()
. We then perform essential validation to ensure critical variables are present. - Private Key Path: We construct an absolute path to
private.key
usingpath.resolve
. This makes the path resolution more robust regardless of where you run the script from. - Fastify Initialization: We create a Fastify instance with
logger: true
to enable automatic request logging via Pino. - Vonage SDK Initialization: We initialize the SDK using the Application ID and the path to the private key, which is the standard authentication method for the Messages API. API Key/Secret are included for completeness but primary auth here uses the App ID/Key.
/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) andtext
(non-empty string). If validation fails, Fastify automatically returns a 400 Bad Request error. - Logging: We log the attempt using
request.log.info
. Fastify injects the logger into the request object. vonage.messages.send()
: This is the core SDK method. We 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 we use
async/await
. - Success Response: If the API call is accepted by Vonage (doesn't mean delivered yet), it returns a
message_uuid
. We 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). We log the error usingrequest.log.error
and return a 500 Internal Server Error with details from the Vonage error if available (error?.response?.data
).
- Schema Validation: Fastify's built-in schema validation ensures the request body contains
- Server Start: The
start
function listens on the configuredPORT
and0.0.0.0
(to be accessible outside localhost, e.g., by ngrok). It logs key configuration details on startup.
Run the Application:
In your first terminal window (where you ran npm install
), start the server:
node src/server.js
You should see log output indicating the server is listening and showing your configuration. Keep this running.
4. Implementing Core Functionality: Receiving SMS (Webhooks)
Now, let's 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 (Example) ---
// In a real application, you would:
// 1. Validate the payload structure (optional but recommended).
// 2. Identify the sender (`params.from`).
// 3. Store the message content (`params.text`) and metadata in a database.
// 4. Trigger business logic (e.g., handle STOP keywords for opt-outs, route to support).
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(`-----------------------`);
// Crucially, implement logic to detect opt-out keywords (like 'STOP')
// and update the contact's subscription status in your database to comply with regulations.
// Example: Simple auto-reply (Use cautiously to avoid loops/costs)
/*
if (params.text.toLowerCase().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 (Example) ---
// In a real application, you would:
// 1. Find the original message in your database using `params.message_uuid`.
// 2. Update its status (e.g., 'delivered', 'failed', 'rejected').
// 3. Handle failures (e.g., retry logic, notify admin).
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
:- 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 (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. You should send the response before doing any heavy processing.- Processing: The code includes comments and
console.log
examples showing how you'd typically process the message (save to DB, check keywords, trigger logic). Handling opt-out requests (like STOP) is essential for compliance. The commented-out auto-reply shows how you could respond, but be careful with automated replies.
/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.) andmessage_uuid
. You'd use the UUID to correlate this status update with the message you sent earlier (likely stored in your database). Handlingfailed
orrejected
statuses is crucial for campaign analysis and potentially retrying.
Restart the Application:
Stop the running server (Ctrl+C) and restart it to load the new webhook routes:
node src/server.js
Ensure your ngrok
tunnel is still running in the other terminal window.
5. Building a Complete API Layer
Our current /send-sms
endpoint is basic. For a ""marketing campaign,"" you might want a more structured approach. Let's refine the API.
Refining /send-sms
(Optional - Add more validation):
You can 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' }
},
Adding a Simple /send-campaign
Endpoint (Conceptual):
A real campaign endpoint would involve fetching contacts from a database, handling rate limits, scheduling, etc. Here's a simplified concept:
// Add this route in src/server.js
/**
* @route POST /send-campaign (Conceptual Example)
* @description Sends the same message to multiple recipients.
* NOTE: This is simplified. Production use needs rate limiting,
* error handling per recipient, and likely background processing.
* @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,
items: {
type: 'string',
pattern: '^\\+?[1-9]\\d{1,14}' // E.164 basic check
}
},
text: { type: 'string', minLength: 1, maxLength: 1600 },
campaignId: { type: 'string' }
}
},
response: { // Simplified response
200: {
type: 'object',
properties: {
submitted_count: { type: 'number' },
failed_count: { type: 'number' },
results: { type: 'array', items: {
type: 'object',
properties: { to: { type: 'string' }, status: { type: 'string' }, message_uuid: { type: 'string' }, error: { type: 'string' } }
} }
}
}
}
}
}, async (request, reply) => {
const { recipients, text, campaignId } = request.body;
request.log.info({ msg: 'Starting campaign send', campaignId, recipient_count: recipients.length });
const results = [];
let submitted_count = 0;
let failed_count = 0;
// WARNING: HIGHLY INEFFICIENT & NOT PRODUCTION-READY. Sending sequentially like this is very slow, blocks the server thread, and doesn't scale.
// Use Promise.allSettled for parallel sending, add rate limiting,
// or ideally use a message queue (e.g., BullMQ, RabbitMQ) for background processing.
for (const to of recipients) {
try {
const resp = await vonage.messages.send({
channel: 'sms', message_type: 'text', to, from: VONAGE_NUMBER, text
});
results.push({ to, status: 'submitted', message_uuid: resp.message_uuid });
submitted_count++;
request.log.info({ msg: 'Campaign SMS submitted', campaignId, to, uuid: resp.message_uuid });
// VERY NAIVE DELAY: This is a crude way to *attempt* rate limiting and blocks the event loop. DO NOT use setTimeout like this in production. Use a proper queue.
await new Promise(resolve => setTimeout(resolve, 200)); // Adjust delay as needed
} catch (error) {
request.log.error({ msg: 'Campaign SMS failed', campaignId, to, error: error?.response?.data || error.message });
results.push({ to, status: 'failed', error: error?.response?.data?.title || error.message });
failed_count++;
}
}
reply.status(200).send({ submitted_count, failed_count, results });
});
Authentication (Basic API Key Example):
For production, you need to protect your API. Here's a very basic API Key check using a Fastify hook.
- Add an API key to your
.env
:API_SECRET_KEY=your-super-secret-random-key
- Add the hook in
src/server.js
before 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' });
// Throwing error stops further processing
throw new Error('Unauthorized'); // Or just return reply inside the hook
}
request.log.info('API key validated successfully.');
});
// --- Routes ---
// Define /send-sms, /send-campaign, etc. *after* the hook
// ... rest of your routes ...
API Endpoint Testing (cURL):
- Health Check:
curl http://localhost:3000/health # Expected: {""status"":""ok"",""timestamp"":""...""}
- Send SMS (with basic auth):
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).
- Return consistent JSON error responses (e.g.,
{ ""error"": ""message"", ""details"": {...} }
). - 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.
- Use
- Logging:
- Fastify's default logger (Pino) is excellent and production-ready. It logs requests, responses, and errors in JSON format, which is great for 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 (Conceptual):
- Sending: If
vonage.messages.send()
fails due to transient network issues or temporary Vonage problems (e.g., 5xx errors), implement a retry strategy.- Use libraries like
async-retry
orp-retry
. - Implement exponential backoff (e.g., wait 1s, 2s, 4s between retries).
- Limit the number of retries.
- Caution: Do not retry on errors indicating permanent failure (e.g., invalid number format (400), insufficient funds (402)).
- Use libraries like
- Receiving (Webhooks): Vonage handles retries automatically if your endpoint doesn't return 200 OK quickly. Your primary responsibility is to respond fast. If your processing after the 200 OK fails, you might need a background job queue (like BullMQ) with its own retry logic to reprocess the webhook data later.
- Sending: If