This guide provides a step-by-step walkthrough for building a robust Node.js application using the Express framework to send SMS messages via the Infobip API, specifically tailored for marketing campaign scenarios.
We will build a backend service capable of accepting requests to send targeted SMS messages, integrating securely with Infobip, handling errors gracefully, and incorporating best practices for production readiness. This service acts as a foundational component for executing SMS marketing campaigns triggered by user actions, schedules, or other business logic.
Project Goals:
- Create a simple Express API endpoint to trigger SMS sends.
- Securely integrate with the Infobip SMS API using API keys.
- Implement robust input validation and error handling.
- Structure the application for clarity and maintainability.
- Provide clear guidance on prerequisites like Infobip Campaign Registration (especially for regions like the US).
- Incorporate production best practices like structured logging and API security.
Technology Stack:
- Node.js: A JavaScript runtime environment.
- Express: A minimal and flexible Node.js web application framework.
- Axios: A promise-based HTTP client for making requests to the Infobip API.
- dotenv: A module to load environment variables from a
.env
file for secure configuration. - Winston: A versatile logging library.
- Infobip API: The external service used for sending SMS messages.
System Architecture:
graph LR
A[Client/Trigger<br>(e.g., Frontend,<br>Scheduler, etc.)] --> B{Node.js/Express App<br>(API Endpoint)};
B --> C[Infobip API<br>(SMS Sending)];
subgraph B [Node.js/Express App]
direction TB
B1[Validate Request] --> B2[Build Infobip Payload];
B2 --> B3[Call Infobip API];
B3 --> B4[Handle Response/Error];
end
Prerequisites:
- Node.js and npm (or yarn): Installed on your development machine. (Download Node.js)
- Infobip Account: A registered account with Infobip. (Create Account)
- Infobip API Key and Base URL: Obtain these from your Infobip account dashboard.
- Registered Phone Number (for Free Trial): If using a free trial account, you can typically only send SMS to the phone number verified during signup.
- Crucially Important: Registered Infobip Campaign (Especially for US Traffic): For sending Application-to-Person (A2P) SMS in regions like the US using 10DLC (10-digit long codes), you must register a Brand and Campaign through Infobip (either via their UI or the Number Registration API). This is a mandatory step enforced by carriers. This guide focuses on sending messages under an existing, approved campaign. Sending without proper registration will lead to message failure and filtering. See Infobip's 10DLC Campaign Documentation for details on this critical registration process.
Final Outcome:
By the end of this guide, you will have a functional Express application with a secured API endpoint (/api/campaign/send-sms
) that accepts a destination phone number and message text, validates the input, sends the SMS via Infobip using a dedicated service, logs actions and errors, and returns a curated result. The application will be configured securely using environment variables.
1. Setting up the project
Let's initialize the Node.js project and install the necessary dependencies.
-
Create Project Directory: Open your terminal or command prompt and create a new directory for your project. Navigate into it.
mkdir infobip-sms-campaign-app cd infobip-sms-campaign-app
-
Initialize npm: Initialize the project using npm. You can accept the defaults or customize them.
npm init -y
This creates a
package.json
file. -
Install Dependencies: Install Express for the web server, Axios for HTTP requests, dotenv for environment variables, and Winston for logging.
npm install express axios dotenv winston
-
Create Project Structure: Set up a basic directory structure for better organization.
mkdir src mkdir src/routes mkdir src/services mkdir src/controllers mkdir src/config mkdir src/middleware touch src/server.js touch src/routes/campaignRoutes.js touch src/services/infobipService.js touch src/controllers/campaignController.js touch src/config/logger.js touch src/middleware/auth.js # For API key auth example touch .env touch .gitignore
src/
: Contains all the source code.src/routes/
: Holds Express route definitions.src/services/
: Contains logic for interacting with external services (like Infobip).src/controllers/
: Handles incoming requests and utilizes services.src/config/
: Holds configuration files, like the logger setup.src/middleware/
: Contains custom middleware functions (e.g., authentication).src/server.js
: The main entry point for the application..env
: Stores environment variables (API keys, etc.). Never commit this file to Git..gitignore
: Specifies files and directories that Git should ignore.
-
Configure
.gitignore
: Addnode_modules
and.env
to your.gitignore
file to prevent committing them.# .gitignore node_modules .env npm-debug.log *.log # Ignore log files
-
Set up Environment Variables: Open the
.env
file and add placeholders for your Infobip credentials, server port, and a potential internal API key for securing your own endpoint. You will replace the placeholder values later.# .env # Server Configuration PORT=3000 NODE_ENV=development # Or 'production' # Infobip API Credentials INFOBIP_API_KEY=YOUR_INFOBIP_API_KEY INFOBIP_BASE_URL=YOUR_INFOBIP_BASE_URL # Internal API Security (Example) # Generate a strong random key for production INTERNAL_API_KEY=YOUR_SECRET_API_KEY_FOR_INTERNAL_ACCESS
PORT
: The port your Express server will listen on.NODE_ENV
: Helps configure behavior (e.g., logging levels) based on the environment.INFOBIP_API_KEY
: Your API key obtained from Infobip.INFOBIP_BASE_URL
: Your unique base URL provided by Infobip (e.g.,xxxxx.api.infobip.com
).INTERNAL_API_KEY
: A secret key you define to protect your own API endpoint.
2. Implementing core functionality - The Infobip Service
We'll create a dedicated service to handle all interactions with the Infobip API. This promotes separation of concerns and makes the code easier to test and maintain.
-
Configure Logger (
src/config/logger.js
): Set up a reusable Winston logger.// src/config/logger.js const winston = require('winston'); const logger = winston.createLogger({ level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', // Log level based on environment format: winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.errors({ stack: true }), // Log stack traces winston.format.splat(), winston.format.json() // Log in JSON format ), defaultMeta: { service: 'infobip-sms-service' }, // Add service context transports: [ // In production, you might write to files or external services // new winston.transports.File({ filename: 'error.log', level: 'error' }), // new winston.transports.File({ filename: 'combined.log' }), ], }); // If we're not in production, log to the `console` with a simpler format. if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), winston.format.simple() ), })); } module.exports = logger;
-
Edit
src/services/infobipService.js
: This file will contain the logic for building the request and calling the Infobip ""Send SMS"" API endpoint (/sms/2/text/advanced
), now using the logger.// src/services/infobipService.js const axios = require('axios'); const logger = require('../config/logger'); // Use the configured logger // Retrieve Infobip credentials from environment variables const INFOBIP_API_KEY = process.env.INFOBIP_API_KEY; const INFOBIP_BASE_URL = process.env.INFOBIP_BASE_URL; /** * Builds the full API endpoint URL. * @returns {string} The complete URL for the Infobip Send SMS API. * @throws {Error} If INFOBIP_BASE_URL is not set. */ const buildUrl = () => { if (!INFOBIP_BASE_URL) { logger.error('INFOBIP_BASE_URL environment variable is not set.'); throw new Error('Infobip configuration error: Base URL is missing.'); } return `https://${INFOBIP_BASE_URL}/sms/2/text/advanced`; }; /** * Constructs the necessary HTTP headers for Infobip API authentication. * @returns {object} Headers object including Content-Type and Authorization. * @throws {Error} If INFOBIP_API_KEY is not set. */ const buildHeaders = () => { if (!INFOBIP_API_KEY) { logger.error('INFOBIP_API_KEY environment variable is not set.'); throw new Error('Infobip configuration error: API Key is missing.'); } return { 'Authorization': `App ${INFOBIP_API_KEY}`, 'Content-Type': 'application/json', 'Accept': 'application/json', }; }; /** * Constructs the request body payload for the Infobip Send SMS API. * @param {string} destinationNumber - The recipient's phone number in E.164 format (e.g., +447...). * @param {string} messageText - The content of the SMS message. * @param {string} [sender] - Optional: The sender ID (Alphanumeric or Short Code/Long Code). **Crucially, this MUST be a valid, registered Sender ID appropriate for the destination country and regulations (e.g., pre-registered 10DLC for US A2P). Using an invalid/unregistered sender will likely cause message failure. Defaulting to 'InfoSMS' is often NOT valid for many regions/use cases.** Verify requirements with Infobip documentation and local regulations. * @returns {object} The request body payload. * @throws {Error} If destinationNumber or messageText is missing. */ const buildRequestBody = (destinationNumber, messageText, sender) => { if (!destinationNumber || !messageText) { logger.warn('Attempted to build request body with missing destination or text.', { destinationNumber, messageText }); throw new Error('Destination number and message text are required.'); } const payload = { messages: [ { destinations: [{ to: destinationNumber }], text: messageText, // Only include 'from' if a valid sender is provided. // Relying on Infobip's default/configured sender might be safer if unsure. ...(sender && { from: sender }), // Add campaignId, notifyUrl etc. here if required by your campaign setup }, ], // tracking: { // Optional: Add tracking if needed for campaign analytics // track: 'URL', // type: 'MY_CAMPAIGN_TRACKER' // } }; logger.debug('Built Infobip request payload.', { payload }); // Log payload at debug level return payload; }; /** * Sends an SMS message using the Infobip API. * @param {string} destinationNumber - The recipient's phone number (E.164 format recommended). * @param {string} messageText - The message content. * @param {string} [sender] - Optional: The sender ID (see buildRequestBody docs for critical usage notes). * @returns {Promise<object>} A promise that resolves with the relevant part of the Infobip API response (e.g., message status and ID). * @throws {Error} If the API call fails or returns an error status. */ const sendSms = async (destinationNumber, messageText, sender) => { let url, headers, requestBody; try { url = buildUrl(); headers = buildHeaders(); requestBody = buildRequestBody(destinationNumber, messageText, sender); logger.info(`Attempting to send SMS to ${destinationNumber} via Infobip...`); const response = await axios.post(url, requestBody, { headers: headers }); logger.info('Infobip API Response Status:', { status: response.status }); // Log only essential parts of the response, not the whole potentially large object logger.debug('Infobip API Response Body:', { data: response.data }); // Basic check for success indication in the response structure const messageInfo = response.data?.messages?.[0]; if (messageInfo) { const status = messageInfo.status; if (status?.groupName === 'PENDING' || status?.groupName === 'DELIVERED') { logger.info(`SMS submitted successfully for ${destinationNumber}. Status: ${status.name}, Message ID: ${messageInfo.messageId}`); } else { logger.warn(`SMS submission for ${destinationNumber} resulted in status: ${status?.name || 'Unknown'} (${status?.description || 'N/A'}), Group: ${status?.groupName || 'Unknown'}`, { messageId: messageInfo.messageId }); } // Return curated info return { messageId: messageInfo.messageId, status: status, to: messageInfo.to }; } else { logger.warn(`Unexpected response structure received from Infobip for ${destinationNumber}.`, { responseData: response.data }); // Return raw data if structure is unknown, but flag it return { rawResponse: response.data, warning: ""Unexpected response structure"" }; } } catch (error) { logger.error('Error sending SMS via Infobip.', { message: error.message, url: url, // Log the URL attempted destination: destinationNumber, // Log relevant context stack: error.stack, // Include stack trace }); let errorMessage = 'Failed to send SMS due to an internal error.'; // Generic message if (error.response) { // The request was made and the server responded with a non-2xx status code logger.error('Infobip API Error Response:', { status: error.response.status, headers: error.response.headers, data: error.response.data, // Log the full error body from Infobip }); // Attempt to extract a meaningful error message from Infobip's response const serviceException = error.response.data?.requestError?.serviceException; if (serviceException) { errorMessage = `Infobip API Error: ${serviceException.text || 'Unknown error'} (ID: ${serviceException.messageId || 'N/A'})`; } else { errorMessage = `Infobip API request failed with status ${error.response.status}.`; } throw new Error(errorMessage); // Throw curated error message } else if (error.request) { // The request was made but no response was received logger.error('Infobip Error: No response received.', { request: error.request }); errorMessage = 'No response received from Infobip API. Check network connectivity and Base URL.'; throw new Error(errorMessage); } else { // Something happened in setting up the request that triggered an Error errorMessage = `Error setting up Infobip request: ${error.message}`; throw new Error(errorMessage); // Throw the setup error message } } }; module.exports = { sendSms, // Expose helpers for potential testing _buildUrl: buildUrl, _buildHeaders: buildHeaders, _buildRequestBody: buildRequestBody };
- Logging: Replaced
console.*
withlogger.*
calls using the Winston logger. Added more contextual information to logs. - Error Handling: Logs detailed technical errors server-side and throws a more user-friendly (but still informative) error message for the controller layer.
- Sender ID: Added stronger warnings about the
sender
parameter in the JSDoc and implementation notes. It's now optional and only included if explicitly provided, encouraging reliance on Infobip's configured defaults if unsure. - Response Handling: Returns a curated object with key information (
messageId
,status
,to
) instead of the full raw response on success.
- Logging: Replaced
3. Building the API layer
Now, let's create the Express controller and route that will use the infobipService
.
-
Edit
src/controllers/campaignController.js
: This controller handles the incoming HTTP request, performs validation, calls the service, and sends back a curated response.// src/controllers/campaignController.js const infobipService = require('../services/infobipService'); const logger = require('../config/logger'); // Basic E.164 format regex (simplified: starts with +, then digits) // For robust validation, consider libraries like 'libphonenumber-js' const E164_REGEX = /^\+[1-9]\d{1,14}$/; /** * Controller function to handle sending an SMS message. * POST /api/campaign/send-sms * Body: { "to": "+14155552671", "text": "messageContent", "sender": "OptionalSenderID" } */ const sendCampaignSms = async (req, res) => { const { to, text, sender } = req.body; // Include optional sender // Input Validation const errors = []; if (!to) { errors.push('Missing required field: `to` (phone number)'); } else if (!E164_REGEX.test(to)) { // Add more robust validation if needed (e.g., using libphonenumber-js) errors.push('Invalid phone number format for `to`. Use E.164 format (e.g., +14155552671).'); } if (!text) { errors.push('Missing required field: `text` (message content)'); } else if (text.length > 1600) { // Example: Arbitrary limit to prevent abuse errors.push('Message text exceeds maximum allowed length (1600 characters).'); } // Optional: Validate 'sender' format if provided if (errors.length > 0) { logger.warn('Invalid request to send SMS.', { errors, requestBody: req.body }); return res.status(400).json({ success: false, message: 'Invalid request payload.', errors: errors, }); } try { // Pass validated data to the service const result = await infobipService.sendSms(to, text, sender); // Pass sender if provided // Service logs details. Controller sends client-friendly, curated response. res.status(200).json({ success: true, message: 'SMS submitted successfully to Infobip.', // Return only necessary info, e.g., messageId for tracking details: { messageId: result?.messageId, statusGroup: result?.status?.groupName, recipient: result?.to, } }); } catch (error) { // The service layer already logged the detailed technical error. // Log the error context at the controller level. logger.error('Error processing send SMS request in controller.', { errorMessage: error.message, recipient: to, // Log context stack: error.stack // Log stack trace for controller-level issues }); // Send a generic server error response to the client. Avoid exposing internal details. res.status(500).json({ success: false, message: 'Failed to send SMS due to a server error. Please check server logs for details or contact support.', // Do NOT include error.message directly here in production // errorId: 'SOME_TRACKING_ID' // Optional: provide an ID for correlation }); } }; module.exports = { sendCampaignSms, };
- Validation: Added basic E.164 regex validation for the
to
field and a length check fortext
. Returns specific error messages using backticks for field names. - Curated Responses: Success response now includes only
messageId
,statusGroup
, andrecipient
from the service result. Error response is generic, advising to check logs, and explicitly does not include the rawerror.message
. - Logging: Logs validation failures and errors occurring within the controller itself.
- Validation: Added basic E.164 regex validation for the
-
Edit
src/routes/campaignRoutes.js
: Define the Express route and link it to the controller function.// src/routes/campaignRoutes.js const express = require('express'); const campaignController = require('../controllers/campaignController'); // Optional: Import authentication middleware // const { requireApiKey } = require('../middleware/auth'); const router = express.Router(); // Define the route for sending SMS // POST /api/campaign/send-sms // Consider adding authentication middleware here: // router.post('/send-sms', requireApiKey, campaignController.sendCampaignSms); router.post('/send-sms', campaignController.sendCampaignSms); // You could add more campaign-related routes here (e.g., getting campaign status) module.exports = router;
-
Edit
src/server.js
: Set up the main Express application, load environment variables, configure middleware (including logging), and mount the routes.// src/server.js // Load environment variables from .env file FIRST require('dotenv').config(); const express = require('express'); const helmet = require('helmet'); // For security headers const campaignRoutes = require('./routes/campaignRoutes'); const logger = require('./config/logger'); // Import the logger // Create the Express application const app = express(); // Security Middleware app.use(helmet()); // Set various security HTTP headers app.use(express.json()); // Middleware to parse JSON request bodies app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies // HTTP Request Logging Middleware (using Winston) app.use((req, res, next) => { // Log basic request info logger.info(`Incoming Request: ${req.method} ${req.path}`, { ip: req.ip, userAgent: req.get('User-Agent'), body: req.body // Be cautious logging full bodies in production (PII/secrets) }); // Log response finish res.on('finish', () => { logger.info(`Request Finished: ${req.method} ${req.path} - Status: ${res.statusCode}`, { duration: `${Date.now() - res.locals.startTime}ms` // Requires setting startTime earlier }); }); res.locals.startTime = Date.now(); // Store start time for duration calculation next(); }); // Mount the campaign routes under the /api/campaign prefix // Make sure to apply any necessary authentication middleware here or in the router itself app.use('/api/campaign', campaignRoutes); // Simple health check endpoint app.get('/health', (req, res) => { res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() }); }); // 404 Handler for undefined routes app.use((req, res, next) => { res.status(404).json({ message: 'Resource not found at ' + req.originalUrl }); }); // Centralized Error Handler Middleware (Logs errors using Winston, sends generic response) // This catches errors passed via next(err) or uncaught synchronous errors in route handlers // Note: Asynchronous errors need to be caught and passed to next() or handled in controllers/services app.use((err, req, res, next) => { // Log the error using Winston logger.error('Unhandled error caught by central handler:', { message: err.message, stack: err.stack, url: req.originalUrl, method: req.method, ip: req.ip, }); // Avoid sending stack traces or detailed errors to the client in production const statusCode = err.status || 500; // Use error status or default to 500 const responseMessage = process.env.NODE_ENV === 'production' ? 'Internal Server Error' : err.message; // Show more detail in dev res.status(statusCode).json({ message: 'An unexpected error occurred.', error: responseMessage // Provide minimal info }); }); // Start the server const PORT = process.env.PORT || 3000; // Default to 3000 if PORT not set app.listen(PORT, () => { logger.info(`Server listening on port ${PORT}`); logger.info(`Current environment: ${process.env.NODE_ENV || 'development'}`); // Verify essential environment variables are loaded if (!process.env.INFOBIP_API_KEY || !process.env.INFOBIP_BASE_URL) { logger.warn('Infobip API Key or Base URL not found in environment variables. API calls will fail.'); } if (!process.env.INTERNAL_API_KEY && process.env.NODE_ENV === 'production') { logger.warn('INTERNAL_API_KEY is not set. The API endpoint security is compromised in production!'); } }); // Handle unhandled promise rejections process.on('unhandledRejection', (reason, promise) => { logger.error('Unhandled Rejection at:', { promise, reason: reason.stack || reason }); // Optionally exit process: process.exit(1); }); // Handle uncaught exceptions process.on('uncaughtException', (error) => { logger.error('Uncaught Exception:', { message: error.message, stack: error.stack }); // Graceful shutdown logic might go here process.exit(1); // Mandatory exit after uncaught exception });
dotenv.config()
: Still first.- Logging: Integrated Winston for request logging and centralized error handling.
- Security: Added
helmet
middleware. - Error Handling: Enhanced the final error handler to use Winston and provide less detail in production. Added global handlers for
unhandledRejection
anduncaughtException
. - Startup Checks: Added warnings if essential keys (Infobip or internal API key) are missing.
-
Update
package.json
(Optional but Recommended): Add scripts to easily start the server.// package.json (add inside "scripts") "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", // If you install nodemon: npm install --save-dev nodemon "lint": "eslint .", // Example if using ESLint "test": "echo \"Error: no test specified\" && exit 1" // Keep or replace },
4. Integrating with Infobip (Configuration Details)
This section details how to get your Infobip credentials and configure the application.
-
Obtain Infobip API Key and Base URL:
- Log in to your Infobip account portal.
- Navigate to the Developers section or API Keys management area.
- Base URL: Find your unique API Base URL (e.g.,
[your-unique-id].api.infobip.com
). Copy this value. - API Key: Generate a new API key. Give it a descriptive name (e.g.,
nodejs-campaign-app
). Copy the generated key value immediately. - Security Note: Treat your API key like a password. Use environment variables; do not commit it to version control.
-
Update
.env
File: Replace the placeholder values in your.env
file with the actual Base URL, API Key, and a secure internal API key.# .env (Example values - Replace with your actual credentials!) PORT=3000 NODE_ENV=development # Infobip API Credentials INFOBIP_API_KEY=abcdef1234567890abcdef1234567890-abcdef12-abcd-1234-abcd-abcdef123456 INFOBIP_BASE_URL=z9x8y7.api.infobip.com # Internal API Security (Generate a strong random string for this) INTERNAL_API_KEY=Str0ngS3cr3tK3yF0rYourAPI!
-
Environment Variable Purpose:
PORT
: Network port the Express server listens on.NODE_ENV
: Set toproduction
in deployed environments.INFOBIP_API_KEY
: Authenticates requests to the Infobip API (Authorization: App <key>
).INFOBIP_BASE_URL
: Correct domain for Infobip API endpoints (e.g.,yoursubdomain.api.infobip.com
). Does not includehttps://
.INTERNAL_API_KEY
: Used to secure your application's API endpoint (see Section 7 - Note: Section 7 is not present in the provided text).
-
Campaign Registration (Crucial Reminder):
- As highlighted in the prerequisites, simply having an API key is often insufficient, especially for A2P 10DLC in the US. You must have a registered and approved campaign linked to your sender number(s).
- Use the Infobip portal or the Number Registration API to register your Brand and Campaign. This involves detailing your use case, providing message samples, and explaining opt-in procedures.
- Allow time for Infobip Compliance review and approval.
- Only after approval can you reliably send messages using the associated sender ID/number. You might need to include a
campaignId
in the API request body (src/services/infobipService.js
). Verify the requirements for your specific approved campaign. Failure to comply is a primary reason for message delivery issues.
5. Implementing error handling, logging, and retry mechanisms
Our implementation now includes structured logging with Winston and improved error handling. Let's discuss retries.
- Consistent Error Strategy: Errors are caught at the lowest level possible (e.g.,
infobipService
). Detailed technical info is logged there using Winston. A curated error is thrown upwards. The controller catches this, logs the context, and sends a generic, safe error response to the client. Centralized handlers catch uncaught exceptions/rejections. - Logging:
- We now use Winston configured in
src/config/logger.js
. It provides structured JSON logging, different levels based onNODE_ENV
, and timestamping. - Logs include context like service name, request details, and error stacks.
- In production, configure transports to write logs to files or stream them to external aggregation services (ELK, Datadog, Splunk).
- We now use Winston configured in
- Retry Mechanisms:
- Network glitches or temporary Infobip issues (like
503 Service Unavailable
) might cause transient failures. Implementing retries can improve reliability for these cases. - Use libraries like
async-retry
oraxios-retry
.axios-retry
integrates directly with Axios, whileasync-retry
is a more general-purpose promise retry library. - Caution: Only retry on specific, recoverable error types (network errors, 5xx server errors). Do not retry on clien
- Network glitches or temporary Infobip issues (like