code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

Implement Scheduled SMS Reminders with Node.js, Express, and Infobip

A guide to building a Node.js/Express application for scheduling and sending SMS reminders using the Infobip API, Prisma, and node-cron.

Automating communication is crucial for engaging users and streamlining operations. Sending timely reminders – for appointments, payments, or events – is a common requirement. This guide provides a complete walkthrough for building a production-ready system in Node.js and Express to schedule and send SMS reminders using the Infobip API and its Node.js SDK.

We will build an API endpoint to accept reminder requests and a background scheduler that checks for due reminders and dispatches them via Infobip SMS. This approach gives you full control over the scheduling logic within your application while leveraging Infobip's robust communication infrastructure.

Project Overview and Goals

Goal

To create a Node.js Express application that can:

  1. Accept API requests to schedule an SMS reminder for a specific phone number at a future time.
  2. Store these scheduled reminders persistently.
  3. Run a background job to periodically check for reminders that are due.
  4. Send the due reminders as SMS messages using the Infobip API.
  5. Provide basic logging, error handling, and configuration management.

Problem Solved

This system solves the challenge of reliably sending automated, time-sensitive SMS notifications without manual intervention, improving user engagement and reducing missed appointments or deadlines.

Technologies

  • Node.js: Asynchronous JavaScript runtime for building the backend.
  • Express: Minimalist web framework for Node.js to create the API layer.
  • Infobip Node.js SDK: To interact with the Infobip SMS API easily and reliably.
  • node-cron: Task scheduler library for running background jobs within Node.js.
  • PostgreSQL: Relational database for persistently storing reminder data.
  • Prisma: Next-generation ORM for Node.js and TypeScript, used for database schema management, migrations, and type-safe database access.
  • dotenv: For managing environment variables.

System Architecture

mermaid
graph LR
    A[Client/User] --> B{Express API Server};
    B -- POST /api/reminders --> C[Reminder Controller];
    C -- Save Reminder --> D[(PostgreSQL Database)];
    E[Node-Cron Scheduler] -- Runs Periodically --> F[Check Due Reminders Service];
    F -- Fetch Due Reminders --> D;
    F -- Send Reminder --> G[Infobip Service];
    G -- Use SDK --> H{Infobip SMS API};
    H -- Sends SMS --> I[End User Phone];
    subgraph Node.js Application
        B; C; E; F; G;
    end

Outcome

By the end of this guide, you will have a functional Express application with an API endpoint (POST /api/reminders) to schedule SMS messages and a background worker that sends these messages at the specified time via Infobip.

Prerequisites

  • Node.js (v18 or later recommended) and npm/yarn installed.
  • Access to a PostgreSQL database instance. (See Section 6 for notes on setup).
  • An active Infobip account (a free trial account works for testing - see Section 11 caveats).
  • Basic familiarity with Node.js, Express, APIs, and databases.

1. Setting up the project

Let's start by creating the project structure and installing necessary dependencies.

Environment Setup

Ensure Node.js and npm (or yarn) are installed. You can download Node.js from the official website. Verify the installation:

bash
node -v
npm -v

Project Initialization

  1. Create Project Directory:

    bash
    mkdir node-infobip-scheduler
    cd node-infobip-scheduler
  2. Initialize npm Project:

    bash
    npm init -y

    This creates a package.json file.

  3. Install Dependencies:

    bash
    # Core framework and utilities
    npm install express dotenv node-cron
    
    # Infobip SDK
    npm install @infobip-api/sdk
    
    # Prisma for database interaction
    npm install prisma --save-dev
    npm install @prisma/client
    
    # Input validation and phone number utility
    npm install express-validator libphonenumber-js
    
    # Optional: Logging library
    npm install winston
  4. Initialize Prisma:

    bash
    npx prisma init --datasource-provider postgresql

    This creates a prisma directory with a schema.prisma file and a .env file for database connection details.

Project Structure

Organize your project for maintainability:

text
node-infobip-scheduler/
├── prisma/
│   ├── schema.prisma         # Prisma schema definition
│   └── migrations/           # Database migrations (created later)
├── src/
│   ├── config/               # Configuration files (e.g., logger)
│   ├── controllers/          # API request handlers
│   ├── jobs/                 # Background job definitions (node-cron)
│   ├── middleware/           # Express middleware (e.g., auth, error handling)
│   ├── routes/               # API route definitions
│   ├── services/             # Business logic and external service interactions
│   ├── utils/                # Utility functions (e.g., validators)
│   └── index.js              # Main application entry point
├── .env                      # Environment variables (DATABASE_URL, Infobip keys)
├── package.json
├── package-lock.json
└── README.md

Configure Environment Variables (.env)

Prisma already added DATABASE_URL. Add placeholders for Infobip credentials and the server port. Replace placeholders later with actual values.

dotenv
# .env

# Database Connection (Prisma format)
# Example: postgresql://user:password@host:port/database?schema=public
DATABASE_URL=""postgresql://...""

# Server Configuration
PORT=3000

# Infobip Credentials
# Find these in your Infobip portal:
# INFOBIP_API_KEY: Go to Developers -> API Keys. Create/copy the key value.
INFOBIP_API_KEY=YOUR_INFOBIP_API_KEY
# INFOBIP_BASE_URL: Find on portal homepage or under Developers -> API Keys (e.g., youraccount.api.infobip.com).
INFOBIP_BASE_URL=YOUR_INFOBIP_BASE_URL

# Optional: A simple API key for securing your scheduling endpoint
# IMPORTANT: For production, use a strong, randomly generated key.
API_ACCESS_KEY=YOUR_SECRET_ACCESS_KEY

Purpose: Using .env keeps sensitive information like API keys and database URLs out of your codebase, adhering to security best practices. dotenv library loads these variables into process.env.

Basic Express Server Setup (src/index.js)

javascript
// src/index.js
require('dotenv').config(); // Load .env variables early
const express = require('express');
const reminderRoutes = require('./routes/reminderRoutes');
const { basicAuth } = require('./middleware/authMiddleware'); // NOTE: Defined in Section 3
const { errorHandler } = require('./middleware/errorMiddleware'); // NOTE: Defined in Section 5
const logger = require('./config/logger'); // NOTE: Defined in Section 5
const initializeScheduler = require('./jobs/reminderScheduler');

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware
app.use(express.json()); // Parse JSON request bodies

// Basic Logging Middleware (Example)
app.use((req, res, next) => {
  logger.info(`${req.method} ${req.url}`);
  next();
});

// Routes
// Apply basic authentication to reminder routes
app.use('/api/reminders', basicAuth, reminderRoutes);

// Health Check Endpoint
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
});

// Global Error Handler - Must be the last middleware applied AFTER routes
app.use(errorHandler);

// Start the server
app.listen(PORT, () => {
  logger.info(`Server running on port ${PORT}`);
  // Initialize and start the scheduler job
  initializeScheduler(); // NOTE: Defined in Section 2
  logger.info('Reminder scheduler initialized.');
});

module.exports = app; // Export for testing purposes

Why: This sets up a basic Express server, loads environment variables, includes JSON parsing, defines a simple health check, wires up routes (created later), includes middleware for auth and errors (defined later), and starts the server. Crucially, it also initializes the reminder scheduler job.

Note on Dependencies: Notice the require statements for logger, basicAuth, and errorHandler. The actual code for these modules will be created in later sections (Sections 5 and 3, respectively). If running incrementally, these lines would cause errors until those modules exist.

2. Implementing core functionality (Scheduler)

We'll use node-cron to periodically check for reminders that need to be sent.

Create the Scheduler Job (src/jobs/reminderScheduler.js)

javascript
// src/jobs/reminderScheduler.js
const cron = require('node-cron');
const { PrismaClient } = require('@prisma/client');
const infobipService = require('../services/infobipService'); // NOTE: Defined in Section 4
const logger = require('../config/logger'); // NOTE: Defined in Section 5

const prisma = new PrismaClient();

// Schedule to run every minute: '* * * * *'
// Adjust the cron schedule as needed (e.g., '*/5 * * * *' for every 5 minutes)
const cronSchedule = '* * * * *';

// Simple flag lock to prevent concurrent runs.
// Note: In high-concurrency or distributed environments, consider more robust distributed locking mechanisms (e.g., using Redis).
let taskRunning = false;

const checkAndSendReminders = async () => {
  if (taskRunning) {
    logger.warn('Reminder job already running, skipping this cycle.');
    return;
  }
  taskRunning = true;
  logger.info('Running scheduled reminder check...');

  try {
    const now = new Date();
    // Find pending reminders due now or in the past
    const dueReminders = await prisma.reminder.findMany({
      where: {
        sendAt: {
          lte: now, // Less than or equal to the current time (UTC)
        },
        status: 'PENDING', // Only send pending reminders
      },
      take: 100, // Process in batches to avoid overwhelming resources
      orderBy: {
        sendAt: 'asc', // Process older reminders first
      },
    });

    logger.info(`Found ${dueReminders.length} due reminders.`);

    if (dueReminders.length === 0) {
      taskRunning = false; // Release lock early if nothing to do
      return;
    }

    for (const reminder of dueReminders) {
      try {
        logger.info(`Attempting to send reminder ID: ${reminder.id} to ${reminder.phoneNumber}`);
        // Call the Infobip service (defined in Section 4)
        await infobipService.sendSms(reminder.phoneNumber, reminder.message);

        // Update status to SENT on success
        await prisma.reminder.update({
          where: { id: reminder.id },
          data: { status: 'SENT' },
        });
        logger.info(`Reminder ID: ${reminder.id} sent successfully.`);

      } catch (sendError) {
        logger.error(`Failed to send reminder ID: ${reminder.id}. Error: ${sendError.message}`, { error: sendError });
        // Update status to FAILED on error
        try {
          await prisma.reminder.update({
            where: { id: reminder.id },
            data: { status: 'FAILED' },
          });
        } catch (updateError) {
           logger.error(`Failed to update status for reminder ID: ${reminder.id} after send failure. Error: ${updateError.message}`, { error: updateError });
        }
      }
    }
  } catch (error) {
    logger.error(`Error during reminder check: ${error.message}`, { error: error });
  } finally {
    taskRunning = false; // Release the lock
    logger.info('Reminder check finished.');
  }
};

const initializeScheduler = () => {
  // Validate cron schedule format (basic check)
  if (!cron.validate(cronSchedule)) {
      logger.error(`Invalid cron schedule: ${cronSchedule}. Scheduler not started.`);
      return;
  }

  // Schedule the task
  cron.schedule(cronSchedule, checkAndSendReminders, {
      scheduled: true,
      timezone: ""UTC"" // Explicitly set timezone to UTC for consistency
  });

  logger.info(`Scheduler started with schedule: ${cronSchedule} in UTC timezone.`);

  // Optional: Run once immediately on startup for testing/quick processing
  // logger.info('Running initial reminder check on startup...');
  // checkAndSendReminders();
};

module.exports = initializeScheduler;

Why node-cron

It's a simple, widely used library for scheduling tasks within a Node.js process. Perfect for periodic jobs like checking for due reminders.

Logic

  • Runs on a defined schedule (e.g., every minute).
  • Uses a simple taskRunning flag as a mutex. Caveat: This simple flag might not be fully robust in edge cases like unhandled promise rejections before the flag is reset or in distributed environments. Consider Redis-based locks or similar for higher guarantees if needed.
  • Queries the database (using Prisma) for reminders where sendAt is in the past or present (UTC) and status is 'PENDING'.
  • Fetches reminders in batches (take: 100) to manage load.
  • Iterates through due reminders, calling the infobipService (defined later) to send the SMS.
  • Updates the reminder status to 'SENT' or 'FAILED' based on the outcome.
  • Includes error handling for both sending SMS and database updates.
  • Logs progress and errors (using logger, defined later).
  • Uses UTC for scheduling and querying to avoid timezone issues.

Integration

The initializeScheduler function is exported and called in src/index.js when the server starts.

3. Building a complete API layer (Scheduling Endpoint)

We need an API endpoint for clients to submit reminder requests.

Define Routes (src/routes/reminderRoutes.js)

javascript
// src/routes/reminderRoutes.js
const express = require('express');
const reminderController = require('../controllers/reminderController');
const { validateReminder } = require('../utils/validators'); // Validation middleware (defined below)

const router = express.Router();

// POST /api/reminders - Schedule a new reminder
// Applies validation middleware before hitting the controller
router.post('/', validateReminder, reminderController.scheduleReminder);

// Optional: GET /api/reminders/:id - Get status of a specific reminder
// router.get('/:id', reminderController.getReminderStatus);

module.exports = router;

Why: This file defines the specific HTTP methods and paths for reminder-related actions, linking them to controller functions and applying validation middleware.

Implement Controller (src/controllers/reminderController.js)

javascript
// src/controllers/reminderController.js
const { PrismaClient } = require('@prisma/client');
const { validationResult } = require('express-validator');
const logger = require('../config/logger'); // NOTE: Defined in Section 5
const { parsePhoneNumberWithError } = require('libphonenumber-js');

const prisma = new PrismaClient();

exports.scheduleReminder = async (req, res, next) => {
  // Check for validation errors defined in `validateReminder` middleware
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    logger.warn('Validation failed for scheduleReminder request.', { errors: errors.array() });
    return res.status(400).json({ errors: errors.array() });
  }

  const { phoneNumber, message, sendAt } = req.body;

  try {
    // Validate and format phone number to E.164 standard
    let formattedPhoneNumber;
    try {
        // `parsePhoneNumberWithError` attempts to parse the number.
        // It might use a default country (US) if the number isn't in international format.
        // For strict E.164 enforcement, consider checking `parsedNumber.country === undefined`
        // or pre-validating the input format more strictly if needed.
        const parsedNumber = parsePhoneNumberWithError(phoneNumber);
        if (!parsedNumber || !parsedNumber.isValid()) {
            throw new Error('Invalid phone number format or number.');
        }
        formattedPhoneNumber = parsedNumber.format('E.164'); // e.g., +14155552671
    } catch (phoneError) {
        logger.warn(`Invalid phone number provided: ${phoneNumber}`, { error: phoneError.message });
        return res.status(400).json({ errors: [{ msg: `Invalid phone number: ${phoneError.message}` }] });
    }

    // Validate sendAt is a valid future date (ISO 8601 format expected from validator)
    // Note: The validator `isISO8601` allows formats without timezone offset.
    // Ensure clients send UTC ('Z') or offset for clarity (See Section 8).
    const sendAtDate = new Date(sendAt); // Validator ensures `sendAt` is convertible
    // Check if date is valid AND in the future.
    // Note: `sendAtDate <= new Date()` prevents scheduling for the immediate present (within the same minute/second).
    // If same-minute scheduling is desired, adjust logic slightly (e.g., add a small buffer).
    if (isNaN(sendAtDate.getTime()) || sendAtDate <= new Date()) {
        logger.warn(`Invalid sendAt date provided: ${sendAt}. Must be in the future.`);
        return res.status(400).json({ errors: [{ msg: 'sendAt must be a valid ISO 8601 timestamp in the future.' }] });
    }


    // Save reminder to the database
    const newReminder = await prisma.reminder.create({
      data: {
        phoneNumber: formattedPhoneNumber,
        message: message,
        sendAt: sendAtDate, // Store as Date object (Prisma handles DB conversion to timestamp with timezone)
        status: 'PENDING', // Initial status
      },
    });

    logger.info(`Reminder scheduled successfully with ID: ${newReminder.id}`);
    res.status(201).json({
      message: 'Reminder scheduled successfully.',
      reminderId: newReminder.id,
      status: newReminder.status,
      scheduledAt: newReminder.createdAt, // ISO string format
      sendAt: newReminder.sendAt, // ISO string format
    });

  } catch (error) {
    logger.error(`Error scheduling reminder: ${error.message}`, { error });
    // Pass error to the global error handler middleware (defined in Section 5)
    next(error);
  }
};

// Optional: Implement getReminderStatus function if route is defined
// exports.getReminderStatus = async (req, res, next) => { ... };

Why: The controller handles the core logic for the API endpoint.

  • It uses express-validator results to check for bad input.
  • It uses libphonenumber-js to validate and format the phone number to E.164 standard, crucial for reliable international SMS sending.
  • It validates the sendAt timestamp, ensuring it's a valid date in the future. Clarified that scheduling in the immediate same minute might be prevented by the < new Date() check.
  • It interacts with the database (via Prisma) to create a new reminder record with a 'PENDING' status.
  • It returns a success response with the ID of the newly created reminder.
  • It passes any unexpected errors to the central error handling middleware (next(error)).

Implement Basic Authentication Middleware (src/middleware/authMiddleware.js)

javascript
// src/middleware/authMiddleware.js
const logger = require('../config/logger'); // NOTE: Defined in Section 5

// WARNING: Basic API Key authentication is simple but NOT recommended for
// production environments handling sensitive data or multiple tenants.
// Consider more robust methods like JWT, OAuth2, or platform-specific auth.
const basicAuth = (req, res, next) => {
  const providedKey = req.headers['x-api-key'];
  const expectedKey = process.env.API_ACCESS_KEY;

  // Check if the server has an API key configured
  if (!expectedKey) {
    logger.error('CRITICAL: API_ACCESS_KEY is not set in environment variables. Denying all requests to protected endpoints.');
    // Return 500 as this is a server configuration issue
    return res.status(500).json({ message: 'Server configuration error.' });
  }

  // Check if the client provided the correct key
  if (!providedKey || providedKey !== expectedKey) {
    logger.warn('Unauthorized access attempt: Invalid or missing API key.');
    return res.status(401).json({ message: 'Unauthorized: Invalid API Key.' });
  }

  // Key is valid, proceed to the next middleware or route handler
  next();
};

module.exports = { basicAuth };

Why: Protects the scheduling endpoint from unauthorized access. This is a very basic example using a static API key passed in a header (x-api-key). Added a warning recommending stronger methods for production.

Implement Request Validation (src/utils/validators.js)

javascript
// src/utils/validators.js
const { body } = require('express-validator');

const validateReminder = [
  body('phoneNumber')
    .trim()
    .notEmpty().withMessage('phoneNumber is required.')
    .isString().withMessage('phoneNumber must be a string.')
    // More thorough validation (E.164 format) is done in the controller using libphonenumber-js
    .isLength({ min: 8, max: 20 }).withMessage('phoneNumber has invalid length.'), // Basic length check

  body('message')
    .trim()
    .notEmpty().withMessage('message is required.')
    .isString().withMessage('message must be a string.')
    // Max 1600 chars allows for ~10 concatenated standard SMS messages. Adjust as needed.
    .isLength({ min: 1, max: 1600 }).withMessage('message must be between 1 and 1600 characters.')
    // Consider adding .escape() if message content might be rendered in HTML later,
    // but not strictly needed for sending via SMS API.
    ,

  body('sendAt')
    .notEmpty().withMessage('sendAt is required.')
    // Validates ISO 8601 format (e.g., ""2025-12-31T10:00:00.000Z"", ""2025-12-31T05:00:00-05:00"")
    // Note: It might accept formats without timezone - enforce 'Z' or offset in client requests (See Section 8).
    .isISO8601().withMessage('sendAt must be a valid ISO 8601 timestamp (UTC recommended, e.g., YYYY-MM-DDTHH:mm:ss.sssZ).')
    // .toDate() // Convert string to Date object for controller validation (already done by `new Date()` in controller)
    ,
];

module.exports = { validateReminder };

Why: Uses express-validator to define rules for incoming request bodies. Ensures required fields are present, have the correct types, and meet basic constraints (like message length). This prevents invalid data from reaching the controller logic.

API Endpoint Documentation & Testing

You can test the POST /api/reminders endpoint using curl or Postman.

curl Example:

Replace placeholders with your actual values.

bash
curl -X POST http://localhost:3000/api/reminders \
  -H ""Content-Type: application/json"" \
  -H ""x-api-key: YOUR_SECRET_ACCESS_KEY"" \
  -d '{
    ""phoneNumber"": ""+14155551234"",
    ""message"": ""Your appointment is tomorrow at 10 AM."",
    ""sendAt"": ""2025-04-21T10:00:00.000Z""
  }'

Request Body (JSON):

json
{
  ""phoneNumber"": ""+14155551234"",
  ""message"": ""Your appointment is tomorrow at 10 AM."",
  ""sendAt"": ""2025-04-21T10:00:00.000Z""
}

Success Response (JSON):

json
{
    ""message"": ""Reminder scheduled successfully."",
    ""reminderId"": ""clv..."",
    ""status"": ""PENDING"",
    ""scheduledAt"": ""2025-04-20T15:30:00.123Z"",
    ""sendAt"": ""2025-04-21T10:00:00.000Z""
}

Error Response (JSON - Validation Example):

json
{
    ""errors"": [
        {
            ""type"": ""field"",
            ""value"": ""invalid-date"",
            ""msg"": ""sendAt must be a valid ISO 8601 timestamp (UTC recommended, e.g., YYYY-MM-DDTHH:mm:ss.sssZ)."",
            ""path"": ""sendAt"",
            ""location"": ""body""
        }
    ]
}

4. Integrating with Infobip

Now, let's implement the service that uses the Infobip Node.js SDK to send SMS messages.

Create Infobip Service (src/services/infobipService.js)

javascript
// src/services/infobipService.js
const { Infobip, AuthType } = require('@infobip-api/sdk');
const logger = require('../config/logger'); // NOTE: Defined in Section 5

// Ensure required environment variables are set at startup
if (!process.env.INFOBIP_BASE_URL || !process.env.INFOBIP_API_KEY) {
  logger.error('CRITICAL: Infobip environment variables (INFOBIP_BASE_URL, INFOBIP_API_KEY) are not set.');
  // Throw an error to prevent the app from starting without essential config
  throw new Error('Missing Infobip configuration in environment variables.');
}

// Instantiate the Infobip client
let infobipClient;
try {
    infobipClient = new Infobip({
      baseUrl: process.env.INFOBIP_BASE_URL,
      apiKey: process.env.INFOBIP_API_KEY,
      authType: AuthType.ApiKey, // Specify API Key authentication
    });
    logger.info('Infobip client initialized successfully.');
} catch (error) {
    logger.error(`FATAL: Failed to initialize Infobip client: ${error.message}`, { error });
    // Prevent function calls if client failed to init. Consider exiting process.
    infobipClient = null;
    // throw new Error('Failed to initialize Infobip client.'); // Optionally re-throw
}


/**
 * Sends an SMS message using the Infobip API.
 * @param {string} toPhoneNumber - The recipient phone number in E.164 format.
 * @param {string} messageText - The text content of the SMS.
 * @throws {Error} If the SMS sending fails or client is not initialized.
 */
const sendSms = async (toPhoneNumber, messageText) => {
  if (!infobipClient) {
      logger.error('Infobip client is not initialized. Cannot send SMS.');
      throw new Error('Infobip client is not initialized.');
  }

  // Basic validation before sending
  if (!toPhoneNumber || !messageText) {
      logger.error('Missing recipient phone number or message text in sendSms call.');
      throw new Error('Missing recipient phone number or message text.');
  }

  // Construct the request payload for Infobip Send SMS API
  const payload = {
    messages: [
      {
        destinations: [{ to: toPhoneNumber }],
        // `from` (Sender ID): Optional. Can be alphanumeric (e.g., 'YourBrand') or numeric.
        // Alphanumeric IDs often require registration with Infobip/carriers and may not work in all countries.
        // Using a specific purchased number (Long Code, Short Code) provides better deliverability and enables replies.
        // If omitted, Infobip uses a default shared number pool (deliverability might vary).
        // from: 'YourBrand', // Example: requires setup
        text: messageText,
      },
    ],
    // Optional: Add other parameters like validityPeriod, notifyUrl (for delivery reports), etc.
  };

  try {
    logger.info(`Sending SMS via Infobip to ${toPhoneNumber}. Message length: ${messageText.length}`);
    const response = await infobipClient.channels.sms.send(payload);

    // Log success and relevant details from the response
    const messageResponse = response.data?.messages?.[0];
    if (messageResponse) {
        logger.info(`Infobip SMS submitted successfully. To: ${toPhoneNumber}, Message ID: ${messageResponse.messageId}, Status Group: ${messageResponse.status?.groupName} (${messageResponse.status?.description})`);
    } else {
        // This case indicates an unexpected response structure from Infobip SDK/API
        logger.warn(`Infobip response structure unexpected for SMS to ${toPhoneNumber}.`, { responseData: response.data });
    }

    // Check for potential non-error issues indicated by status group ID
    // Group ID 1 (PENDING) or 3 (DELIVERED) are generally OK initially.
    // Group ID 2 (UNDELIVERABLE), 4 (EXPIRED), 5 (REJECTED) indicate problems.
    if (messageResponse?.status?.groupId && ![1, 3].includes(messageResponse.status.groupId)) {
         logger.warn(`Infobip reported non-successful status group for SMS to ${toPhoneNumber}: ${messageResponse.status.groupName} - ${messageResponse.status.description} (ID: ${messageResponse.status.id})`);
         // Decide if this should be treated as an error for retry/failure purposes.
         // For now, we log it but don't throw, as the API call itself succeeded (HTTP 2xx).
         // Consider configuring Delivery Report webhooks for definitive final status.
         // Example: throw new Error(`Infobip message rejected/undeliverable: ${messageResponse.status.description}`);
    }

    return response.data; // Return the full response data

  } catch (error) {
    // Log detailed error information. Be cautious about logging full `error.response?.data`
    // in production as it might contain sensitive details depending on the error.
    const errorDetails = error.response?.data || error.message;
    logger.error(`Infobip API error sending SMS to ${toPhoneNumber}: ${error.message}`, {
      error: errorDetails, // Log specific API error response if available
      status: error.response?.status,
      // Log non-sensitive parts of the payload for debugging
      payloadSummary: {
          to: toPhoneNumber, // E.164 format number
          messageLength: messageText.length,
          senderIdUsed: payload.messages[0].from || 'Default', // Check if 'from' was set
      }
    });

    // Re-throw the error to be caught by the caller (e.g., the scheduler job)
    // This allows the caller to handle the failure (e.g., mark reminder as FAILED).
    throw new Error(`Failed to send SMS via Infobip: ${error.message}`);
  }
};

module.exports = { sendSms };

Why: This service encapsulates all interaction with the Infobip SDK.

  • Initializes the Infobip client using baseUrl and apiKey from environment variables. Added error handling for missing variables and client initialization failure.
  • Uses AuthType.ApiKey.
  • The sendSms function constructs the payload. Added clarification on the optional from field (Sender ID) and its implications regarding registration, cost, and deliverability.
  • Includes robust logging for success and failure. Logs the Infobip messageId and status group.
  • Checks the response status group ID for potential issues beyond HTTP errors.
  • Modified error logging to log error.response?.data but added a comment warning about potentially sensitive information. Also logs non-sensitive payload parts.
  • Re-throws errors for the caller (scheduler job) to handle.

Fallback Strategy

If you need higher resilience, you could implement a fallback mechanism. In the catch block of checkAndSendReminders (Section 2), instead of immediately marking as FAILED, you could call another function (e.g., alternativeSmsService.sendSms). This would require:

  1. Setting up another SMS provider account and credentials.
  2. Creating a similar service file (src/services/alternativeSmsService.js).
  3. Adding logic to decide when to use the fallback (e.g., after Infobip fails N times).

5. Implementing error handling, logging, and retry mechanisms

Robust error handling and logging are essential for production systems.

Consistent Error Handling Strategy

  • Validation Errors: Handled specifically in controllers (using express-validator) returning 400 status code.
  • Authentication Errors: Handled by authMiddleware, returning 401.
  • Operational Errors (Expected): Handled within services or jobs (e.g., Infobip API errors, DB connection issues). Logged appropriately, potentially retried (see below), and may result in updating DB status (e.g., FAILED). Handled by the caller or allowed to propagate.
  • Programmer Errors (Unexpected): Caught by the global error handler (errorHandler), logged as critical errors, and return a generic 500 status code to the client.

Global Error Handling Middleware (src/middleware/errorMiddleware.js)

javascript
// src/middleware/errorMiddleware.js
const logger = require('../config/logger'); // NOTE: Defined below in this section

// This middleware MUST be the last `app.use()` call, after all routes.
// It catches errors passed via `next(error)` or thrown synchronously in route handlers.
// eslint-disable-next-line no-unused-vars
const errorHandler = (err, req, res, next) => {
  // Log the full error details for debugging purposes
  // Sentry or similar services can capture this automatically if integrated.
  logger.error('Unhandled error caught by global error handler:', {
    // Standard Error properties
    message: err.message,
    stack: err.stack, // Include stack trace for debugging
    // Additional context (optional but helpful)
    method: req.method,
    url: req.originalUrl,
    ip: req.ip,
    // You might add more context like user ID if available
  });

  // Avoid sending detailed error messages/stack traces to the client in production
  // Check if the response has already been sent (e.g., by another middleware)
  if (res.headersSent) {
    return next(err); // Delegate to default Express error handler if headers sent
  }

  // Send a generic error response
  res.status(500).json({
    message: 'Internal Server Error. Please try again later.',
    // Optionally include an error ID for correlation with logs
    // errorId: generateUniqueId()
  });
};

module.exports = { errorHandler };

(Note: The rest of Section 5, including Logger Configuration and Retry Mechanisms, was missing from the original provided text. This rewrite includes the completed errorHandler based on the structure.)

Frequently Asked Questions

How to schedule SMS reminders with Node.js?

Use Node.js with Express, the Infobip API, and node-cron to build an SMS reminder system. Create an API endpoint to handle reminder requests and a scheduler to check for due reminders and dispatch them via Infobip.

What is the Infobip Node.js SDK used for?

The Infobip Node.js SDK simplifies interaction with the Infobip SMS API, allowing you to easily send SMS messages from your Node.js application. It handles authentication, request formatting, and response parsing.

Why does this project use Prisma?

Prisma is a next-generation ORM that streamlines database operations in Node.js. It provides type safety, schema migrations, and an intuitive API for querying and updating the PostgreSQL database where reminder information is stored.

When should I use a distributed locking mechanism?

If your reminder system operates in a high-concurrency or distributed environment, a more robust locking mechanism like Redis is recommended to prevent race conditions when multiple scheduler instances might run concurrently.

Can I use a different database with this system?

While the article uses PostgreSQL, you could adapt the system to use other databases by configuring the appropriate Prisma data source and adjusting database interaction code in the scheduler and controller.

How to set up Infobip API credentials?

You'll need an Infobip account and an API key. Obtain these from the Infobip portal under Developers -> API Keys, then store them securely as environment variables (INFOBIP_API_KEY and INFOBIP_BASE_URL).

What is node-cron used for in this project?

Node-cron is a task scheduling library that enables periodic execution of functions within your Node.js application. It is used to trigger the check for due reminders at defined intervals.

How to send SMS messages with Infobip API?

The project's Infobip service constructs a payload with recipient number and message text, then uses the Infobip Node.js SDK's send() method to make the API call, handling successful submissions or potential errors.

How to secure the reminder scheduling endpoint?

The example uses a basic API key for authentication. However, for production, stronger methods like JWT or OAuth2 are recommended. Implement appropriate authentication middleware to protect the endpoint.

What is the E.164 phone number format?

E.164 is an international standard for phone number formatting. It includes a '+' sign followed by the country code and national number without spaces or punctuation, ensuring consistency for global SMS delivery.

What's the cron schedule format for node-cron?

Node-cron uses a cron expression format (e.g., '* * * * *' for every minute, or '*/5 * * * *' for every 5 minutes). See the node-cron documentation for advanced scheduling patterns.

How to implement SMS reminder retry mechanism?

Implement a retry mechanism in the scheduler job’s catch block. If sending via the primary SMS service fails, store retry attempts in the database and re-attempt sending up to a defined limit.

How to handle SMS sending failures?

The scheduler job handles failures by updating the reminder status in the database to 'FAILED'. Detailed error information is logged for debugging. Consider a fallback strategy or retry mechanism to improve reliability.

What are the prerequisites for setting up SMS reminder?

You need Node.js v18+, access to a PostgreSQL database, an active Infobip account with API credentials, and familiarity with Node.js, Express, and APIs.