Build a Node.js Fastify SMS Marketing Campaign Service with Infobip
Complete guide to building a production-ready Node.js application using Fastify to send marketing SMS messages via the Infobip API with error handling, security, and best practices.
Build a production-ready Node.js application using Fastify to send marketing SMS messages via the Infobip API. This guide covers project setup, core implementation, API definition, Infobip integration, error handling, security, testing, and deployment.
Project Overview and Goals
This guide shows you how to build a backend service that sends targeted SMS messages for marketing campaigns. While Infobip offers robust features for managing campaigns (like 10DLC registration), this guide focuses on the sending mechanism via their API, integrated into a scalable Fastify application.
Problem Solved: Build a structured, performant, and maintainable way to programmatically send SMS messages through Infobip from a Node.js backend for promotional alerts, notifications, or other campaign-related communications.
Technologies Used:
Node.js: JavaScript runtime environment chosen for its asynchronous nature, large ecosystem (npm), and suitability for I/O-bound tasks like API interactions.
Fastify: High-performance, low-overhead web framework for Node.js chosen for its speed, extensive plugin architecture, developer experience, and built-in schema validation.
Infobip Node.js SDK: Official library for interacting with the Infobip API, simplifying authentication, request formatting, and response handling compared to raw HTTP calls.
dotenv: Module to load environment variables from a .env file into process.env, essential for managing configuration and secrets securely.
pino-pretty: Development dependency that makes Fastify's default JSON logs human-readable during development.
Expected Outcome: A functional Fastify API endpoint (/send-sms) that accepts a destination phone number and message content, sends the SMS via Infobip, and returns the status.
Basic understanding of JavaScript, Node.js, REST APIs, and terminal commands
(Optional but Recommended for US Traffic) An approved Brand and Campaign registered within Infobip for 10DLC compliance. This guide focuses on sending; register campaigns separately via the Infobip portal or Number Registration API. See Infobip 10DLC Campaigns Documentation.
1. Setting up the Project
Initialize your Node.js project using Fastify.
Create Project Directory:
Open your terminal and create a new directory for the project, then navigate into it.
Bash
mkdir fastify-infobip-sms
cd fastify-infobip-sms
Initialize Node.js Project:
Create a package.json file.
Bash
npm init -y
Install Dependencies:
Install Fastify, the Infobip SDK, and dotenv. Add pino-pretty as a development dependency for readable logs.
src/controllers/: Handles request logic and calls services.
src/services/: Encapsulates business logic, like interacting with Infobip.
src/config/: Holds configuration-related files.
Configure Environment Variables:
Create a .env file in the project root to store sensitive information like API keys. Never commit this file to version control.
Create a .env.example file to show necessary variables (commit this one).
.env.example:
ini
# Infobip API CredentialsINFOBIP_API_KEY=YOUR_INFOBIP_API_KEY
INFOBIP_BASE_URL=YOUR_INFOBIP_BASE_URL
# Application SettingsPORT=3000LOG_LEVEL=info
# Optional: Sender ID for SMS (e.g., 'InfoSMS', requires setup in Infobip)# INFOBIP_SENDER_ID=YourSenderID
.env:
ini
# Infobip API CredentialsINFOBIP_API_KEY=paste_your_actual_api_key_here
INFOBIP_BASE_URL=paste_your_actual_base_url_here
# Application SettingsPORT=3000LOG_LEVEL=info
# INFOBIP_SENDER_ID=OptionalSender
Why: Using .env separates configuration from code, enhancing security and making it easy to manage different environments (development, staging, production).
Create .gitignore:
Ensure sensitive files and generated directories are not tracked by Git.
// Load environment variables earlyrequire('dotenv').config();
// Require the framework and instantiate itconst fastify = require('fastify')({
logger: {
level: process.env.LOG_LEVEL || 'info',
// Use pino-pretty only in development for readabilitytransport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
},
});
// Register routes
fastify.register(require('./src/routes/smsRoutes'));
// Health check route
fastify.get('/health', async (request, reply) => {
return { status: 'ok', timestamp: newDate().toISOString() };
});
// Run the server!conststart = async () => {
try {
const port = process.env.PORT || 3000;
await fastify.listen({ port: parseInt(port, 10), host: '0.0.0.0' });
fastify.log.info(`Server listening on port ${port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Why: This sets up a Fastify server, configures logging (using pino-pretty in development), registers routes, adds a health check endpoint, and starts listening for requests.
Add Run Scripts to package.json:
JSON
{"scripts":{"start":"node server.js","dev":"node --watch server.js | pino-pretty","test":"echo \"Error: no test specified yet\" && exit 1"}}
npm start: Runs the server normally.
npm run dev: Runs the server using Node's built-in watch mode (requires Node.js 18.11+) for auto-restarts on file changes and pipes logs through pino-pretty.
Run npm run dev and access http://localhost:3000/health in your browser to verify the server is running.
2. Implementing Core Functionality (SMS Service)
Create a service to handle the interaction with the Infobip SDK.
Create Infobip Service:
This service encapsulates the logic for sending SMS messages.
src/services/infobipService.js:
JavaScript
const { Infobip, AuthType } = require('@infobip-api/sdk');
// Ensure required environment variables are presentif (!process.env.INFOBIP_API_KEY || !process.env.INFOBIP_BASE_URL) {
// Log this critical error at the application start or use a dedicated config validation stepconsole.error('CRITICAL: Missing Infobip API Key or Base URL in environment variables.');
thrownewError('Infobip API Key and Base URL are required.');
}
// Initialize Infobip client instanceconst infobipClient = newInfobip({
baseUrl: process.env.INFOBIP_BASE_URL,
apiKey: process.env.INFOBIP_API_KEY,
authType: AuthType.ApiKey, // Using API Key for authentication
});
/**
* Sends a single SMS message using the Infobip API.
* @param {string} destinationNumber - The recipient's phone number in international format (e.g., 447123456789).
* @param {string} messageText - The text content of the SMS.
* @param {string} [senderId] - Optional sender ID (must be pre-configured in Infobip). Defaults to process.env.INFOBIP_SENDER_ID.
* @returns {Promise<object>} - The response object from the Infobip API.
* @throws {Error} - Throws an error if the API call fails, potentially including details from Infobip.
*/constsendSingleSms = async (destinationNumber, messageText, senderId = process.env.INFOBIP_SENDER_ID) => {
// Logging for this action will be handled by the controller calling this serviceconst payload = {
messages: [
{
destinations: [{ to: destinationNumber }],
text: messageText,
// Only include 'from' if senderId is provided
...(senderId && { from: senderId }),
},
],
};
try {
// Use the Infobip SDK to send the SMSconst response = await infobipClient.channels.sms.send(payload);
// Success logging handled by the controllerreturn response.data; // Return the data part of the response
} catch (error) {
// Error logging handled by the controller, re-throw to propagate// Consider wrapping the error for more context if needed// e.g., throw new Error(`Infobip API Error: ${error.message}`);throw error; // Re-throw the original error (or a wrapped one)
}
};
module.exports = {
sendSingleSms,
// Add other Infobip-related functions here (e.g., sendBulkSms, checkStatus)
};
Why: Encapsulating external API interactions within a dedicated service makes the code modular, easier to test (by mocking the service), and isolates Infobip-specific logic from the controllers. Logging related to specific requests is handled in the controller, which has request context.
3. Building the API Layer
Create the Fastify route and controller to expose the SMS sending functionality.
Create SMS Controller:
This handles the incoming HTTP request, validates the input, calls the service, and sends the response.
src/controllers/smsController.js:
JavaScript
const infobipService = require('../services/infobipService');
/**
* Handles the request to send an SMS message.
* @param {import('fastify').FastifyRequest} request - The Fastify request object, includes request.log.
* @param {import('fastify').FastifyReply} reply - The Fastify reply object.
*/constsendSmsHandler = async (request, reply) => {
const { destinationNumber, message } = request.body;
request.log.info(`Received request to send SMS to ${destinationNumber}`);
try {
const result = await infobipService.sendSingleSms(destinationNumber, message);
// Check Infobip response structure for success indication// NOTE: Check Infobip docs for the exact fields/values indicating successful acceptance vs. immediate rejection.// The structure and 'groupName' might vary.const messageStatus = result.messages?.[0]?.status;
if (messageStatus && messageStatus.groupName === 'PENDING') {
request.log.info(`SMS for ${destinationNumber} accepted by Infobip (status: PENDING): Message ID ${result.messages?.[0]?.messageId}`);
reply.code(202).send({
message: 'SMS accepted for delivery.',
infobipResponse: result,
});
} else {
// Handle cases where the message might be processed but not in a clear 'PENDING' state, or rejected immediately.
request.log.warn({infobipResponse: result}, `SMS for ${destinationNumber} processed by Infobip, but status is not PENDING (or status missing). Check response.`);
reply.code(200).send({
message: 'SMS processed by Infobip, check response status details.',
infobipResponse: result,
});
}
} catch (error) {
request.log.error({ err: error, destination: destinationNumber }, `Failed to process send SMS request: ${error.message}`);
// Log Infobip specific details if availableif (error.response?.data) {
request.log.error({ infobipError: error.response.data }, 'Infobip API error details');
}
// Send a generic error response to the client
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to send SMS due to an internal issue.',
// Optionally include more detail in non-production environmentsdetails: process.env.NODE_ENV !== 'production' ? error.message : undefined,
});
}
};
module.exports = {
sendSmsHandler,
};
Why: The controller bridges the HTTP layer and the service layer. It extracts data from the request, uses the request-specific logger (request.log), invokes the service function, and formats the HTTP response based on the outcome (success or error).
Define API Route and Schema:
Configure the Fastify route, including input validation using JSON Schema.
src/routes/smsRoutes.js:
JavaScript
const smsController = require('../controllers/smsController');
// Define the schema for the request body validationconst sendSmsSchema = {
body: {
type: 'object',
required: ['destinationNumber', 'message'],
properties: {
destinationNumber: {
type: 'string',
description: 'Recipient phone number in international E.164 format (e.g., +447123456789)',
// Example pattern - ensures starts with optional +, then digits, covering international format.pattern: '^\\+?[1-9]\\d{1,14}$'
},
message: {
type: 'string',
description: 'The text content of the SMS message',
minLength: 1,
maxLength: 1600// Example max length, check Infobip limits
},
},
additionalProperties: false, // Disallow extra properties in the request body
},
response: {
// Define expected success response schemas200: { // Processed, check statusdescription: 'SMS processed by Infobip, check response for detailed status.',
type: 'object',
properties: {
message: { type: 'string' },
infobipResponse: { type: 'object' }, // Be more specific based on actual API response if needed
},
},
202: { // Accepted for deliverydescription: 'SMS accepted by Infobip for delivery.',
type: 'object',
properties: {
message: { type: 'string' },
infobipResponse: { type: 'object' },
},
},
// Define error response schemas (Fastify handles 500 generically, but you can customize)500: {
description: 'Internal server error occurred.',
type: 'object',
properties: {
error: { type: 'string', example: 'Internal Server Error' },
message: { type: 'string' },
details: { type: 'string', description: 'Error details (only in non-production)' }
}
}
// Add 4xx schemas here too (e.g., 400 for validation errors - Fastify generates this, but you can document it)
},
};
/**
* Encapsulates the routes for SMS functionality.
* @param {import('fastify').FastifyInstance} fastify - The Fastify instance.
* @param {object} options - Plugin options.
* @param {function} done - Callback function to signal plugin readiness.
*/asyncfunctionsmsRoutes(fastify, options) {
fastify.post('/send-sms', { schema: sendSmsSchema }, smsController.sendSmsHandler);
// Add other SMS-related routes here (e.g., /sms/status/:messageId)
}
module.exports = smsRoutes;
Why: Fastify's built-in schema validation (sendSmsSchema) automatically checks if the incoming request body matches the defined structure and format. This prevents invalid data from reaching the controller and provides automatic 400 Bad Request responses for invalid inputs.
Testing the Endpoint:
Run the server (npm run dev). Test the endpoint using curl or a tool like Postman.
curl Example:
Bash
curl -X POST http://localhost:3000/send-sms \
-H "Content-Type: application/json" \
-d '{
"destinationNumber": "+12345678900",
"message": "Hello from Fastify and Infobip!"
}'
Replace +12345678900 with a valid number (your own if using Infobip free trial).
{"message":"SMS accepted for delivery.","infobipResponse":{"bulkId":"some-bulk-id","messages":[{"to":"+12345678900","status":{"groupId":1,"groupName":"PENDING","id":26,"name":"PENDING_ACCEPTED","description":"Message accepted"},"messageId":"some-unique-message-id"}]}}
Example Error Response (e.g., Invalid Phone Number Format - Status Code 400 Bad Request):
JSON
{"statusCode":400,"error":"Bad Request","message":"body/destinationNumber must match pattern \"^\\\\+?[1-9]\\\\d{1,14}$\""}
4. Integrating with Infobip (Deep Dive)
We've already initialized the SDK, but let's detail the configuration steps.
API Key: Navigate to Apps > API Keys. Create a new API key if you don't have one. Copy the key value securely. Treat this like a password.
Base URL: Your Base URL is specific to your account. You can usually find it on the Infobip portal homepage after logging in, or within the API documentation examples tailored to your account. It looks something like xxxxx.api.infobip.com.
(Optional) Sender ID: To use a custom sender name (like your brand name instead of a number), you need to configure and potentially register it within Infobip under Channels and Numbers > Sender Names. Regulations vary by country.
Secure Storage:
As done in Step 1.5, store INFOBIP_API_KEY and INFOBIP_BASE_URL in your .env file for local development.
Ensure .env is listed in your .gitignore file.
In production environments, use secure secret management solutions provided by your cloud provider (e.g., AWS Secrets Manager, Azure Key Vault, GCP Secret Manager) or tools like HashiCorp Vault instead of .env files. Load these secrets into environment variables during deployment.
SDK Initialization (Recap):
The code in src/services/infobipService.js handles this:
baseUrl: Tells the SDK which Infobip API endpoint to communicate with.
apiKey: The credential used for authentication.
authType: AuthType.ApiKey: Specifies the authentication method.
Fallback Mechanisms (Conceptual):
While the SDK handles basic retries for network issues, robust applications might need more:
Retry Logic: For transient errors (like temporary network issues or Infobip rate limits), implement application-level retries with exponential backoff using libraries like async-retry. Wrap the infobipService.sendSingleSms call within the controller or add retry logic inside the service method itself (see Section 5).
Circuit Breaker: Use a library like opossum to implement a circuit breaker pattern. If Infobip API calls consistently fail, the circuit breaker ""opens,"" preventing further calls for a period, reducing load on both systems.
Alternative Provider (Advanced): For critical notifications, you might configure a secondary SMS provider and switch to it if Infobip experiences a prolonged outage (requires significant architectural planning).
5. Error Handling, Logging, and Retry Mechanisms
Building on the basics.
Consistent Error Handling:
Service Layer: Catches errors from the SDK in the service (infobipService.js). Re-throws the error (potentially wrapped) to be handled by the controller.
Controller Layer: Catches errors propagated from the service in the controller (smsController.js). Uses request.log to log detailed errors, including context like the request body (sanitized if necessary) and any error details from Infobip. Sends an appropriate HTTP status code (e.g., 500 for server errors, 503 for temporary unavailability if using retries/circuit breaker) and a generic error message to the client, avoiding leaking internal details in production.
Fastify Hooks: Use Fastify's setErrorHandler hook for centralized formatting of error responses before they are sent to the client, ensuring consistency.
Logging:
Levels: Use different log levels (info, warn, error, debug). Set LOG_LEVEL via environment variable (info for production, debug for development).
Context: Fastify's logger automatically includes request details when using request.log. Log key events: incoming requests (Fastify does this), interactions with external services (Infobip calls - logged in controller), successful operations (SMS accepted - logged in controller), and errors (with stack traces and context - logged in controller).
Format: Use JSON format for logs in production (Fastify's default) for easier parsing by log aggregation tools (like Datadog, Splunk, ELK stack). Use pino-pretty only during development.
Correlation IDs: Implement request/correlation IDs (e.g., using fastify-request-id or relying on Fastify's built-in reqId) to trace a single request through logs across different services or operations. request.log automatically includes this.
Retry Strategy (Example using async-retry in the Service):
Install: npm install async-retry
Modify src/services/infobipService.js:
JavaScript
const retry = require('async-retry');
const { Infobip, AuthType } = require('@infobip-api/sdk');
// ... env var checks and client init ...constsendSingleSms = async (destinationNumber, messageText, senderId = process.env.INFOBIP_SENDER_ID) => {
const payload = { /* ... payload definition ... */ };
// Wrap the SDK call in retry logicreturnretry(async (bail, attempt) => {
// Note: Logging within the retry loop might be verbose.// Consider logging only on failure or final success.// The controller will log the overall attempt and final outcome.try {
console.log(`[Attempt ${attempt}] Calling Infobip API for ${destinationNumber}`); // Use console or a passed-in loggerconst response = await infobipClient.channels.sms.send(payload);
return response.data; // Return data on success
} catch (error) {
console.warn(`[Attempt ${attempt}] Error sending SMS via Infobip: ${error.message}`);
// Decide if the error is retryable// Example: Don't retry on 4xx client errors (bad request, auth failed)// Check Infobip's specific error codes for retryable conditions (e.g., rate limits 429, server errors 5xx)if (error.response && error.response.status >= 400 && error.response.status < 500 && error.response.status !== 429) {
console.error(`Non-retryable client error (${error.response.status}) from Infobip. Bailing out.`);
bail(newError(`Infobip client error (${error.response.status}): ${error.message}`)); // Stop retryingreturn; // Required after bail
}
// For potentially retryable errors (5xx, 429, network issues), throw to trigger retryconsole.warn(`Retryable error encountered (status: ${error.response?.status}). Retrying...`);
throw error;
}
}, {
retries: 3, // Number of retriesfactor: 2, // Exponential backoff factorminTimeout: 1000, // Initial delay msmaxTimeout: 5000, // Max delay msonRetry: (error, attempt) => {
console.warn(`Retrying Infobip request (Attempt ${attempt}) due to error: ${error.message}`);
}
});
};
module.exports = { sendSingleSms };
Note on Logging: Using console.log inside the service is shown for simplicity here. In a real application, you'd ideally pass the request.log instance (or a generic logger instance) into the service function if you need detailed logging within the service or retry loop itself.
Testing Errors: Mock the infobipClient.channels.sms.send method in tests to throw specific errors (network errors, 4xx errors, 5xx errors, 429 errors) and verify the retry logic and error handling paths work as expected.
6. Database Schema and Data Layer (Conceptual)
For a real marketing campaign system, you'd need a database. While this guide focuses on the sending mechanism, here's a conceptual overview:
Purpose: Store user lists/segments, campaign details, message templates, individual message statuses (mapping your internal ID to Infobip's messageId), opt-out information, etc.
Technology: MongoDB, PostgreSQL, MySQL, or similar. Choose based on your data structure needs and team familiarity.
Data Layer: Implement repositories or data access objects (DAOs) to interact with the database (e.g., using Mongoose for MongoDB or an ORM like Prisma/Sequelize for SQL). Keep database logic separate from services. Your Fastify application would interact with this layer.
Migrations: Use tools like migrate-mongo (MongoDB) or the ORM's built-in migration tools (Prisma Migrate, Sequelize CLI) to manage schema changes over time.
Note: Implementing the database layer is beyond the scope of this specific sending guide but crucial for a complete campaign system.
7. Security Features
Security is paramount, especially when handling user data and API keys.
Input Validation:
Fastify Schemas: Already implemented (sendSmsSchema). Ensures only expected data types and formats are accepted. Crucial against injection attacks and basic data corruption.
Sanitization: While validation helps, consider sanitizing message content if it includes user-generated input to prevent potential issues, although direct XSS is less likely via SMS itself. Basic checks or escaping might be relevant depending on how messages are constructed.
Secrets Management:
Use environment variables (.env locally, secure secret stores in production).
Never hardcode API keys or sensitive data in source code. Ensure .gitignore prevents committing .env.
Rate Limiting:
Protect your API from abuse and ensure fair usage. Use @fastify/rate-limit.
Install: npm install @fastify/rate-limit
Register in server.js (before your routes):
JavaScript
// In server.js, after fastify instantiation
fastify.register(require('@fastify/rate-limit'), {
max: 100, // Max requests per window per IP (default)timeWindow: '1 minute'// Consider more sophisticated keying (e.g., by API key if authenticated)
});
Adjust max and timeWindow based on expected load and Infobip's own rate limits.
Authentication/Authorization (If applicable):
If this API endpoint should not be public, protect it. Use strategies like:
API Keys: Implement a check for a custom X-API-Key header using a Fastify preHandler hook or @fastify/auth.
JWT: If users log in elsewhere, validate JWTs using @fastify/jwt.
OAuth: For third-party integrations, use @fastify/oauth2.
Other Considerations:
Helmet: Use @fastify/helmet to set various security-related HTTP headers. npm install @fastify/helmet, then fastify.register(require('@fastify/helmet')) in server.js.
Dependency Updates: Regularly update dependencies (npm audit fix, use tools like Dependabot/Snyk) to patch known vulnerabilities.
HTTPS: Ensure your service runs over HTTPS in production (usually handled by a load balancer, reverse proxy like Nginx/Caddy, or PaaS).
8. Handling Special Cases
Real-world SMS sending involves nuances:
Sender ID (from field): Regulations vary globally. Some countries require pre-registration (Alphanumeric Sender ID), others override it with a local number (e.g., shared short code, long code). Use the optional senderId parameter in sendSingleSms and configure it correctly in Infobip. Ensure compliance with local laws (check Infobip docs for country specifics).
Character Sets and Encoding: Standard SMS (GSM 03.38) supports 160 characters. Using non-standard characters (like emojis or certain accented letters) switches to UCS-2 encoding, reducing the limit to 70 characters per SMS segment. Long messages are split into multiple segments. Be mindful of message length and character usage to manage costs and ensure readability. The Infobip API/SDK typically handles encoding automatically, but awareness is important.
Concatenated Messages: Long messages are automatically split and reassembled by the receiving device. Infobip handles this segmentation. Be aware of the per-segment cost.
Delivery Reports (DLRs): Infobip can send status updates (delivered, failed, etc.) back to your application via webhooks. Set up a separate endpoint in your Fastify app to receive these DLRs, parse them, and update the status of the corresponding message in your database (requires storing the messageId from the initial send response). This is crucial for tracking campaign effectiveness and handling failures. Implementing DLR handling is a common next step after basic sending.
Opt-Out Handling (STOP Keywords): Regulations (like TCPA in the US) require handling opt-out requests (e.g., replying STOP). Infobip can often manage standard keyword responses automatically if configured. You also need to ensure your application respects opt-outs stored in your database and doesn't send messages to unsubscribed numbers.
10DLC Compliance (US): Sending Application-to-Person (A2P) SMS to US numbers using standard 10-digit long codes requires registering your Brand and specific Campaigns with The Campaign Registry (TCR) via providers like Infobip. This involves defining the message use case (e.g., marketing, notifications). Sending without registration risks filtering or blocking. This guide assumes registration is handled separately via Infobip's portal or APIs.
Rate Limits (Infobip Side): Infobip imposes rate limits on API calls and message sending throughput (messages per second), which can vary based on your account, destination country, and sender type (e.g., Toll-Free, Short Code, 10DLC). Implement rate limiting (@fastify/rate-limit) and potentially retry logic (Section 5) in your application to avoid exceeding these limits and handle 429 Too Many Requests errors gracefully.
Frequently asked questions
Use the Infobip Node.js SDK within a Fastify application. Create a service to handle Infobip interactions, a controller to manage requests and responses, and a route to expose the functionality via an API endpoint like '/send-sms'. This structure promotes modularity and maintainability.
Fastify is a high-performance Node.js web framework chosen for its speed and plugin architecture. It handles routing, request handling, input validation (using JSON Schema), and provides a structure for building scalable API endpoints.
Dotenv loads environment variables from a '.env' file, separating configuration from code. This enhances security by keeping API keys out of version control and simplifies managing settings across different environments (development, production).
10DLC registration is required for sending Application-to-Person (A2P) SMS to US numbers using standard 10-digit long codes. Register your brand and campaigns with The Campaign Registry (TCR) through Infobip to comply with US regulations and avoid message filtering.
Yes, you can use an Alphanumeric Sender ID (your brand name) but it requires configuration within your Infobip account and might need registration, depending on the country. Use the optional 'senderId' parameter when sending messages, adhering to local regulations.
Pino-pretty is a development dependency used to format Fastify's default JSON logs into a more human-readable form during development. It simplifies debugging and monitoring during the development process.
Implement try...catch blocks in both the service and controller layers. Log errors with context using 'request.log' in the controller, including any Infobip-specific error details. Return appropriate HTTP status codes (like 500) and generic error messages to the client.
You'll need an Infobip account (free or paid), your Infobip API Key and Base URL, Node.js and npm installed, and a basic understanding of JavaScript, Node.js, REST APIs, and terminal commands. For US traffic, 10DLC registration is often recommended.
A client (web app, CLI) sends requests to the Fastify API server. The server interacts with the Infobip API to send SMS messages. The server includes routes, controllers, services, configuration, and logging components.
A functional Fastify API endpoint ('/send-sms') that can accept a destination phone number and message content, send the SMS through Infobip, and return the status of the message delivery attempt.
Infobip automatically handles splitting long messages into multiple segments (concatenated SMS). Be aware of per-segment costs. Standard SMS allows 160 characters; using non-standard characters reduces this to 70 per segment.
Store the API key in a '.env' file locally (included in '.gitignore') and use secure secret management solutions (like AWS Secrets Manager or HashiCorp Vault) in production. Never hardcode API keys in your source code.
The Infobip Node.js SDK simplifies interacting with the Infobip API. It streamlines authentication, request formatting, and response handling compared to making raw HTTP calls, making development more efficient.
Infobip can manage standard opt-out keywords (like STOP) automatically with proper configuration. Your application should also respect opt-outs recorded in your database and avoid sending to unsubscribed numbers, ensuring compliance with regulations like TCPA.
The '/health' endpoint allows monitoring tools and load balancers to check if the server is running and responding. It returns a simple JSON response like '{ "status": "ok" }' to confirm server health.
Build a production-ready Node.js application using Fastify to send marketing SMS messages via the Infobip API. This guide covers project setup, core implementation, API definition, Infobip integration, error handling, security, testing, and deployment.
Project Overview and Goals
This guide shows you how to build a backend service that sends targeted SMS messages for marketing campaigns. While Infobip offers robust features for managing campaigns (like 10DLC registration), this guide focuses on the sending mechanism via their API, integrated into a scalable Fastify application.
Problem Solved: Build a structured, performant, and maintainable way to programmatically send SMS messages through Infobip from a Node.js backend for promotional alerts, notifications, or other campaign-related communications.
Technologies Used:
.envfile intoprocess.env, essential for managing configuration and secrets securely.System Architecture:
Expected Outcome: A functional Fastify API endpoint (
/send-sms) that accepts a destination phone number and message content, sends the SMS via Infobip, and returns the status.Prerequisites:
1. Setting up the Project
Initialize your Node.js project using Fastify.
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir fastify-infobip-sms cd fastify-infobip-smsInitialize Node.js Project: Create a
package.jsonfile.Install Dependencies: Install Fastify, the Infobip SDK, and
dotenv. Addpino-prettyas a development dependency for readable logs.Create Project Structure: Organize the project for clarity and maintainability.
mkdir src mkdir src/routes mkdir src/controllers mkdir src/services mkdir src/configsrc/: Contains all source code.src/routes/: Defines API routes.src/controllers/: Handles request logic and calls services.src/services/: Encapsulates business logic, like interacting with Infobip.src/config/: Holds configuration-related files.Configure Environment Variables: Create a
.envfile in the project root to store sensitive information like API keys. Never commit this file to version control. Create a.env.examplefile to show necessary variables (commit this one)..env.example:# Infobip API Credentials INFOBIP_API_KEY=YOUR_INFOBIP_API_KEY INFOBIP_BASE_URL=YOUR_INFOBIP_BASE_URL # Application Settings PORT=3000 LOG_LEVEL=info # Optional: Sender ID for SMS (e.g., 'InfoSMS', requires setup in Infobip) # INFOBIP_SENDER_ID=YourSenderID.env:# Infobip API Credentials INFOBIP_API_KEY=paste_your_actual_api_key_here INFOBIP_BASE_URL=paste_your_actual_base_url_here # Application Settings PORT=3000 LOG_LEVEL=info # INFOBIP_SENDER_ID=OptionalSenderWhy: Using
.envseparates configuration from code, enhancing security and making it easy to manage different environments (development, staging, production).Create
.gitignore: Ensure sensitive files and generated directories are not tracked by Git..gitignore:Basic Server Setup: Create the main server file.
server.js(in project root):// Load environment variables early require('dotenv').config(); // Require the framework and instantiate it const fastify = require('fastify')({ logger: { level: process.env.LOG_LEVEL || 'info', // Use pino-pretty only in development for readability transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined, }, }); // Register routes fastify.register(require('./src/routes/smsRoutes')); // Health check route fastify.get('/health', async (request, reply) => { return { status: 'ok', timestamp: new Date().toISOString() }; }); // Run the server! const start = async () => { try { const port = process.env.PORT || 3000; await fastify.listen({ port: parseInt(port, 10), host: '0.0.0.0' }); fastify.log.info(`Server listening on port ${port}`); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();Why: This sets up a Fastify server, configures logging (using
pino-prettyin development), registers routes, adds a health check endpoint, and starts listening for requests.Add Run Scripts to
package.json:{ "scripts": { "start": "node server.js", "dev": "node --watch server.js | pino-pretty", "test": "echo \"Error: no test specified yet\" && exit 1" } }npm start: Runs the server normally.npm run dev: Runs the server using Node's built-in watch mode (requires Node.js 18.11+) for auto-restarts on file changes and pipes logs throughpino-pretty.Run
npm run devand accesshttp://localhost:3000/healthin your browser to verify the server is running.2. Implementing Core Functionality (SMS Service)
Create a service to handle the interaction with the Infobip SDK.
Create Infobip Service: This service encapsulates the logic for sending SMS messages.
src/services/infobipService.js:const { Infobip, AuthType } = require('@infobip-api/sdk'); // Ensure required environment variables are present if (!process.env.INFOBIP_API_KEY || !process.env.INFOBIP_BASE_URL) { // Log this critical error at the application start or use a dedicated config validation step console.error('CRITICAL: Missing Infobip API Key or Base URL in environment variables.'); throw new Error('Infobip API Key and Base URL are required.'); } // Initialize Infobip client instance const infobipClient = new Infobip({ baseUrl: process.env.INFOBIP_BASE_URL, apiKey: process.env.INFOBIP_API_KEY, authType: AuthType.ApiKey, // Using API Key for authentication }); /** * Sends a single SMS message using the Infobip API. * @param {string} destinationNumber - The recipient's phone number in international format (e.g., 447123456789). * @param {string} messageText - The text content of the SMS. * @param {string} [senderId] - Optional sender ID (must be pre-configured in Infobip). Defaults to process.env.INFOBIP_SENDER_ID. * @returns {Promise<object>} - The response object from the Infobip API. * @throws {Error} - Throws an error if the API call fails, potentially including details from Infobip. */ const sendSingleSms = async (destinationNumber, messageText, senderId = process.env.INFOBIP_SENDER_ID) => { // Logging for this action will be handled by the controller calling this service const payload = { messages: [ { destinations: [{ to: destinationNumber }], text: messageText, // Only include 'from' if senderId is provided ...(senderId && { from: senderId }), }, ], }; try { // Use the Infobip SDK to send the SMS const response = await infobipClient.channels.sms.send(payload); // Success logging handled by the controller return response.data; // Return the data part of the response } catch (error) { // Error logging handled by the controller, re-throw to propagate // Consider wrapping the error for more context if needed // e.g., throw new Error(`Infobip API Error: ${error.message}`); throw error; // Re-throw the original error (or a wrapped one) } }; module.exports = { sendSingleSms, // Add other Infobip-related functions here (e.g., sendBulkSms, checkStatus) };Why: Encapsulating external API interactions within a dedicated service makes the code modular, easier to test (by mocking the service), and isolates Infobip-specific logic from the controllers. Logging related to specific requests is handled in the controller, which has request context.
3. Building the API Layer
Create the Fastify route and controller to expose the SMS sending functionality.
Create SMS Controller: This handles the incoming HTTP request, validates the input, calls the service, and sends the response.
src/controllers/smsController.js:const infobipService = require('../services/infobipService'); /** * Handles the request to send an SMS message. * @param {import('fastify').FastifyRequest} request - The Fastify request object, includes request.log. * @param {import('fastify').FastifyReply} reply - The Fastify reply object. */ const sendSmsHandler = async (request, reply) => { const { destinationNumber, message } = request.body; request.log.info(`Received request to send SMS to ${destinationNumber}`); try { const result = await infobipService.sendSingleSms(destinationNumber, message); // Check Infobip response structure for success indication // NOTE: Check Infobip docs for the exact fields/values indicating successful acceptance vs. immediate rejection. // The structure and 'groupName' might vary. const messageStatus = result.messages?.[0]?.status; if (messageStatus && messageStatus.groupName === 'PENDING') { request.log.info(`SMS for ${destinationNumber} accepted by Infobip (status: PENDING): Message ID ${result.messages?.[0]?.messageId}`); reply.code(202).send({ message: 'SMS accepted for delivery.', infobipResponse: result, }); } else { // Handle cases where the message might be processed but not in a clear 'PENDING' state, or rejected immediately. request.log.warn({infobipResponse: result}, `SMS for ${destinationNumber} processed by Infobip, but status is not PENDING (or status missing). Check response.`); reply.code(200).send({ message: 'SMS processed by Infobip, check response status details.', infobipResponse: result, }); } } catch (error) { request.log.error({ err: error, destination: destinationNumber }, `Failed to process send SMS request: ${error.message}`); // Log Infobip specific details if available if (error.response?.data) { request.log.error({ infobipError: error.response.data }, 'Infobip API error details'); } // Send a generic error response to the client reply.code(500).send({ error: 'Internal Server Error', message: 'Failed to send SMS due to an internal issue.', // Optionally include more detail in non-production environments details: process.env.NODE_ENV !== 'production' ? error.message : undefined, }); } }; module.exports = { sendSmsHandler, };Why: The controller bridges the HTTP layer and the service layer. It extracts data from the request, uses the request-specific logger (
request.log), invokes the service function, and formats the HTTP response based on the outcome (success or error).Define API Route and Schema: Configure the Fastify route, including input validation using JSON Schema.
src/routes/smsRoutes.js:const smsController = require('../controllers/smsController'); // Define the schema for the request body validation const sendSmsSchema = { body: { type: 'object', required: ['destinationNumber', 'message'], properties: { destinationNumber: { type: 'string', description: 'Recipient phone number in international E.164 format (e.g., +447123456789)', // Example pattern - ensures starts with optional +, then digits, covering international format. pattern: '^\\+?[1-9]\\d{1,14}$' }, message: { type: 'string', description: 'The text content of the SMS message', minLength: 1, maxLength: 1600 // Example max length, check Infobip limits }, }, additionalProperties: false, // Disallow extra properties in the request body }, response: { // Define expected success response schemas 200: { // Processed, check status description: 'SMS processed by Infobip, check response for detailed status.', type: 'object', properties: { message: { type: 'string' }, infobipResponse: { type: 'object' }, // Be more specific based on actual API response if needed }, }, 202: { // Accepted for delivery description: 'SMS accepted by Infobip for delivery.', type: 'object', properties: { message: { type: 'string' }, infobipResponse: { type: 'object' }, }, }, // Define error response schemas (Fastify handles 500 generically, but you can customize) 500: { description: 'Internal server error occurred.', type: 'object', properties: { error: { type: 'string', example: 'Internal Server Error' }, message: { type: 'string' }, details: { type: 'string', description: 'Error details (only in non-production)' } } } // Add 4xx schemas here too (e.g., 400 for validation errors - Fastify generates this, but you can document it) }, }; /** * Encapsulates the routes for SMS functionality. * @param {import('fastify').FastifyInstance} fastify - The Fastify instance. * @param {object} options - Plugin options. * @param {function} done - Callback function to signal plugin readiness. */ async function smsRoutes(fastify, options) { fastify.post('/send-sms', { schema: sendSmsSchema }, smsController.sendSmsHandler); // Add other SMS-related routes here (e.g., /sms/status/:messageId) } module.exports = smsRoutes;Why: Fastify's built-in schema validation (
sendSmsSchema) automatically checks if the incoming request body matches the defined structure and format. This prevents invalid data from reaching the controller and provides automatic 400 Bad Request responses for invalid inputs.Testing the Endpoint: Run the server (
npm run dev). Test the endpoint usingcurlor a tool like Postman.curlExample:curl -X POST http://localhost:3000/send-sms \ -H "Content-Type: application/json" \ -d '{ "destinationNumber": "+12345678900", "message": "Hello from Fastify and Infobip!" }'Replace
+12345678900with a valid number (your own if using Infobip free trial).Expected Successful Response (Status Code 202 Accepted - if Infobip returns PENDING):
{ "message": "SMS accepted for delivery.", "infobipResponse": { "bulkId": "some-bulk-id", "messages": [ { "to": "+12345678900", "status": { "groupId": 1, "groupName": "PENDING", "id": 26, "name": "PENDING_ACCEPTED", "description": "Message accepted" }, "messageId": "some-unique-message-id" } ] } }Example Error Response (e.g., Invalid Phone Number Format - Status Code 400 Bad Request):
{ "statusCode": 400, "error": "Bad Request", "message": "body/destinationNumber must match pattern \"^\\\\+?[1-9]\\\\d{1,14}$\"" }4. Integrating with Infobip (Deep Dive)
We've already initialized the SDK, but let's detail the configuration steps.
Obtain Infobip Credentials:
xxxxx.api.infobip.com.Secure Storage:
INFOBIP_API_KEYandINFOBIP_BASE_URLin your.envfile for local development..envis listed in your.gitignorefile..envfiles. Load these secrets into environment variables during deployment.SDK Initialization (Recap): The code in
src/services/infobipService.jshandles this:const { Infobip, AuthType } = require('@infobip-api/sdk'); // ... check for env vars ... const infobipClient = new Infobip({ baseUrl: process.env.INFOBIP_BASE_URL, apiKey: process.env.INFOBIP_API_KEY, authType: AuthType.ApiKey, });baseUrl: Tells the SDK which Infobip API endpoint to communicate with.apiKey: The credential used for authentication.authType: AuthType.ApiKey: Specifies the authentication method.Fallback Mechanisms (Conceptual): While the SDK handles basic retries for network issues, robust applications might need more:
async-retry. Wrap theinfobipService.sendSingleSmscall within the controller or add retry logic inside the service method itself (see Section 5).opossumto implement a circuit breaker pattern. If Infobip API calls consistently fail, the circuit breaker ""opens,"" preventing further calls for a period, reducing load on both systems.5. Error Handling, Logging, and Retry Mechanisms
Building on the basics.
Consistent Error Handling:
infobipService.js). Re-throws the error (potentially wrapped) to be handled by the controller.smsController.js). Usesrequest.logto log detailed errors, including context like the request body (sanitized if necessary) and any error details from Infobip. Sends an appropriate HTTP status code (e.g., 500 for server errors, 503 for temporary unavailability if using retries/circuit breaker) and a generic error message to the client, avoiding leaking internal details in production.setErrorHandlerhook for centralized formatting of error responses before they are sent to the client, ensuring consistency.Logging:
info,warn,error,debug). SetLOG_LEVELvia environment variable (infofor production,debugfor development).request.log. Log key events: incoming requests (Fastify does this), interactions with external services (Infobip calls - logged in controller), successful operations (SMS accepted - logged in controller), and errors (with stack traces and context - logged in controller).pino-prettyonly during development.fastify-request-idor relying on Fastify's built-inreqId) to trace a single request through logs across different services or operations.request.logautomatically includes this.Retry Strategy (Example using
async-retryin the Service):npm install async-retrysrc/services/infobipService.js:const retry = require('async-retry'); const { Infobip, AuthType } = require('@infobip-api/sdk'); // ... env var checks and client init ... const sendSingleSms = async (destinationNumber, messageText, senderId = process.env.INFOBIP_SENDER_ID) => { const payload = { /* ... payload definition ... */ }; // Wrap the SDK call in retry logic return retry(async (bail, attempt) => { // Note: Logging within the retry loop might be verbose. // Consider logging only on failure or final success. // The controller will log the overall attempt and final outcome. try { console.log(`[Attempt ${attempt}] Calling Infobip API for ${destinationNumber}`); // Use console or a passed-in logger const response = await infobipClient.channels.sms.send(payload); return response.data; // Return data on success } catch (error) { console.warn(`[Attempt ${attempt}] Error sending SMS via Infobip: ${error.message}`); // Decide if the error is retryable // Example: Don't retry on 4xx client errors (bad request, auth failed) // Check Infobip's specific error codes for retryable conditions (e.g., rate limits 429, server errors 5xx) if (error.response && error.response.status >= 400 && error.response.status < 500 && error.response.status !== 429) { console.error(`Non-retryable client error (${error.response.status}) from Infobip. Bailing out.`); bail(new Error(`Infobip client error (${error.response.status}): ${error.message}`)); // Stop retrying return; // Required after bail } // For potentially retryable errors (5xx, 429, network issues), throw to trigger retry console.warn(`Retryable error encountered (status: ${error.response?.status}). Retrying...`); throw error; } }, { retries: 3, // Number of retries factor: 2, // Exponential backoff factor minTimeout: 1000, // Initial delay ms maxTimeout: 5000, // Max delay ms onRetry: (error, attempt) => { console.warn(`Retrying Infobip request (Attempt ${attempt}) due to error: ${error.message}`); } }); }; module.exports = { sendSingleSms };console.loginside the service is shown for simplicity here. In a real application, you'd ideally pass therequest.loginstance (or a generic logger instance) into the service function if you need detailed logging within the service or retry loop itself.infobipClient.channels.sms.sendmethod in tests to throw specific errors (network errors, 4xx errors, 5xx errors, 429 errors) and verify the retry logic and error handling paths work as expected.6. Database Schema and Data Layer (Conceptual)
For a real marketing campaign system, you'd need a database. While this guide focuses on the sending mechanism, here's a conceptual overview:
messageId), opt-out information, etc.users:{ _id, phoneNumber, firstName, lastName, tags: [], subscribed: true, createdAt, updatedAt }campaigns:{ _id, name, description, targetAudience: { tags: [] }, messageTemplate: """", status: ""draft|active|archived"", createdAt, updatedAt }messages:{ _id, userId, campaignId, infobipMessageId, infobipBulkId, status: ""pending|accepted|sent|delivered|failed|rejected|undeliverable"", sentAt, statusUpdatedAt, cost }(Status values aligned with Infobip Delivery Reports)opt_outs:{ _id, phoneNumber, reason, optedOutAt }migrate-mongo(MongoDB) or the ORM's built-in migration tools (Prisma Migrate, Sequelize CLI) to manage schema changes over time.Note: Implementing the database layer is beyond the scope of this specific sending guide but crucial for a complete campaign system.
7. Security Features
Security is paramount, especially when handling user data and API keys.
Input Validation:
sendSmsSchema). Ensures only expected data types and formats are accepted. Crucial against injection attacks and basic data corruption.Secrets Management:
.envlocally, secure secret stores in production)..gitignoreprevents committing.env.Rate Limiting:
@fastify/rate-limit.npm install @fastify/rate-limitserver.js(before your routes):// In server.js, after fastify instantiation fastify.register(require('@fastify/rate-limit'), { max: 100, // Max requests per window per IP (default) timeWindow: '1 minute' // Consider more sophisticated keying (e.g., by API key if authenticated) });maxandtimeWindowbased on expected load and Infobip's own rate limits.Authentication/Authorization (If applicable):
X-API-Keyheader using a FastifypreHandlerhook or@fastify/auth.@fastify/jwt.@fastify/oauth2.Other Considerations:
@fastify/helmetto set various security-related HTTP headers.npm install @fastify/helmet, thenfastify.register(require('@fastify/helmet'))inserver.js.npm audit fix, use tools like Dependabot/Snyk) to patch known vulnerabilities.8. Handling Special Cases
Real-world SMS sending involves nuances:
fromfield): Regulations vary globally. Some countries require pre-registration (Alphanumeric Sender ID), others override it with a local number (e.g., shared short code, long code). Use the optionalsenderIdparameter insendSingleSmsand configure it correctly in Infobip. Ensure compliance with local laws (check Infobip docs for country specifics).messageIdfrom the initial send response). This is crucial for tracking campaign effectiveness and handling failures. Implementing DLR handling is a common next step after basic sending.@fastify/rate-limit) and potentially retry logic (Section 5) in your application to avoid exceeding these limits and handle 429 Too Many Requests errors gracefully.