Project Overview and Goals
This guide details how to build a robust Node.js application using the Fastify framework to handle inbound SMS messages via Infobip webhooks and enable automated replies, creating a functional two-way messaging system.
What We'll Build:
A Fastify server that:
- Listens for incoming HTTP POST requests (webhooks) from Infobip containing SMS message data.
- Securely validates and processes these incoming messages.
- Logs message details for tracking and debugging.
- Optionally sends an automated SMS reply back to the original sender via the Infobip API.
Problem Solved:
Many applications need to react to SMS messages sent by users – for customer support, automated information retrieval, receiving commands, or triggering workflows. This guide provides the foundation for building such systems, enabling programmatic interaction via SMS.
Technologies Used:
- Node.js: A JavaScript runtime environment for building server-side applications.
- Fastify: A high-performance, low-overhead web framework for Node.js, chosen for its speed, extensibility, and developer-friendly features.
- Infobip API & SDK: Provides the communication infrastructure for sending and receiving SMS messages. We'll use their webhook system for inbound messages and the official Node.js SDK (
@infobip-api/sdk
) for outbound replies. dotenv
: A zero-dependency module to load environment variables from a.env
file intoprocess.env
.
System Architecture:
Note: This diagram relies on monospace fonts. For optimal display across all platforms, consider replacing this with an actual image diagram and providing descriptive alt text.
+-------------+ +-----------------+ +----------------------+ +---------------------+ +-------------+
| User's Phone| ----> | Infobip Network | ----> | Infobip Webhook Call | ----> | Your Fastify App | ----> | User's Phone|
| (Sends SMS) | | (Receives SMS) | | (HTTP POST to URL) | | (Processes & Replies)| | (Receives Reply)|
+-------------+ +-----------------+ +----------------------+ +---------------------+ +-------------+
|
| [Optional Reply]
V
+----------------+
| Infobip API Call |
| (Send SMS) |
+----------------+
Prerequisites:
- An active Infobip account with API access.
- A provisioned phone number within your Infobip account capable of sending/receiving SMS.
- Node.js (v18 or later recommended) and npm (or yarn) installed on your development machine.
- Basic understanding of JavaScript, Node.js, REST APIs, and webhooks.
- A tool to expose your local server to the internet for testing webhooks (e.g.,
ngrok
).
Final Outcome:
A running Fastify application capable of receiving SMS messages sent to your Infobip number and logging them. Optionally, it can send an automated reply.
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 and create a new directory for the project.
mkdir fastify-infobip-sms
cd fastify-infobip-sms
1.2 Initialize Node.js Project:
Initialize the project using npm. You can accept the defaults or customize as needed.
npm init -y
This creates a package.json
file.
1.3 Install Dependencies:
We need Fastify for the web server, the Infobip SDK for interacting with their API, and dotenv
for managing environment variables.
npm install fastify @infobip-api/sdk dotenv
1.4 Install Development Dependencies (Optional but Recommended):
For a better development experience, install nodemon
to automatically restart the server on file changes.
npm install --save-dev nodemon
1.5 Configure package.json
Scripts:
Add scripts to your package.json
for running the server easily.
// package.json
{
// ... other properties
"main": "src/server.js", // Specify entry point
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js", // Use nodemon for development
"test": "echo \"Error: no test specified\" && exit 1"
},
// ... other properties
}
Note: The "main": "src/server.js"
line points to a file (src/server.js
) that we create in the next step. This is standard practice but be aware the file doesn't exist immediately after this package.json
edit.
1.6 Create Project Structure:
Organize your code for better maintainability.
mkdir src
mkdir src/routes
mkdir src/config
touch src/server.js
touch src/routes/infobipWebhook.js
touch src/config/environment.js
touch .env
touch .env.example
touch .gitignore
src/
: Contains all source code.src/routes/
: Holds route handlers.src/config/
: For configuration files.src/server.js
: The main application entry point..env
: Stores sensitive credentials (API keys, etc.). Never commit this file..env.example
: A template showing required environment variables. Commit this file..gitignore
: Specifies intentionally untracked files that Git should ignore.
1.7 Configure .gitignore
:
Add node_modules
and .env
to your .gitignore
file to prevent committing them.
# .gitignore
node_modules/
.env
*.log
1.8 Set Up Environment Variables:
Define the necessary environment variables in .env.example
and .env
.
# .env.example
# Infobip Credentials
INFOBIP_API_KEY=YOUR_INFOBIP_API_KEY
INFOBIP_BASE_URL=YOUR_INFOBIP_BASE_URL # e.g., xyz.api.infobip.com
INFOBIP_WEBHOOK_SECRET=YOUR_OPTIONAL_SHARED_SECRET # Optional, for added security if supported/used
# Server Configuration
PORT=3000
HOST=0.0.0.0 # Listen on all available network interfaces
Important: Copy .env.example
to .env
and fill in your actual Infobip API Key and Base URL in the .env
file. You can find these in your Infobip account dashboard (usually under API Keys). The INFOBIP_WEBHOOK_SECRET
is optional; use it only if you configure a corresponding mechanism in Infobip (see Section 6).
1.9 Load Environment Variables:
Create a simple module to load and export environment variables.
// src/config/environment.js
require('dotenv').config(); // Load variables from .env
const environment = {
infobip: {
apiKey: process.env.INFOBIP_API_KEY,
baseUrl: process.env.INFOBIP_BASE_URL,
webhookSecret: process.env.INFOBIP_WEBHOOK_SECRET, // Can be undefined
},
server: {
port: parseInt(process.env.PORT || '3000', 10),
host: process.env.HOST || '0.0.0.0',
},
};
// Basic validation
if (!environment.infobip.apiKey || !environment.infobip.baseUrl) {
console.error(
'FATAL ERROR: INFOBIP_API_KEY and INFOBIP_BASE_URL must be defined in the .env file'
);
process.exit(1);
}
module.exports = environment;
1.10 Basic Fastify Server Setup:
Initialize the Fastify server in src/server.js
.
// src/server.js
const fastify = require('fastify')({
logger: true, // Enable built-in Pino logger
});
const config = require('./config/environment');
const infobipWebhookRoutes = require('./routes/infobipWebhook');
// --- Register Plugins and Routes ---
// Register the webhook routes
fastify.register(infobipWebhookRoutes, { prefix: '/webhook' });
// --- Default Root Route ---
fastify.get('/', async (request, reply) => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
// --- Health Check Route ---
fastify.get('/health', async (request, reply) => {
return { status: 'healthy' };
});
// --- Start Server ---
const start = async () => {
try {
await fastify.listen({ port: config.server.port, host: config.server.host });
fastify.log.info(`Server listening on ${fastify.server.address().port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
// Export the app instance if needed for testing (alternative to builder function)
// module.exports = fastify;
// Or refactor into a builder function for cleaner testing (see Section 9.2 note)
Now you have a basic Fastify server structure ready. You can test it by running npm run dev
. You should see log output indicating the server has started. Accessing http://localhost:3000/
in your browser should return {"status":"ok", ...}
.
2. Implementing the Inbound Webhook
The core of receiving SMS messages is handling the HTTP POST request Infobip sends to your application (the webhook).
2.1 Define the Webhook Route Handler:
In src/routes/infobipWebhook.js
, create the Fastify route handler that will listen for POST requests at /webhook/infobip
.
// src/routes/infobipWebhook.js
const config = require('../config/environment');
// Import Infobip SDK later when needed for replies
// const { Infobip, AuthType } = require('@infobip-api/sdk');
async function infobipWebhookRoutes(fastify, options) {
// Optional: Instantiate Infobip client here if needed within this plugin scope
// const infobipClient = new Infobip({ /* ... */ });
fastify.post('/infobip', async (request, reply) => {
fastify.log.info('Received Infobip webhook request');
fastify.log.debug({ body: request.body }, 'Webhook payload');
// --- Security Check ---
// **IMPORTANT:** Verify if/how Infobip supports securing webhooks.
// Common methods include Basic Authentication or a custom signature header.
// Consult the Infobip documentation for the *specific mechanism* they provide.
// The example below uses a hypothetical shared secret header. Adapt as needed.
if (config.infobip.webhookSecret) {
const receivedSecret = request.headers['x-webhook-secret']; // Example header - CHECK INFOBIP DOCS
if (receivedSecret !== config.infobip.webhookSecret) {
fastify.log.warn('Invalid or missing webhook secret received');
return reply.code(401).send({ error: 'Unauthorized' });
}
}
// Add checks for Basic Auth (request.headers.authorization) if configured in Infobip.
// --- Process Incoming Message ---
try {
// **IMPORTANT:** Infobip payload structure can vary.
// Always check the *specific webhook configuration* in your Infobip portal
// and the official Infobip documentation for the expected payload format.
// The common structure involves a 'results' array.
const results = request.body.results;
if (!results || !Array.isArray(results) || results.length === 0) {
fastify.log.warn('Webhook payload missing or invalid results array');
return reply.code(400).send({ error: 'Invalid payload format' });
}
// Process each message in the payload (often just one)
for (const message of results) {
// **IMPORTANT:** Field names like 'receivedAt', 'text', 'from', 'to', 'messageId'
// depend on your Infobip webhook configuration. Verify these in the Infobip docs
// or by inspecting an actual payload received from Infobip.
const messageId = message.messageId;
const from = message.from;
const to = message.to; // Your Infobip number
const text = message.text;
const receivedAt = message.receivedAt; // Example field name
if (!messageId || !from || !text) {
fastify.log.warn({ message }, 'Skipping message with missing fields');
continue; // Skip this message, process others if any
}
fastify.log.info(
`Processing message ${messageId} from ${from}: ""${text}""`
);
// --- TODO: Implement your business logic here ---
// Examples:
// 1. Store the message in a database
// 2. Trigger a workflow based on keywords in 'text'
// 3. Send an automated reply (see Section 4)
// --- Add Reply Logic Here (See Section 4) ---
} // End for loop
// --- Acknowledge Receipt ---
// Crucially, respond quickly to Infobip to prevent timeouts/retries
reply.code(200).send({ status: 'received' });
} catch (error) {
fastify.log.error(
{ err: error },
'Error processing Infobip webhook payload'
);
// Send a generic error response. Avoid leaking internal details.
reply.code(500).send({ error: 'Internal Server Error' });
}
});
}
module.exports = infobipWebhookRoutes;
Explanation:
- Route Definition: We define a
POST
route at/infobip
(prefixed with/webhook
, making the full path/webhook/infobip
). - Logging: We log the incoming request body for debugging. Adjust log levels (
info
,debug
) as needed. - Security Check: Includes a placeholder for validating a secret. Crucially, it emphasizes checking Infobip documentation for their actual supported security mechanisms (Basic Auth, signatures, etc.) and adapting the code accordingly.
- Payload Parsing: Checks for the
results
array. Adds strong emphasis (multiple bolded notes) that the payload structure and field names (results
,messageId
,from
,text
,receivedAt
) MUST be verified against the specific Infobip configuration and documentation. - Data Extraction & Validation: Extracts fields based on the assumed common structure, with added warnings about verification. Basic checks ensure required fields are present.
- Business Logic Placeholder:
TODO
marks where application-specific logic goes. - Acknowledgement: Sends
200 OK
promptly. - Error Handling:
try...catch
logs errors and sends500
.
3. Integrating with Infobip
Now, we need to tell Infobip where to send the incoming SMS messages.
3.1 Expose Your Local Server (Development Only):
Infobip needs a publicly accessible URL to send webhooks. During development, you can use ngrok
.
-
Download and install ngrok: https://ngrok.com/download
-
Authenticate ngrok if you haven't already (follow their instructions).
-
Start your Fastify server:
npm run dev
(it should be running on port 3000). -
In a new terminal window, run:
ngrok http 3000
-
ngrok
will display output similar to this:Forwarding https://<unique-id>.ngrok-free.app -> http://localhost:3000
Copy the
https://<unique-id>.ngrok-free.app
URL. This is your public URL. Remember that freengrok
URLs are temporary and change every time you restartngrok
. For stable testing or production, you need a permanent URL (see Section 8).
3.2 Configure Infobip Webhook:
- Log in to your Infobip account portal (https://portal.infobip.com/).
- Navigate to the section for managing your numbers or SMS settings (e.g., "Apps", "Numbers", "Channels -> SMS"). Look for incoming message settings or webhook configurations.
- Find the phone number you want to use.
- Locate the option to configure "Incoming Messages", "Forward to URL", or "Webhook URL".
- Enter your public webhook URL:
https://<unique-id>.ngrok-free.app/webhook/infobip
(use your actual ngrok URL). - Security (Optional but Recommended): Configure webhook security in Infobip (e.g., Basic Authentication, specific headers/signatures if offered) and ensure your application code (Section 2.1) validates it correctly.
- Save the configuration.
3.3 Verify API Key and Base URL:
Double-check that the INFOBIP_API_KEY
and INFOBIP_BASE_URL
in your .env
file are correct. These are needed for sending replies (Section 4).
- API Key: Find in "API Keys" or "Developer Tools" in Infobip.
- Base URL: Specific to your account (e.g.,
yoursubdomain.api.infobip.com
), usually shown near the API key.
4. Adding Two-Way Reply Functionality
Let's make the application reply to incoming messages.
4.1 Instantiate Infobip SDK Client and Send Reply:
Update src/routes/infobipWebhook.js
to use the SDK.
// src/routes/infobipWebhook.js
const config = require('../config/environment');
const { Infobip, AuthType } = require('@infobip-api/sdk'); // Import SDK
async function infobipWebhookRoutes(fastify, options) {
// Instantiate Infobip client within the plugin scope
const infobipClient = new Infobip({
baseUrl: config.infobip.baseUrl,
apiKey: config.infobip.apiKey,
authType: AuthType.ApiKey, // Use API Key authentication
});
fastify.post('/infobip', async (request, reply) => {
// ... (logging and security checks from Section 2.1) ...
try {
const results = request.body.results;
// ... (payload validation from Section 2.1, including checking Infobip docs) ...
for (const message of results) {
// Verify field names based on Infobip docs/payload
const messageId = message.messageId;
const from = message.from; // Sender's number (recipient of our reply)
const to = message.to; // Your Infobip number (sender of our reply)
const text = message.text;
// ... (field validation) ...
fastify.log.info(
`Processing message ${messageId} from ${from}: ""${text}""`
);
// --- Send Automated Reply ---
try {
const replyText = `Thanks for your message: ""${text.substring(0, 50)}${text.length > 50 ? '...' : ''}""`;
fastify.log.info(`Sending reply to ${from}`);
// Use the SDK's send method
const infobipResponse = await infobipClient.channels.sms.send({
messages: [
{
destinations: [{ to: from }], // Send reply to the original sender
// **IMPORTANT**: Specifying 'from' is highly recommended for consistency.
// Use your Infobip number ('to' from the incoming message) or a registered
// Alphanumeric Sender ID (e.g., 'MyBrand').
// Alphanumeric Sender ID rules are country-specific. Check Infobip docs.
from: to,
text: replyText,
},
],
});
// Log the result from Infobip API
// **Note:** Verify `infobipResponse.data` is the correct path to response details
// in the specific SDK version you are using. Log `infobipResponse` itself
// during testing if unsure.
fastify.log.info(
{ response: infobipResponse.data },
'Infobip reply API response'
);
} catch (sendError) {
fastify.log.error(
{ err: sendError, recipient: from },
'Failed to send SMS reply via Infobip API'
);
// Decide if this failure should cause the webhook to return 500. Usually not.
}
// --- End Send Reply ---
} // End for loop
reply.code(200).send({ status: 'received' });
} catch (error) {
// ... (general error handling from Section 2.1) ...
reply.code(500).send({ error: 'Internal Server Error' });
}
});
}
module.exports = infobipWebhookRoutes;
Explanation:
- Import & Instantiate: Imports
Infobip
,AuthType
and createsinfobipClient
. - Send Reply Logic:
- Calls
infobipClient.channels.sms.send()
. destinations
: Setto
the original sender (from
).from
: Updated explanation: Emphasizes that specifyingfrom
(your Infobip number or Alphanumeric Sender ID) is highly recommended or required. Notes Alphanumeric Sender ID rules are country-specific and require checking docs.text
: Reply content.
- Calls
- Response Logging: Logs
infobipResponse.data
. Adds a note advising verification of thedata
property based on the SDK version. - Reply Error Handling: Specific
try...catch
for sending, logging errors but generally still returning200 OK
for the webhook receipt.
5. Error Handling and Logging
Robust error handling and clear logging are crucial for production systems.
Error Handling Strategy:
- Webhook Processing: Wrap main logic in
try...catch
. Log errors (fastify.log.error
). Return appropriate HTTP status codes (500
,400
,401
). - Reply Sending: Wrap SDK calls in separate
try...catch
. Log send errors specifically. Usually return200
to the webhook even if the reply fails, handling reply failures asynchronously if needed. - Fastify Errors: Utilize Fastify's built-in error handling and logging. Consider custom error handlers (
fastify.setErrorHandler()
) for advanced scenarios.
Logging:
- Fastify Logger (Pino): Enabled (
logger: true
) for structured JSON logging. - Log Levels: Use
info
,warn
,error
,debug
appropriately. Control level via environment variables in production. - Context: Include relevant data (messageId, recipient, error objects) in logs.
- Log Analysis: Use centralized logging systems (Datadog, ELK, Splunk) in production.
Retry Mechanisms:
- Webhook Retries (Infobip): Infobip retries on non-
2xx
responses. Your app should respond quickly and be idempotent (handle duplicate messages safely, e.g., by checkingmessageId
against recent history). - Reply Retries (Your App): Implement custom retry logic (e.g., background job queue like BullMQ) for critical replies if the Infobip API call fails transiently.
6. Security Considerations
Securing your webhook endpoint is vital.
- Webhook Secret / Authentication:
- Highly Recommended: Use Infobip's security features (Basic Auth, Signature Validation - check their docs).
- Implement validation in your route handler (Section 2.1). Store secrets securely (environment variables).
- HTTPS: Always use HTTPS.
ngrok
provides this locally. Production requires TLS termination (hosting, load balancer). - Input Validation:
- Validate webhook payload structure rigorously.
- Sanitize/validate SMS
text
content if used in databases, commands, or displayed elsewhere to prevent injection attacks (XSS, SQLi).
- Rate Limiting: Protect against DoS/abuse using
@fastify/rate-limit
.- Install:
npm install @fastify/rate-limit
- Register in
server.js
:// src/server.js // ... other imports const rateLimit = require('@fastify/rate-limit'); // ... inside start() or before registering routes if using async register await fastify.register(rateLimit, { max: 100, // Example: Max requests per window per IP timeWindow: '1 minute' }); // ... register routes
- Adjust limits appropriately.
- Install:
- Least Privilege: Use Infobip API keys with minimum required permissions.
- Dependency Updates: Keep dependencies updated (
npm audit
).
7. Troubleshooting and Caveats
Common issues and things to watch out for:
- Webhook Not Received: Check URL (Infobip vs. actual), server status, firewalls,
ngrok
status (temporary URLs!). - 401 Unauthorized: Mismatched secrets/credentials (Infobip config vs.
.env
/app validation). - 400 Bad Request: Payload structure mismatch (check Infobip docs/config vs. your parsing logic in Section 2.1). Log the received
request.body
. - 500 Internal Server Error: Check server logs (
fastify.log.error
) for exceptions. - Reply Sending Fails: Check API Key/Base URL, Infobip balance/permissions, 'from'/'to' numbers (validity, Sender ID rules), Infobip status page.
- Infobip Free Trial Limitations: Often restricted to sending only to your verified number.
- Idempotency: Handle potential webhook retries without causing duplicate actions.
- Payload Variations: Ensure your handler matches the specific Infobip webhook event type/configuration.
8. Deployment Strategies
Moving from local development to production.
8.1 Obtain a Stable Public URL: Use cloud hosting (AWS, Google Cloud, Azure, Heroku, DigitalOcean, Render, Railway), load balancers, or API gateways for a permanent HTTPS endpoint.
8.2 Environment Configuration: Use secure environment variable management provided by your host (NOT .env
files in production). Set NODE_ENV=production
.
8.3 Process Management: Use pm2
or container orchestration (Docker, Kubernetes) for process management, restarts, and clustering (pm2 start src/server.js -i max
).
8.4 Dockerization (Optional): Containerize for consistency.
Dockerfile
:# Dockerfile FROM node:18-alpine As base WORKDIR /app # Install dependencies only when package files change COPY package*.json ./ RUN npm ci --only=production # Copy application code COPY . . # Expose the port the app runs on EXPOSE 3000 # Define the command to run your app CMD [ ""node"", ""src/server.js"" ]
.dockerignore
: Include.git
,.env
,node_modules/
.- Build/Run:
docker build
,docker run
(pass environment variables securely).
8.5 CI/CD Pipeline: Automate testing and deployment using GitHub Actions, GitLab CI, Jenkins, etc. (Lint, Test, Build, Deploy).
8.6 Rollback Procedures: Plan for reverting failed deployments (previous Docker image, Git tag).
9. Verification and Testing
Ensure your application works correctly.
9.1 Manual Verification:
- Run app (local
ngrok
or deployed). - Configure Infobip webhook URL correctly.
- Send test SMS to Infobip number.
- Check app logs for receipt, processing, and reply logs.
- Check phone for reply SMS.
- Check Infobip portal logs for webhook/SMS status.
9.2 Automated Testing (Recommended):
Implement unit and integration tests.
-
Install Testing Tools:
npm install --save-dev tap
(orjest
,supertest
). -
Unit Tests: Test functions in isolation.
-
Integration Tests (Webhook): Simulate webhook requests using Fastify's
inject
orsupertest
.- Note on Test Setup: The example below uses Fastify's
inject
. This typically works best ifserver.js
is refactored to export an asynchronousbuild
function that creates and returns the Fastify instance, rather than starting the server immediately. You would then import and call thisbuild
function in your test file. See Fastify's documentation on testing for details.
// test/webhook.test.js (Example structure) const { test } = require('tap'); // Assuming server.js is refactored to export a build function: // const build = require('../src/app'); // Adjust path if needed test('POST /webhook/infobip should process valid message', async (t) => { // const app = await build(); // Build app instance for testing // t.teardown(() => app.close()); // Close app after test // --- If NOT refactoring server.js, testing becomes more complex --- // You might need to require('../src/server.js') which starts the server, // then find a way to make requests to it (less ideal). // OR mock the Fastify instance and route registration. // Refactoring for testability is recommended. t.comment('Test assumes server.js refactored to export a build function'); t.end(); // Placeholder end for demonstration return; // Skip actual execution in this example /* // Example test logic (if app instance 'app' is available) const mockPayload = { results: [ { /* ... valid message data ... */ } ], // ... other payload fields }; const response = await app.inject({ method: 'POST', url: '/webhook/infobip', payload: mockPayload, // headers: { 'x-webhook-secret': 'your-test-secret' } // If testing secret }); t.equal(response.statusCode, 200, 'should return status 200'); t.same(JSON.parse(response.payload), { status: 'received' }, 'should return correct payload'); // Add more assertions: check logs (requires logger injection/mocking), check mocks */ }); // Add more tests for invalid payloads, security, errors, etc.
- Note on Test Setup: The example below uses Fastify's
-
Test Coverage: Use tools like
c8
or Istanbul (nyc
) to measure coverage.
Verification Checklist:
- Dependencies installed.
- Environment variables configured.
- Server starts.
- Webhook URL configured in Infobip.
- Security mechanism validated (if used).
- App publicly accessible.
- SMS triggers webhook.
- Logs show successful processing.
-
200 OK
sent to Infobip. - Reply received (if implemented).
- Infobip logs confirm delivery/sending.
- Invalid requests rejected correctly (400, 401).
- Errors logged and handled.
- Automated tests pass (if implemented).
Complete Code Repository
A complete, working example of the code described in this guide can be found on GitHub.
This repository includes the project structure, package.json
, example .env.example
, .gitignore
, and the source code files.
This guide provides a solid foundation for building a production-ready two-way SMS system using Fastify and Infobip. Remember to consult the official Infobip API documentation for the most up-to-date details on payload formats, authentication methods, and API capabilities. Happy coding!