This guide provides a complete walkthrough for building a Node.js application using the Express framework to send SMS messages via the Infobip API and handle real-time delivery status updates through webhooks (callbacks).
By the end of this tutorial, you will have a functional application capable of:
Sending SMS messages programmatically using Infobip.
Receiving delivery reports (DLRs) from Infobip to track message status (e.g., delivered, failed, rejected).
Processing these DLRs within your Express application.
This enables developers to build more robust messaging workflows, providing visibility into message delivery success and enabling actions based on status updates — such as retrying failed messages or logging delivery confirmations.
Target Audience: Developers familiar with Node.js, Express, and basic API concepts.
System Architecture
The system involves the following components:
User/Client: Initiates the request to send an SMS (e.g., via a web form or another service).
Node.js/Express Application:
Exposes an API endpoint to receive SMS sending requests.
Calls the Infobip API to send the SMS, including a notifyUrl for callbacks.
Exposes a webhook endpoint (/infobip-dlr) to receive delivery reports from Infobip.
Processes incoming delivery reports.
Infobip Platform:
Receives the SMS send request from the Node.js application.
Attempts to deliver the SMS to the recipient's handset via mobile networks.
Sends a POST request containing the delivery status to the notifyUrl provided by the application.
sequenceDiagram
participant User/Client
participant Node.js/Express App
participant Infobip API
participant Mobile Network/Handset
User/Client->>+Node.js/Express App: POST /send-sms (to, message)
Node.js/Express App->>+Infobip API: Send SMS Request (to, message, notifyUrl='/infobip-dlr')
Infobip API-->>-Node.js/Express App: Acknowledge Request (messageId)
Node.js/Express App-->>-User/Client: Success/Failure Sending Initiated
Infobip API->>+Mobile Network/Handset: Deliver SMS
Mobile Network/Handset-->>-Infobip API: Delivery Status Update
Infobip API->>+Node.js/Express App: POST /infobip-dlr (Delivery Report Payload)
Node.js/Express App-->>-Infobip API: 200 OK (Acknowledge Receipt)
Note over Node.js/Express App: Process DLR (Log, Update DB, etc.)
Prerequisites
Node.js and npm (or yarn): Ensure you have a recent version installed. (Node.js Downloads)
Infobip Account: You need an active Infobip account. A free trial account works, but remember it typically restricts sending SMS only to the phone number used during registration. (Infobip Signup)
Infobip API Key and Base URL: Obtain these from your Infobip account dashboard.
Publicly Accessible URL: For Infobip to send delivery reports back to your application, your callback endpoint must be reachable from the public internet. During development, tools like ngrok are essential.
Basic understanding: Familiarity with JavaScript, Node.js, Express, REST APIs, and environment variables.
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
1.1 Create Project Directory
Open your terminal or command prompt and create a new directory for the project.
Bash
mkdir infobip-dlr-app
cd infobip-dlr-app
1.2 Initialize Node.js Project
Initialize the project using npm (or yarn). This creates a package.json file.
Bash
npm init -y
Why -y? This flag accepts the default settings during initialization, speeding up the process. You can omit it to customize project details.
1.3 Install Dependencies
We need express for our web server, axios to make HTTP requests to the Infobip API, and dotenv to manage environment variables securely.
Bash
npm install express axios dotenv
express: A minimal and flexible Node.js web application framework.
axios: A popular promise-based HTTP client for making requests to external APIs like Infobip's.
dotenv: Loads environment variables from a .env file into process.env, keeping sensitive information like API keys out of your source code.
1.4 Create Project Structure
Create the basic files and folders:
Bash
# For Linux/macOStouch index.js .env .gitignore infobipService.js
# For Windows (Command Prompt)echo. > index.js
echo. > .envecho. > .gitignore
echo. > infobipService.js
# For Windows (PowerShell)
New-Item index.js -ItemType File
New-Item .env -ItemType File
New-Item .gitignore -ItemType File
New-Item infobipService.js -ItemType File
Add node_modules and .env to your .gitignore file. This prevents committing dependencies and sensitive credentials to version control.
Text
# .gitignore
node_modules
.env
Why ignore .env? It contains sensitive API keys and credentials. Never commit this file to public or shared repositories. Team members should create their own .env files based on a template or secure sharing mechanism.
1.6 Set up Environment Variables (.env)
Open the .env file and add placeholders for your Infobip credentials and application settings. You will replace these placeholders later.
.env
# .env
# Infobip Credentials
INFOBIP_API_KEY=YOUR_INFOBIP_API_KEY_HERE
INFOBIP_BASE_URL=YOUR_INFOBIP_BASE_URL_DOMAIN_HERE # e.g., xyz.api.infobip.com
# Application Settings
PORT=3000
APP_BASE_URL=http://localhost:3000 # Replace with your public URL (e.g., ngrok URL) during testing
INFOBIP_API_KEY: Your secret key for authenticating API requests.
INFOBIP_BASE_URL: The specific domain assigned to your Infobip account for API access.
PORT: The port your Express application will listen on.
APP_BASE_URL: The base URL where your application is accessible from the internet. Infobip needs this to send callbacks. For local development, this will be your ngrok tunnel URL.
2. Implementing Core Functionality: Sending SMS
Let's create a service to interact with the Infobip API for sending SMS messages.
2.1 Create Infobip Service File
This step was included in the project structure setup (1.4). You should have an infobipService.js file.
2.2 Implement sendSms Function
Add the following code to infobipService.js. This code adapts the logic from the Infobip developer blog post, adding the crucial notifyUrl parameter.
JavaScript
// infobipService.jsconst axios = require('axios');
// Load environment variables (ensure dotenv is configured in index.js first)const apiKey = process.env.INFOBIP_API_KEY;
const baseUrlDomain = process.env.INFOBIP_BASE_URL;
const appBaseUrl = process.env.APP_BASE_URL; // Base URL for callbacks/**
* Constructs the full API endpoint URL for sending SMS.
* @param {string} domain - The Infobip base URL domain.
* @returns {string} The full API URL.
*/constbuildUrl = (domain) => {
if (!domain) {
thrownewError('INFOBIP_BASE_URL is not defined in environment variables.');
}
// Using the recommended Advanced SMS endpointreturn`https://${domain}/sms/2/text/advanced`;
};
/**
* Constructs the necessary HTTP headers for Infobip API authentication.
* @param {string} key - The Infobip API Key.
* @returns {object} Headers object.
*/constbuildHeaders = (key) => {
if (!key) {
thrownewError('INFOBIP_API_KEY is not defined in environment variables.');
}
return {
'Authorization': `App ${key}`,
'Content-Type': 'application/json',
'Accept': 'application/json', // Explicitly accept JSON responses
};
};
/**
* Constructs the request body for the Infobip Send SMS API.
* Includes the destination number, message text, and the callback URL.
* @param {string} destinationNumber - The recipient's phone number in international format (e.g., 447... ).
* @param {string} message - The text content of the SMS.
* @param {string} callbackUrl - The publicly accessible URL for delivery reports.
* @returns {object} Request body object.
*/constbuildRequestBody = (destinationNumber, message, callbackUrl) => {
const destinationObject = {
to: destinationNumber,
// messageId: 'OPTIONAL-CUSTOM-MESSAGE-ID' // Optional: Add a custom ID if needed
};
const messageObject = {
destinations: [destinationObject],
text: message,
// from: 'YourSenderID', // Optional: Specify a custom sender ID if configured/allowednotifyUrl: callbackUrl, // ** Crucial for receiving delivery reports **notifyContentType: 'application/json', // Specify format for DLRs// See Infobip docs for more options: validityPeriod, sendAt, etc.
};
return {
messages: [messageObject],
// bulkId: 'OPTIONAL-CUSTOM-BULK-ID' // Optional: Group messages
};
};
/**
* Sends an SMS message using the Infobip API.
* @param {string} destinationNumber - Recipient's phone number (international format).
* @param {string} message - SMS text content.
* @returns {Promise<object>} A promise that resolves with the Infobip API response or rejects with an error.
*/constsendSms = async (destinationNumber, message) => {
if (!destinationNumber || !message) {
thrownewError('Destination number and message text are required.');
}
if (!appBaseUrl) {
thrownewError('APP_BASE_URL is not defined. Needed for callback URL.');
}
const apiUrl = buildUrl(baseUrlDomain);
const headers = buildHeaders(apiKey);
// Construct the full callback URLconst callbackUrl = `${appBaseUrl}/infobip-dlr`;
const requestBody = buildRequestBody(destinationNumber, message, callbackUrl);
console.log(`Sending SMS to ${destinationNumber} via ${apiUrl}`);
console.log(`Callback URL set to: ${callbackUrl}`);
try {
const response = await axios.post(apiUrl, requestBody, { headers });
console.log('Infobip API Response:', JSON.stringify(response.data, null, 2));
// The response here confirms Infobip accepted the request, not final delivery.// It includes a messageId useful for tracking.return response.data;
} catch (error) {
console.error('Error sending SMS via Infobip:');
if (error.response) {
// The request was made and the server responded with a status code// that falls out of the range of 2xxconsole.error('Status:', error.response.status);
console.error('Headers:', JSON.stringify(error.response.headers, null, 2));
console.error('Data:', JSON.stringify(error.response.data, null, 2));
// Rethrow a more specific error or handle based on status codethrownewError(`Infobip API Error: ${error.response.status} - ${JSON.stringify(error.response.data)}`);
} elseif (error.request) {
// The request was made but no response was receivedconsole.error('Request Error:', error.request);
thrownewError('Error sending SMS: No response received from Infobip.');
} else {
// Something happened in setting up the request that triggered an Errorconsole.error('Axios Error:', error.message);
thrownewError(`Error sending SMS: ${error.message}`);
}
}
};
module.exports = {
sendSms,
};
Why async/await? It simplifies working with promises returned by axios, making the code cleaner and easier to read than using .then() and .catch() chains directly for complex flows.
Why notifyUrl and notifyContentType? These parameters instruct Infobip where and how (JSON format) to send the delivery report once the final status of the message is known. This is the core mechanism for enabling callbacks.
Why APP_BASE_URL? The callback URL must be absolute and publicly accessible. We construct it using the base URL defined in our environment variables.
3. Building the API Layer and Handling Callbacks
Now, let's set up our Express server, create an endpoint to trigger SMS sending, and critically, an endpoint to receive the delivery reports from Infobip.
3.1 Basic Express Server Setup
Modify your index.js file:
JavaScript
// index.js// Load environment variables from .env filerequire('dotenv').config();
const express = require('express');
const infobipService = require('./infobipService'); // Import our serviceconst app = express();
const port = process.env.PORT || 3000; // Use port from .env or default to 3000// Middleware to parse JSON request bodies// Crucial for both our API endpoint and receiving Infobip's JSON callbacks
app.use(express.json());
// Simple root route for health check or basic info
app.get('/', (req, res) => {
res.send('Infobip DLR Handler App is running!');
});
// --- API Endpoint to Send SMS ---
app.post('/send-sms', async (req, res) => {
const { to, text } = req.body; // Expect 'to' (number) and 'text' (message) in JSON body// Basic Input Validationif (!to || typeof to !== 'string' || to.trim() === '') {
return res.status(400).json({ error: 'Missing or invalid "to" phone number in request body.' });
}
if (!text || typeof text !== 'string' || text.trim() === '') {
return res.status(400).json({ error: 'Missing or invalid "text" message in request body.' });
}
try {
console.log(`Received request to send SMS to: ${to}`);
const result = await infobipService.sendSms(to.trim(), text.trim());
// Send back the initial response from Infobip (acknowledgment, messageId)
res.status(202).json({ // 202 Accepted: Request taken, processing underwaymessage: 'SMS sending initiated successfully.',
infobipResponse: result,
});
} catch (error) {
console.error('Error in /send-sms endpoint:', error.message);
// Avoid sending detailed internal errors back to the client in production
res.status(500).json({ error: 'Failed to initiate SMS sending.' });
}
});
// --- Webhook Endpoint to Receive Infobip Delivery Reports ---
app.post('/infobip-dlr', (req, res) => {
console.log('--- Received Infobip Delivery Report ---');
console.log('Timestamp:', newDate().toISOString());
console.log('Headers:', JSON.stringify(req.headers, null, 2)); // Log headers for debugging if neededconsole.log('Body:', JSON.stringify(req.body, null, 2)); // Log the full DLR payload// --- Processing Logic ---// The actual payload structure can vary slightly based on Infobip product/settings.// Inspect the logged 'Body' from a real callback to confirm structure.// Common structure includes a 'results' array.if (req.body && Array.isArray(req.body.results)) {
req.body.results.forEach(report => {
const messageId = report.messageId;
const status = report.status ? report.status.name : 'UNKNOWN';
const groupName = report.status ? report.status.groupName : 'UNKNOWN';
const description = report.status ? report.status.description : 'No description';
const recipient = report.to;
const errorCode = report.error ? report.error.id : null;
const errorName = report.error ? report.error.name : null;
const errorDescription = report.error ? report.error.description : null;
console.log(`\n--- Processing DLR for Message ID: ${messageId} ---`);
console.log(` To: ${recipient}`);
console.log(` Status Group: ${groupName}`); // e.g., PENDING, UNDELIVERABLE, DELIVERED, EXPIRED, REJECTEDconsole.log(` Status: ${status}`); // e.g., PENDING_ACCEPTED, DELIVERED_TO_HANDSET, UNDELIVERABLE_NOT_DELIVEREDconsole.log(` Description: ${description}`);
if (errorCode) {
console.error(` Error Code: ${errorCode} (${errorName})`);
console.error(` Error Description: ${errorDescription}`);
}
// TODO: Add your business logic here:// 1. Find the original message record in your database using messageId.// 2. Update the message status based on 'groupName' or 'status'.// 3. If status is 'UNDELIVERABLE' or 'REJECTED', trigger alerts or retry logic.// 4. Log detailed information for auditing.
});
} else {
console.warn('Received DLR in unexpected format:', JSON.stringify(req.body));
}
// ** Crucial: Respond to Infobip quickly with a 2xx status code **// Failing to respond or responding with an error might cause Infobip to retry sending the DLR.
res.sendStatus(200); // Send 'OK' back to Infobip to acknowledge receipt.
});
// Start the server
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
console.log(`Ensure INFOBIP_API_KEY and INFOBIP_BASE_URL are set.`);
console.log(`Callback endpoint configured internally as /infobip-dlr`);
console.log(`Ensure APP_BASE_URL is set correctly in .env for callbacks`);
if (process.env.NODE_ENV !== 'production') {
console.warn(`--- Development Mode ---`);
console.warn(`Use a tool like ngrok to expose port ${port} publicly.`);
console.warn(`Update APP_BASE_URL in .env with your ngrok URL (e.g., https://xxxxx.ngrok.io)`);
console.warn(`Example ngrok command: ngrok http ${port}`);
}
});
Why require('dotenv').config() first? This line must execute before any code that accesses process.env variables (like our infobipService.js) to ensure those variables are loaded from the .env file.
Why express.json() middleware? Infobip sends delivery reports as JSON payloads in the POST request body. This middleware parses that JSON and makes it available as req.body. It's also needed for our /send-sms endpoint to parse the incoming JSON request.
Why /send-sms endpoint? This provides a clean interface for other parts of your system (or external clients) to request an SMS without needing direct access to the Infobip service logic.
Why /infobip-dlr endpoint? This is the publicly accessible listener that Infobip will call. Its sole purpose is to receive, acknowledge, and process incoming delivery status updates.
Why res.sendStatus(200) in /infobip-dlr? You must respond to Infobip's webhook request with a success status (2xx) quickly. This acknowledges receipt. If you respond with an error (4xx, 5xx) or time out, Infobip will likely retry sending the report according to their retry schedule, potentially leading to duplicate processing if not handled carefully. Do complex processing asynchronously if needed.
Why parse req.body.results? Based on Infobip documentation and common webhook patterns, delivery reports often come batched within a results array in the JSON payload. Always inspect the actual payload you receive during testing to confirm the structure.
4. Integrating with Infobip: Configuration
Let's get the necessary credentials from Infobip and configure our .env file.
Navigate to the API Keys management section. This is typically found under your user profile, account settings, or a dedicated ""Developers"" section. The exact location might change, explore the portal if needed.
Example Path (might vary): Click your username/icon -> Account Settings -> API Keys. Or look for Developers -> API Keys.
Create a new API Key if you don't have one already. Give it a descriptive name (e.g., Node DLR App Key).
Copy the API Key immediately and store it securely. You often only see the full key upon creation.
Find your Base URL (API Domain). This is usually displayed prominently in the API section or on the main dashboard after logging in. It looks like xxxxxx.api.infobip.com.
Example Location (might vary): Often shown on the API Keys page or a general API information page.
4.2 Update .env File
Open your .env file and replace the placeholders with the actual values you obtained:
.env
# .env
# Infobip Credentials
INFOBIP_API_KEY=YOUR_ACTUAL_INFOBIP_API_KEY
INFOBIP_BASE_URL=YOUR_ACTUAL_INFOBIP_BASE_URL_DOMAIN # e.g., abc12.api.infobip.com
# Application Settings
PORT=3000
APP_BASE_URL=http://localhost:3000 # <-- IMPORTANT: Update this when using ngrok
4.3 Set Up ngrok for Local Development
Infobip needs to reach your /infobip-dlr endpoint over the public internet. ngrok creates a secure tunnel from a public URL to your local machine.
Authenticate ngrok if you have an account (optional but recommended for longer sessions).
In your terminal (in a separate window from your running Node app), start ngrok, pointing it to the port your Express app is running on (defined by PORT in .env, default 3000).
Copy the https Forwarding URL (e.g., https://<random-string>.ngrok.io). This is your public URL.
Update APP_BASE_URL in your .env file with this https ngrok URL.
.env
# .env (Example Update)
# ... other variables ...
APP_BASE_URL=https://b7a9-123-45-67-89.ngrok.io # Use YOUR ngrok HTTPS URL
Restart your Node.js application (node index.js or using nodemon) after updating .env so it picks up the new APP_BASE_URL.
Why ngrok? It makes your locally running server accessible from the internet without complex network configuration, essential for receiving webhooks during development.
Why HTTPS ngrok URL? Always use HTTPS for security, especially when dealing with potentially sensitive callback data.
5. Error Handling and Logging
Our current code includes basic console logging and error handling. Let's refine it slightly.
Infobip Service (infobipService.js): The sendSms function already includes a try...catch block that logs detailed errors from axios, distinguishing between response errors, request errors, and setup errors. It re-throws a generic error to the caller.
API Endpoint (/send-sms in index.js): The endpoint wraps the service call in try...catch. It logs the error server-side and returns a generic 500 error to the client to avoid leaking internal details. Basic 400 Bad Request errors are returned for invalid input.
Callback Endpoint (/infobip-dlr in index.js): This endpoint logs the entire incoming payload. It includes a console.warn for unexpected formats and logs specific details for recognized formats. Crucially, it always responds with 200 OK to prevent Infobip retries, even if internal processing fails. Robust error handling here might involve:
Wrapping the processing logic in a try...catch.
Logging any processing errors internally (e.g., database update failure).
Still sending res.sendStatus(200) back to Infobip.
Implementing a separate mechanism (like a dead-letter queue or retry queue) to handle DLRs that failed processing.
Further Improvements (Beyond this Guide):
Structured Logging: Use a library like winston or pino for structured JSON logging, making logs easier to parse and analyze, especially in production.
Error Tracking Services: Integrate with services like Sentry or Datadog to capture, aggregate, and alert on application errors.
Retry Mechanisms: For sending SMS, if the initial request to Infobip fails due to network issues or temporary Infobip problems (e.g., 5xx errors), implement a retry strategy with exponential backoff using libraries like axios-retry or async-retry. Do not retry based on DLRs indicating final failure (like UNDELIVERABLE).
6. Database Schema and Data Layer (Conceptual)
While this guide doesn't implement a database, here's how you would typically integrate one:
6.1 Conceptual Schema
You'd likely need a table to store information about outgoing messages:
SQL
CREATE TABLE outgoing_messages (
id SERIAL PRIMARY KEY, -- Or UUID
infobip_message_id VARCHAR(255) UNIQUE, -- Store the ID from Infobip's initial response
recipient_number VARCHAR(20) NOT NULL,
message_text TEXT NOT NULL,
status VARCHAR(50) DEFAULT'PENDING_INFOBIP', -- Initial status before Infobip confirms acceptance
infobip_status_group VARCHAR(50), -- e.g., PENDING, DELIVERED, UNDELIVERABLE
infobip_status_name VARCHAR(100), -- Specific status e.g., PENDING_ACCEPTED
infobip_status_description TEXT,
error_code INTEGER,
error_name VARCHAR(100),
error_description TEXT,
created_at TIMESTAMPTZ DEFAULTCURRENT_TIMESTAMP,
last_updated_at TIMESTAMPTZ DEFAULTCURRENT_TIMESTAMP,
callback_received_at TIMESTAMPTZ -- Timestamp when the DLR was processed
);
-- Index for faster lookups when processing callbacksCREATE INDEX idx_infobip_message_id ON outgoing_messages(infobip_message_id);
6.2 Data Layer Integration
On Sending (/send-sms):
Before calling infobipService.sendSms, insert a new record into outgoing_messages with status PENDING_INFOBIP.
After a successful call to infobipService.sendSms, update the record using the messageId from the Infobip response, setting the infobip_message_id field and potentially updating status to PENDING_ACCEPTED (or similar based on the initial response).
On Receiving Callback (/infobip-dlr):
Inside the loop processing req.body.results:
Extract the messageId from the report.
Find the corresponding record in outgoing_messages using infobip_message_id.
If found, update the infobip_status_group, infobip_status_name, infobip_status_description, error fields, callback_received_at, and last_updated_at based on the DLR content.
Handle cases where the messageId is not found (log an error/warning).
Tools: Use an ORM like Sequelize (for SQL databases) or Mongoose (for MongoDB) to manage database interactions more easily. Use migration tools like sequelize-cli or knex migrations to manage schema changes.
7. Security Features
Security is paramount, especially when handling API keys and potentially sensitive message data.
Environment Variables: We are already using .env to keep INFOBIP_API_KEY out of the code and .gitignore to prevent committing it. This is crucial.
Input Validation (/send-sms): Basic validation is implemented to check for the presence and type of to and text. Enhance this based on expected phone number formats or message length constraints. Use libraries like joi or express-validator for more robust validation schemas.
Webhook Security (/infobip-dlr):
HTTPS: Using ngrok with HTTPS and deploying to a server with HTTPS configured is essential to encrypt data in transit.
Secret Validation (Optional/Advanced): Check if Infobip supports sending a secret signature in a header (e.g., X-Infobip-Signature) along with the webhook. If so, you would:
Configure a secret string in your Infobip webhook settings (if available).
Store the same secret in your application's environment variables.
On receiving a webhook, calculate the expected signature based on the request body and your secret (using HMAC-SHA1 or similar, as specified by Infobip).
Compare the calculated signature with the one provided in the header. Reject requests if they don't match. Note: Simple signature verification doesn't seem to be a standard, easily configurable feature for basic Infobip SMS DLRs via notifyUrl as of common knowledge, but check their latest documentation.
IP Whitelisting (If feasible): If Infobip publishes a list of IP addresses from which they send webhooks, you could configure your firewall or application middleware to only accept requests to /infobip-dlr from those specific IPs. This can be difficult to maintain if IPs change.
Rate Limiting: Protect your /send-sms endpoint from abuse by implementing rate limiting using middleware like express-rate-limit.
Helmet: Use the helmet middleware for Express to set various security-related HTTP headers (e.g., Content-Security-Policy, X-Content-Type-Options). npm install helmet and app.use(helmet());.
8. Handling Special Cases
Phone Number Formatting: The Infobip API generally expects numbers in international format (e.g., 447123456789 for the UK, 14155552671 for the US). Ensure your input validation or normalization logic enforces this format before sending to the API.
Character Encoding & Concatenation: Standard SMS messages have length limits (160 GSM-7 characters, 70 UCS-2 characters for non-standard alphabets). Longer messages are often automatically split (concatenated) by carriers and Infobip. Be aware that concatenated messages consume more credits. Infobip's API handles much of this, but be mindful of text length if cost is critical.
DLR Latency: Delivery reports are not always instantaneous. There can be delays depending on carrier networks. Your application should handle the possibility that a DLR arrives seconds, minutes, or sometimes even longer after the message was sent. The PENDING status indicates the message is still in transit.
Duplicate DLRs: While Infobip aims to deliver reports reliably, network issues or slow responses from your endpoint (timeouts before you send 200 OK) could lead to Infobip retrying the webhook delivery. Design your processing logic (/infobip-dlr) to be idempotent — meaning processing the same DLR multiple times has no adverse effects. Using the infobip_message_id as a unique key in your database helps achieve this (e.g., update status only if the new status is different or more final than the existing one).
Time Zones: Use TIMESTAMPTZ (Timestamp with Time Zone) in your database schemas (created_at, last_updated_at, callback_received_at) to store timestamps unambiguously. Log timestamps using toISOString() for clarity.
9. Performance Optimizations (Conceptual)
For high-volume applications:
Asynchronous Processing: If DLR processing involves complex logic or database updates, consider moving it out of the main /infobip-dlr request handler. Immediately send the 200 OK response, and then push the DLR payload onto a message queue (like RabbitMQ, Redis Streams, or AWS SQS) for background workers to process. This prevents holding up Infobip's request and avoids timeouts.
Database Indexing: As shown in the conceptual schema, index columns frequently used in WHERE clauses (especially infobip_message_id).
Connection Pooling: Ensure your database client (e.g., Sequelize, node-postgres) uses connection pooling to reuse database connections efficiently.
Caching: If you frequently query message statuses, consider caching recent statuses (e.g., in Redis) to reduce database load, especially for non-final states. Invalidate the cache when a final DLR (Delivered, Undeliverable, Rejected) is received.
Load Testing: Use tools like k6, artillery, or JMeter to simulate traffic to your /send-sms endpoint and (if possible) your /infobip-dlr endpoint to identify bottlenecks under load.
10. Monitoring, Observability, and Analytics (Conceptual)
Health Checks: Keep the simple app.get('/') endpoint or create a dedicated /health endpoint that checks basic connectivity (e.g., can reach the database). Monitoring services can ping this endpoint.
Frequently asked questions
Handle Infobip SMS delivery reports by setting up a webhook endpoint in your Node.js/Express application. This endpoint, specified by the notifyUrl parameter in your Infobip API request, receives real-time delivery updates in JSON format. Your application should then process this data, updating internal systems and triggering actions based on the delivery status (e.g., delivered, failed).
The notify URL is a crucial parameter in the Infobip SMS API. It's the publicly accessible URL of your application's webhook endpoint, where Infobip sends real-time delivery reports (DLRs). This URL must be reachable by Infobip for your application to receive status updates.
Infobip needs a callback URL (the notifyUrl) to send your application asynchronous updates about the delivery status of your SMS messages. This enables your system to react to successful deliveries, failures, or other status changes without constantly polling the Infobip API.
Use ngrok during local development with Infobip to create a publicly accessible URL for your webhook endpoint. Since Infobip needs to reach your local server for DLR callbacks, ngrok provides a tunnel from a public URL to your localhost, making testing and development easier.
You can implement retry mechanisms for failed Infobip SMS messages, but only for initial failures from the Infobip API (like network or 5xx errors), not for final statuses like 'UNDELIVERABLE'. Use exponential backoff to avoid overloading the system, but don't retry messages marked as permanently undeliverable by the carrier or Infobip.
The Infobip SMS notify content type is 'application/json'. This parameter specifies that delivery reports sent to your webhook endpoint will be in JSON format, making parsing and processing the data within your application more structured and efficient.
Set up Infobip DLR with Node.js by creating an Express.js server with two key endpoints: /send-sms to initiate message sending and /infobip-dlr to receive callbacks. The infobipService.js file contains the logic for sending SMS and handling API interactions using Axios.
The express.json() middleware in Express.js is essential for handling Infobip DLR callbacks. It parses incoming JSON payloads in POST requests, making the data accessible via req.body. This allows you to easily process delivery reports and update your application's internal state.
Dotenv helps manage environment variables securely when integrating with the Infobip API. It loads credentials like your API key from a .env file, keeping sensitive information out of your source code and version control.
To send SMS with the Infobip API and Node.js, use the sendSms function from infobipService.js. This function requires the recipient's phone number in international format and the message text. It handles constructing the API request, including headers, and making the request using Axios.
To test Infobip webhooks locally, expose your development server using ngrok and update the notifyUrl in your Infobip API requests to point to the generated ngrok HTTPS URL. This allows Infobip to reach your local server with DLR callbacks during development.
Structure a database for Infobip DLRs with a table for outgoing messages. Include fields for message details, Infobip message ID, recipient number, status, error codes, and timestamps. Index the infobip_message_id column for efficient lookup during callback processing.
Common status groups in Infobip DLRs include 'PENDING', 'UNDELIVERABLE', 'DELIVERED', 'EXPIRED', and 'REJECTED'. These groups provide a broad categorization of the message delivery status. More detailed status names within each group provide further information.
Best practices for logging Infobip DLRs include logging the full incoming request body to understand its structure and content. Log specific fields from each report, including messageId, status, recipient, and errors. Use structured logging and external logging services for improved analysis and alerting.
Implement security for your Infobip webhook endpoint by using HTTPS for secure communication. Optional security measures could include signature validation (if supported by Infobip) to verify the sender, and IP whitelisting if feasible. Basic input validation is also important for security and preventing unexpected input.
This guide provides a complete walkthrough for building a Node.js application using the Express framework to send SMS messages via the Infobip API and handle real-time delivery status updates through webhooks (callbacks).
By the end of this tutorial, you will have a functional application capable of:
This enables developers to build more robust messaging workflows, providing visibility into message delivery success and enabling actions based on status updates — such as retrying failed messages or logging delivery confirmations.
Target Audience: Developers familiar with Node.js, Express, and basic API concepts.
System Architecture
The system involves the following components:
notifyUrlfor callbacks./infobip-dlr) to receive delivery reports from Infobip.notifyUrlprovided by the application.Prerequisites
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
1.1 Create Project Directory
Open your terminal or command prompt and create a new directory for the project.
mkdir infobip-dlr-app cd infobip-dlr-app1.2 Initialize Node.js Project
Initialize the project using npm (or yarn). This creates a
package.jsonfile.-y? This flag accepts the default settings during initialization, speeding up the process. You can omit it to customize project details.1.3 Install Dependencies
We need
expressfor our web server,axiosto make HTTP requests to the Infobip API, anddotenvto manage environment variables securely.express: A minimal and flexible Node.js web application framework.axios: A popular promise-based HTTP client for making requests to external APIs like Infobip's.dotenv: Loads environment variables from a.envfile intoprocess.env, keeping sensitive information like API keys out of your source code.1.4 Create Project Structure
Create the basic files and folders:
# For Linux/macOS touch index.js .env .gitignore infobipService.js # For Windows (Command Prompt) echo. > index.js echo. > .env echo. > .gitignore echo. > infobipService.js # For Windows (PowerShell) New-Item index.js -ItemType File New-Item .env -ItemType File New-Item .gitignore -ItemType File New-Item infobipService.js -ItemType FileYour initial structure should look like this:
1.5 Configure
.gitignoreAdd
node_modulesand.envto your.gitignorefile. This prevents committing dependencies and sensitive credentials to version control..env? It contains sensitive API keys and credentials. Never commit this file to public or shared repositories. Team members should create their own.envfiles based on a template or secure sharing mechanism.1.6 Set up Environment Variables (
.env)Open the
.envfile and add placeholders for your Infobip credentials and application settings. You will replace these placeholders later.INFOBIP_API_KEY: Your secret key for authenticating API requests.INFOBIP_BASE_URL: The specific domain assigned to your Infobip account for API access.PORT: The port your Express application will listen on.APP_BASE_URL: The base URL where your application is accessible from the internet. Infobip needs this to send callbacks. For local development, this will be your ngrok tunnel URL.2. Implementing Core Functionality: Sending SMS
Let's create a service to interact with the Infobip API for sending SMS messages.
2.1 Create Infobip Service File
This step was included in the project structure setup (1.4). You should have an
infobipService.jsfile.2.2 Implement
sendSmsFunctionAdd the following code to
infobipService.js. This code adapts the logic from the Infobip developer blog post, adding the crucialnotifyUrlparameter.// infobipService.js const axios = require('axios'); // Load environment variables (ensure dotenv is configured in index.js first) const apiKey = process.env.INFOBIP_API_KEY; const baseUrlDomain = process.env.INFOBIP_BASE_URL; const appBaseUrl = process.env.APP_BASE_URL; // Base URL for callbacks /** * Constructs the full API endpoint URL for sending SMS. * @param {string} domain - The Infobip base URL domain. * @returns {string} The full API URL. */ const buildUrl = (domain) => { if (!domain) { throw new Error('INFOBIP_BASE_URL is not defined in environment variables.'); } // Using the recommended Advanced SMS endpoint return `https://${domain}/sms/2/text/advanced`; }; /** * Constructs the necessary HTTP headers for Infobip API authentication. * @param {string} key - The Infobip API Key. * @returns {object} Headers object. */ const buildHeaders = (key) => { if (!key) { throw new Error('INFOBIP_API_KEY is not defined in environment variables.'); } return { 'Authorization': `App ${key}`, 'Content-Type': 'application/json', 'Accept': 'application/json', // Explicitly accept JSON responses }; }; /** * Constructs the request body for the Infobip Send SMS API. * Includes the destination number, message text, and the callback URL. * @param {string} destinationNumber - The recipient's phone number in international format (e.g., 447... ). * @param {string} message - The text content of the SMS. * @param {string} callbackUrl - The publicly accessible URL for delivery reports. * @returns {object} Request body object. */ const buildRequestBody = (destinationNumber, message, callbackUrl) => { const destinationObject = { to: destinationNumber, // messageId: 'OPTIONAL-CUSTOM-MESSAGE-ID' // Optional: Add a custom ID if needed }; const messageObject = { destinations: [destinationObject], text: message, // from: 'YourSenderID', // Optional: Specify a custom sender ID if configured/allowed notifyUrl: callbackUrl, // ** Crucial for receiving delivery reports ** notifyContentType: 'application/json', // Specify format for DLRs // See Infobip docs for more options: validityPeriod, sendAt, etc. }; return { messages: [messageObject], // bulkId: 'OPTIONAL-CUSTOM-BULK-ID' // Optional: Group messages }; }; /** * Sends an SMS message using the Infobip API. * @param {string} destinationNumber - Recipient's phone number (international format). * @param {string} message - SMS text content. * @returns {Promise<object>} A promise that resolves with the Infobip API response or rejects with an error. */ const sendSms = async (destinationNumber, message) => { if (!destinationNumber || !message) { throw new Error('Destination number and message text are required.'); } if (!appBaseUrl) { throw new Error('APP_BASE_URL is not defined. Needed for callback URL.'); } const apiUrl = buildUrl(baseUrlDomain); const headers = buildHeaders(apiKey); // Construct the full callback URL const callbackUrl = `${appBaseUrl}/infobip-dlr`; const requestBody = buildRequestBody(destinationNumber, message, callbackUrl); console.log(`Sending SMS to ${destinationNumber} via ${apiUrl}`); console.log(`Callback URL set to: ${callbackUrl}`); try { const response = await axios.post(apiUrl, requestBody, { headers }); console.log('Infobip API Response:', JSON.stringify(response.data, null, 2)); // The response here confirms Infobip accepted the request, not final delivery. // It includes a messageId useful for tracking. return response.data; } catch (error) { console.error('Error sending SMS via Infobip:'); if (error.response) { // The request was made and the server responded with a status code // that falls out of the range of 2xx console.error('Status:', error.response.status); console.error('Headers:', JSON.stringify(error.response.headers, null, 2)); console.error('Data:', JSON.stringify(error.response.data, null, 2)); // Rethrow a more specific error or handle based on status code throw new Error(`Infobip API Error: ${error.response.status} - ${JSON.stringify(error.response.data)}`); } else if (error.request) { // The request was made but no response was received console.error('Request Error:', error.request); throw new Error('Error sending SMS: No response received from Infobip.'); } else { // Something happened in setting up the request that triggered an Error console.error('Axios Error:', error.message); throw new Error(`Error sending SMS: ${error.message}`); } } }; module.exports = { sendSms, };async/await? It simplifies working with promises returned byaxios, making the code cleaner and easier to read than using.then()and.catch()chains directly for complex flows.notifyUrlandnotifyContentType? These parameters instruct Infobip where and how (JSON format) to send the delivery report once the final status of the message is known. This is the core mechanism for enabling callbacks.APP_BASE_URL? The callback URL must be absolute and publicly accessible. We construct it using the base URL defined in our environment variables.3. Building the API Layer and Handling Callbacks
Now, let's set up our Express server, create an endpoint to trigger SMS sending, and critically, an endpoint to receive the delivery reports from Infobip.
3.1 Basic Express Server Setup
Modify your
index.jsfile:// index.js // Load environment variables from .env file require('dotenv').config(); const express = require('express'); const infobipService = require('./infobipService'); // Import our service const app = express(); const port = process.env.PORT || 3000; // Use port from .env or default to 3000 // Middleware to parse JSON request bodies // Crucial for both our API endpoint and receiving Infobip's JSON callbacks app.use(express.json()); // Simple root route for health check or basic info app.get('/', (req, res) => { res.send('Infobip DLR Handler App is running!'); }); // --- API Endpoint to Send SMS --- app.post('/send-sms', async (req, res) => { const { to, text } = req.body; // Expect 'to' (number) and 'text' (message) in JSON body // Basic Input Validation if (!to || typeof to !== 'string' || to.trim() === '') { return res.status(400).json({ error: 'Missing or invalid "to" phone number in request body.' }); } if (!text || typeof text !== 'string' || text.trim() === '') { return res.status(400).json({ error: 'Missing or invalid "text" message in request body.' }); } try { console.log(`Received request to send SMS to: ${to}`); const result = await infobipService.sendSms(to.trim(), text.trim()); // Send back the initial response from Infobip (acknowledgment, messageId) res.status(202).json({ // 202 Accepted: Request taken, processing underway message: 'SMS sending initiated successfully.', infobipResponse: result, }); } catch (error) { console.error('Error in /send-sms endpoint:', error.message); // Avoid sending detailed internal errors back to the client in production res.status(500).json({ error: 'Failed to initiate SMS sending.' }); } }); // --- Webhook Endpoint to Receive Infobip Delivery Reports --- app.post('/infobip-dlr', (req, res) => { console.log('--- Received Infobip Delivery Report ---'); console.log('Timestamp:', new Date().toISOString()); console.log('Headers:', JSON.stringify(req.headers, null, 2)); // Log headers for debugging if needed console.log('Body:', JSON.stringify(req.body, null, 2)); // Log the full DLR payload // --- Processing Logic --- // The actual payload structure can vary slightly based on Infobip product/settings. // Inspect the logged 'Body' from a real callback to confirm structure. // Common structure includes a 'results' array. if (req.body && Array.isArray(req.body.results)) { req.body.results.forEach(report => { const messageId = report.messageId; const status = report.status ? report.status.name : 'UNKNOWN'; const groupName = report.status ? report.status.groupName : 'UNKNOWN'; const description = report.status ? report.status.description : 'No description'; const recipient = report.to; const errorCode = report.error ? report.error.id : null; const errorName = report.error ? report.error.name : null; const errorDescription = report.error ? report.error.description : null; console.log(`\n--- Processing DLR for Message ID: ${messageId} ---`); console.log(` To: ${recipient}`); console.log(` Status Group: ${groupName}`); // e.g., PENDING, UNDELIVERABLE, DELIVERED, EXPIRED, REJECTED console.log(` Status: ${status}`); // e.g., PENDING_ACCEPTED, DELIVERED_TO_HANDSET, UNDELIVERABLE_NOT_DELIVERED console.log(` Description: ${description}`); if (errorCode) { console.error(` Error Code: ${errorCode} (${errorName})`); console.error(` Error Description: ${errorDescription}`); } // TODO: Add your business logic here: // 1. Find the original message record in your database using messageId. // 2. Update the message status based on 'groupName' or 'status'. // 3. If status is 'UNDELIVERABLE' or 'REJECTED', trigger alerts or retry logic. // 4. Log detailed information for auditing. }); } else { console.warn('Received DLR in unexpected format:', JSON.stringify(req.body)); } // ** Crucial: Respond to Infobip quickly with a 2xx status code ** // Failing to respond or responding with an error might cause Infobip to retry sending the DLR. res.sendStatus(200); // Send 'OK' back to Infobip to acknowledge receipt. }); // Start the server app.listen(port, () => { console.log(`Server listening on port ${port}`); console.log(`Ensure INFOBIP_API_KEY and INFOBIP_BASE_URL are set.`); console.log(`Callback endpoint configured internally as /infobip-dlr`); console.log(`Ensure APP_BASE_URL is set correctly in .env for callbacks`); if (process.env.NODE_ENV !== 'production') { console.warn(`--- Development Mode ---`); console.warn(`Use a tool like ngrok to expose port ${port} publicly.`); console.warn(`Update APP_BASE_URL in .env with your ngrok URL (e.g., https://xxxxx.ngrok.io)`); console.warn(`Example ngrok command: ngrok http ${port}`); } });require('dotenv').config()first? This line must execute before any code that accessesprocess.envvariables (like ourinfobipService.js) to ensure those variables are loaded from the.envfile.express.json()middleware? Infobip sends delivery reports as JSON payloads in the POST request body. This middleware parses that JSON and makes it available asreq.body. It's also needed for our/send-smsendpoint to parse the incoming JSON request./send-smsendpoint? This provides a clean interface for other parts of your system (or external clients) to request an SMS without needing direct access to the Infobip service logic./infobip-dlrendpoint? This is the publicly accessible listener that Infobip will call. Its sole purpose is to receive, acknowledge, and process incoming delivery status updates.res.sendStatus(200)in/infobip-dlr? You must respond to Infobip's webhook request with a success status (2xx) quickly. This acknowledges receipt. If you respond with an error (4xx, 5xx) or time out, Infobip will likely retry sending the report according to their retry schedule, potentially leading to duplicate processing if not handled carefully. Do complex processing asynchronously if needed.req.body.results? Based on Infobip documentation and common webhook patterns, delivery reports often come batched within aresultsarray in the JSON payload. Always inspect the actual payload you receive during testing to confirm the structure.4. Integrating with Infobip: Configuration
Let's get the necessary credentials from Infobip and configure our
.envfile.4.1 Obtain Infobip API Key and Base URL
Node DLR App Key).xxxxxx.api.infobip.com.4.2 Update
.envFileOpen your
.envfile and replace the placeholders with the actual values you obtained:4.3 Set Up ngrok for Local Development
Infobip needs to reach your
/infobip-dlrendpoint over the public internet.ngrokcreates a secure tunnel from a public URL to your local machine.Download and install ngrok.
Authenticate ngrok if you have an account (optional but recommended for longer sessions).
In your terminal (in a separate window from your running Node app), start ngrok, pointing it to the port your Express app is running on (defined by
PORTin.env, default 3000).ngrok will display output including lines like:
Copy the
httpsForwarding URL (e.g.,https://<random-string>.ngrok.io). This is your public URL.Update
APP_BASE_URLin your.envfile with thishttpsngrok URL.Restart your Node.js application (
node index.jsor usingnodemon) after updating.envso it picks up the newAPP_BASE_URL.5. Error Handling and Logging
Our current code includes basic console logging and error handling. Let's refine it slightly.
infobipService.js): ThesendSmsfunction already includes atry...catchblock that logs detailed errors fromaxios, distinguishing between response errors, request errors, and setup errors. It re-throws a generic error to the caller./send-smsinindex.js): The endpoint wraps the service call intry...catch. It logs the error server-side and returns a generic 500 error to the client to avoid leaking internal details. Basic 400 Bad Request errors are returned for invalid input./infobip-dlrinindex.js): This endpoint logs the entire incoming payload. It includes aconsole.warnfor unexpected formats and logs specific details for recognized formats. Crucially, it always responds with 200 OK to prevent Infobip retries, even if internal processing fails. Robust error handling here might involve:try...catch.res.sendStatus(200)back to Infobip.Further Improvements (Beyond this Guide):
winstonorpinofor structured JSON logging, making logs easier to parse and analyze, especially in production.axios-retryorasync-retry. Do not retry based on DLRs indicating final failure (likeUNDELIVERABLE).6. Database Schema and Data Layer (Conceptual)
While this guide doesn't implement a database, here's how you would typically integrate one:
6.1 Conceptual Schema
You'd likely need a table to store information about outgoing messages:
CREATE TABLE outgoing_messages ( id SERIAL PRIMARY KEY, -- Or UUID infobip_message_id VARCHAR(255) UNIQUE, -- Store the ID from Infobip's initial response recipient_number VARCHAR(20) NOT NULL, message_text TEXT NOT NULL, status VARCHAR(50) DEFAULT 'PENDING_INFOBIP', -- Initial status before Infobip confirms acceptance infobip_status_group VARCHAR(50), -- e.g., PENDING, DELIVERED, UNDELIVERABLE infobip_status_name VARCHAR(100), -- Specific status e.g., PENDING_ACCEPTED infobip_status_description TEXT, error_code INTEGER, error_name VARCHAR(100), error_description TEXT, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, last_updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, callback_received_at TIMESTAMPTZ -- Timestamp when the DLR was processed ); -- Index for faster lookups when processing callbacks CREATE INDEX idx_infobip_message_id ON outgoing_messages(infobip_message_id);6.2 Data Layer Integration
/send-sms):infobipService.sendSms, insert a new record intooutgoing_messageswith statusPENDING_INFOBIP.infobipService.sendSms, update the record using themessageIdfrom the Infobip response, setting theinfobip_message_idfield and potentially updating status toPENDING_ACCEPTED(or similar based on the initial response)./infobip-dlr):req.body.results:messageIdfrom the report.outgoing_messagesusinginfobip_message_id.infobip_status_group,infobip_status_name,infobip_status_description, error fields,callback_received_at, andlast_updated_atbased on the DLR content.messageIdis not found (log an error/warning).Tools: Use an ORM like Sequelize (for SQL databases) or Mongoose (for MongoDB) to manage database interactions more easily. Use migration tools like
sequelize-cliorknex migrationsto manage schema changes.7. Security Features
Security is paramount, especially when handling API keys and potentially sensitive message data.
.envto keepINFOBIP_API_KEYout of the code and.gitignoreto prevent committing it. This is crucial./send-sms): Basic validation is implemented to check for the presence and type oftoandtext. Enhance this based on expected phone number formats or message length constraints. Use libraries likejoiorexpress-validatorfor more robust validation schemas./infobip-dlr):ngrokwith HTTPS and deploying to a server with HTTPS configured is essential to encrypt data in transit.X-Infobip-Signature) along with the webhook. If so, you would:notifyUrlas of common knowledge, but check their latest documentation./infobip-dlrfrom those specific IPs. This can be difficult to maintain if IPs change./send-smsendpoint from abuse by implementing rate limiting using middleware likeexpress-rate-limit.helmetmiddleware for Express to set various security-related HTTP headers (e.g., Content-Security-Policy, X-Content-Type-Options).npm install helmetandapp.use(helmet());.8. Handling Special Cases
447123456789for the UK,14155552671for the US). Ensure your input validation or normalization logic enforces this format before sending to the API.PENDINGstatus indicates the message is still in transit./infobip-dlr) to be idempotent — meaning processing the same DLR multiple times has no adverse effects. Using theinfobip_message_idas a unique key in your database helps achieve this (e.g., update status only if the new status is different or more final than the existing one).TIMESTAMPTZ(Timestamp with Time Zone) in your database schemas (created_at,last_updated_at,callback_received_at) to store timestamps unambiguously. Log timestamps usingtoISOString()for clarity.9. Performance Optimizations (Conceptual)
For high-volume applications:
/infobip-dlrrequest handler. Immediately send the200 OKresponse, and then push the DLR payload onto a message queue (like RabbitMQ, Redis Streams, or AWS SQS) for background workers to process. This prevents holding up Infobip's request and avoids timeouts.WHEREclauses (especiallyinfobip_message_id).k6,artillery, orJMeterto simulate traffic to your/send-smsendpoint and (if possible) your/infobip-dlrendpoint to identify bottlenecks under load.10. Monitoring, Observability, and Analytics (Conceptual)
app.get('/')endpoint or create a dedicated/healthendpoint that checks basic connectivity (e.g., can reach the database). Monitoring services can ping this endpoint.