code examples

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

Node.js Express Scheduling & Reminders Using Infobip

A guide to building a Node.js Express application for scheduling and sending SMS reminders via the Infobip API, covering setup, database, scheduling, and API integration.

This guide provides a step-by-step walkthrough for building a production-ready Node.js application using the Express framework to schedule and send SMS reminders via the Infobip API. We'll cover everything from project setup and database integration to scheduling logic, error handling, security, and deployment.

Project Goal: To create a backend service that allows users (or other systems) to schedule SMS reminders (e.g., appointments, payment due dates) which are automatically sent at the specified time using Infobip.

Problem Solved: Automates the process of sending timely SMS notifications, improving communication and reducing manual effort. Handles scheduling complexity, API integration, and ensures reliable delivery.

Technologies Used:

  • Node.js: JavaScript runtime for building the backend server.
  • Express: Minimalist web framework for Node.js, used for creating the API layer.
  • Infobip API & Node.js SDK: Cloud communications platform for sending SMS messages. We'll use their official SDK (@infobip-api/sdk).
  • PostgreSQL: Robust relational database for storing reminder data.
  • Prisma: Next-generation ORM for Node.js and TypeScript (we'll use JavaScript) to interact with the database.
  • node-cron: Task scheduler for Node.js to trigger reminder checks periodically.
  • dotenv: Module to load environment variables from a .env file.
  • libphonenumber-js: Library for parsing, formatting, and validating phone numbers.
  • (Optional but Recommended) winston (for logging), express-validator (for validation), express-rate-limit (for security), helmet (security headers).

(See package.json in Section 1 for example dependency versions used in this guide.)

System Architecture:

mermaid
graph LR
    A[Client/API Consumer] -- HTTP Request --> B(Express API Layer);
    B -- Create Reminder --> C{Database (PostgreSQL w/ Prisma)};
    D[node-cron Scheduler] -- Runs Periodically --> E{Reminder Check Service};
    E -- Fetch Due Reminders --> C;
    E -- Send SMS Request --> F(Infobip Service);
    F -- Uses SDK --> G[Infobip SMS API];
    G -- Sends SMS --> H(End User's Phone);
    B -- API Response --> A;
    E -- Update Status --> C;
    subgraph Node.js Application
        B
        D
        E
        F
    end

(Note: Rendering requires Markdown platform support for Mermaid diagrams.)

Prerequisites:

  • Node.js (v16 or later recommended) and npm/yarn installed.
  • Access to a PostgreSQL database instance (local or cloud-based).
  • An Infobip account (a free trial account works, but note limitations).
  • Basic familiarity with JavaScript, Node.js, Express, and REST APIs.
  • A text editor or IDE (like VS Code).
  • Terminal/Command Prompt access.

Final Outcome: A running Node.js service with an API endpoint to schedule reminders and a background job that automatically sends these reminders via Infobip SMS at the correct time.


1. Setting up the project

Let's initialize our Node.js project and install the necessary dependencies.

  1. Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.

    bash
    mkdir infobip-reminder-app
    cd infobip-reminder-app
  2. Initialize Node.js Project: This creates a package.json file.

    bash
    npm init -y
  3. Install Dependencies: We need Express for the server, the Infobip SDK, Prisma for database interaction, pg as the PostgreSQL driver, node-cron for scheduling, dotenv for environment variables, and libphonenumber-js for phone number validation.

    bash
    npm install express @infobip-api/sdk pg node-cron dotenv libphonenumber-js
    npm install --save-dev prisma
    • express: Web framework.
    • @infobip-api/sdk: Official SDK for interacting with Infobip APIs.
    • pg: Node.js driver for PostgreSQL (required by Prisma).
    • node-cron: Job scheduler.
    • dotenv: Loads environment variables from .env file.
    • libphonenumber-js: Phone number parsing/validation/formatting.
    • prisma: Development dependency for Prisma CLI commands (schema management, migrations).
  4. Initialize Prisma: This creates a prisma directory with a schema.prisma file and a .env file (if one doesn't exist).

    bash
    npx prisma init --datasource-provider postgresql
  5. Configure Database Connection (.env): Open the .env file created by Prisma (or create one if it wasn't). Update the DATABASE_URL variable with your PostgreSQL connection string.

    dotenv
    # .env
    
    # PostgreSQL connection string
    # Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
    DATABASE_URL="postgresql://your_db_user:your_db_password@localhost:5432/reminder_db?schema=public"
    
    # Infobip Credentials (Obtain from Infobip Dashboard)
    INFOBIP_API_KEY="YOUR_INFOBIP_API_KEY"
    INFOBIP_BASE_URL="YOUR_INFOBIP_BASE_URL" # e.g., xyz.api.infobip.com
    INFOBIP_SENDER_ID="InfoSMS" # Or your registered Sender ID
    
    # Application Port
    PORT=3000
    
    # Node environment (development, production) - affects error handling, logging
    NODE_ENV=development
    
    # Optional: Log level for Winston
    # LOG_LEVEL=debug
    • DATABASE_URL: Replace placeholders with your actual database user, password, host, port, and database name. Ensure the database (reminder_db in the example) exists. Use quotes if your password contains special characters.
    • INFOBIP_API_KEY, INFOBIP_BASE_URL, INFOBIP_SENDER_ID: We'll get these from Infobip later (Section 3). Leave them blank for now or use placeholders.
    • PORT: The port your Express server will listen on.
    • NODE_ENV: Set to production in deployment environments.
  6. Define Project Structure: Create the following directories and files for organization:

    text
    infobip-reminder-app/
    ├── prisma/
    │   ├── schema.prisma
    │   └── migrations/ (will be created later)
    ├── src/
    │   ├── config/
    │   │   ├── infobip.js       # Infobip SDK client setup
    │   │   ├── logger.js        # Logger configuration
    │   │   └── prismaClient.js  # Prisma client instance
    │   ├── controllers/
    │   │   └── reminderController.js # API request handling
    │   ├── jobs/
    │   │   └── reminderScheduler.js # Cron job logic
    │   ├── routes/
    │   │   └── reminderRoutes.js   # API routes definition
    │   ├── services/
    │   │   ├── reminderService.js  # Core logic for reminders & DB interaction
    │   │   └── smsService.js       # Logic for sending SMS via Infobip
    │   ├── app.js             # Express application setup
    │   └── server.js          # Server entry point
    ├── .env
    ├── .gitignore
    ├── package.json
    └── package-lock.json
    • Why this structure? It separates concerns (routes, controllers, services, configuration, jobs), making the application easier to understand, maintain, and test.
  7. Create Basic Express Server (src/app.js):

    javascript
    // src/app.js
    const express = require('express');
    const reminderRoutes = require('./routes/reminderRoutes');
    const logger = require('./config/logger'); // Import the logger
    
    const app = express();
    
    // Middleware to parse JSON bodies
    app.use(express.json());
    
    // Mount reminder routes
    app.use('/api/reminders', reminderRoutes);
    
    // Basic health check endpoint
    app.get('/health', (req, res) => {
      res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
    });
    
    // Global error handler - MUST be defined LAST after all routes/middleware
    // eslint-disable-next-line no-unused-vars
    app.use((err, req, res, next) => {
      logger.error('Unhandled error:', {
        message: err.message,
        stack: err.stack, // Log the full stack trace
        url: req.originalUrl,
        method: req.method,
        ip: req.ip,
      });
    
      // Determine status code - default to 500 if not set
      const statusCode = err.statusCode || 500;
    
      // Send a standardized error response
      res.status(statusCode).json({
        status: 'error',
        statusCode: statusCode,
        message: err.message || 'An unexpected error occurred.',
        // Only include stack trace in development environment for security
        stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
      });
    });
    
    
    module.exports = app;
  8. Create Server Entry Point (src/server.js):

    javascript
    // src/server.js
    require('dotenv').config(); // Load environment variables early
    const app = require('./app');
    const logger = require('./config/logger');
    const { startScheduler } = require('./jobs/reminderScheduler');
    
    const PORT = process.env.PORT || 3000;
    
    app.listen(PORT, () => {
      logger.info(`Server running on port ${PORT} in ${process.env.NODE_ENV || 'development'} mode`);
      // Start the cron job scheduler after the server starts
      startScheduler();
      logger.info('Reminder scheduler started.');
    });
  9. Add Start Script (package.json): Add scripts to easily run your server and tests.

    json
    // package.json (update the scripts and dependencies/devDependencies sections)
    {
      "name": "infobip-reminder-app",
      "version": "1.0.0",
      "description": "SMS Reminder App using Infobip",
      "main": "src/server.js",
      "scripts": {
        "start": "node src/server.js",
        "dev": "nodemon src/server.js",
        "test": "jest",
        "prisma:migrate:dev": "npx prisma migrate dev",
        "prisma:migrate:deploy": "npx prisma migrate deploy",
        "prisma:generate": "npx prisma generate"
      },
      "keywords": [
        "infobip",
        "sms",
        "reminder",
        "node",
        "express",
        "prisma"
      ],
      "author": "",
      "license": "ISC",
      "dependencies": {
        "@infobip-api/sdk": "^3.0.0",
        "dotenv": "^16.0.0",
        "express": "^4.18.0",
        "libphonenumber-js": "^1.10.0",
        "node-cron": "^3.0.0",
        "pg": "^8.8.0"
        // Optional: "express-validator", "winston", "express-rate-limit", "helmet", "async-retry"
      },
      "devDependencies": {
        "prisma": "^5.0.0"
        // Optional: "nodemon", "jest", "@types/jest", "jest-mock-extended", "supertest"
      }
    }
    • (Note: The dependency versions listed (e.g., ^3.0.0) are examples at the time of writing. You should use the versions installed by npm install or check for the latest compatible versions.)
    • You can run the server using npm start. If you install nodemon (npm install --save-dev nodemon), you can use npm run dev for automatic restarts during development.
    • npm test will run Jest tests (requires Jest setup, see Section 13).
    • Added Prisma helper scripts.
  10. Setup .gitignore: Create a .gitignore file in the root directory to avoid committing sensitive files and unnecessary directories.

    text
    # .gitignore
    
    # Dependencies
    /node_modules
    
    # Environment variables
    .env*
    !.env.example
    
    # Prisma generated files / local DB
    /prisma/dev.db*
    /prisma/client
    
    # Logs
    /logs/
    *.log
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    
    # OS generated files
    .DS_Store
    Thumbs.db
    
    # Build outputs
    /dist

2. Database Schema and Data Layer (Prisma)

We'll define the database schema for our reminders and set up Prisma Client to interact with it.

  1. Define Prisma Schema (prisma/schema.prisma): Open the prisma/schema.prisma file and define the Reminder model.

    prisma
    // prisma/schema.prisma
    
    // This is your Prisma schema file,
    // learn more about it in the docs: https://pris.ly/d/prisma-schema
    
    generator client {
      provider = ""prisma-client-js""
      // output   = ""./client"" // Example: Customize client output location if needed
    }
    
    datasource db {
      provider = ""postgresql""
      url      = env(""DATABASE_URL"")
    }
    
    model Reminder {
      id          String   @id @default(cuid()) // Unique identifier
      phoneNumber String   // E.164 format REQUIRED (e.g., +14155552671)
      message     String   @db.Text // Use Text for potentially long messages
      sendAt      DateTime // The exact time (UTC) to send the reminder
      status      Status   @default(PENDING) // Status of the reminder
      createdAt   DateTime @default(now())   // When the reminder was created
      updatedAt   DateTime @updatedAt       // When the reminder was last updated
      infobipMessageId String? // Store Infobip's message ID after sending
      errorMessage String?  @db.Text // Store error message if sending fails
    
      // Index for the scheduler query
      @@index([status, sendAt])
    }
    
    enum Status {
      PENDING    // Reminder is scheduled but not yet sent
      PROCESSING // Reminder is currently being processed by the scheduler
      SENT       // Reminder has been successfully sent via Infobip
      ERROR      // An error occurred trying to send the reminder
      // Add other statuses if needed (e.g., FAILED_DELIVERY from DLR)
    }
    • id: Unique CUID for each reminder.
    • phoneNumber: Recipient's number. Stored in E.164 format (enforced by service).
    • message: Text content of the SMS (using @db.Text for flexibility).
    • sendAt: The crucial field storing the scheduled time in UTC.
    • status: Tracks the lifecycle of the reminder using an enum. PROCESSING helps prevent double-sending.
    • infobipMessageId: Useful for tracking and debugging with Infobip.
    • errorMessage: Stores failure reasons.
    • @@index([status, sendAt]): Adds a database index to optimize queries for finding due reminders.
  2. Create Database Migration: Run the following command to create the SQL migration files based on your schema changes and apply them to your database. Prisma will prompt you to name the migration (e.g., init_reminder_model).

    bash
    npx prisma migrate dev --name init_reminder_model
    • This command does three things:
      1. Creates a new SQL migration file in prisma/migrations/.
      2. Applies the migration to your database, creating the Reminder table and Status enum.
      3. Generates/updates Prisma Client based on your schema.
  3. Initialize Prisma Client: Create an instance of Prisma Client to use throughout your application.

    javascript
    // src/config/prismaClient.js (Create this new file)
    const { PrismaClient } = require('@prisma/client');
    
    // Recommended: Add logging for Prisma operations in development
    const prisma = new PrismaClient({
        log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['error'],
    });
    
    module.exports = prisma;
  4. Implement Data Access Logic (src/services/reminderService.js): This service will handle creating reminders and finding ones that are due.

    javascript
    // src/services/reminderService.js
    const prisma = require('../config/prismaClient');
    const logger = require('../config/logger');
    const { Status } = require('@prisma/client'); // Import the enum
    
    /**
     * Creates a new reminder in the database.
     * Assumes phoneNumber is already validated and normalized to E.164 format.
     * @param {string} phoneNumber - Recipient phone number (MUST be E.164 format).
     * @param {string} message - SMS message content.
     * @param {Date} sendAt - Date object (UTC) when the reminder should be sent.
     * @returns {Promise<object>} The created reminder object.
     * @throws {Error} If required fields are missing, sendAt is invalid/past, or phoneNumber format is incorrect.
     */
    const createReminder = async (phoneNumber, message, sendAt) => {
      if (!phoneNumber || !message || !sendAt) {
        throw new Error('Phone number, message, and sendAt time are required.');
      }
      if (!(sendAt instanceof Date) || isNaN(sendAt)) {
          throw new Error('sendAt must be a valid Date object.');
      }
      // Ensure sendAt is in the future (allow a small buffer for processing)
      if (sendAt <= new Date(Date.now() - 5000)) { // Allow dates slightly in the past by a few seconds
          throw new Error('sendAt time must be in the future.');
      }
      // Service layer enforces E.164 format strictly. Controller should normalize before calling.
      if (!/^\+[1-9]\d{1,14}$/.test(phoneNumber)) {
          logger.error(`Invalid E.164 phone number format received by service: ${phoneNumber}`);
          throw new Error('Phone number must be in E.164 format (e.g., +14155552671)');
      }
    
      try {
          const reminder = await prisma.reminder.create({
              data: {
                  phoneNumber,
                  message,
                  sendAt: sendAt.toISOString(), // Store as ISO string (Prisma handles Date objects too)
                  status: Status.PENDING,
              },
          });
          logger.info(`Reminder created with ID: ${reminder.id} for ${phoneNumber}`);
          return reminder;
      } catch (error) {
          logger.error(`Error creating reminder in DB for ${phoneNumber}: ${error.message}`, { error });
          // Handle potential Prisma errors (e.g., unique constraint if applicable)
          if (error.code === 'P2002') { // Example: Prisma unique constraint violation code
              throw new Error('A reminder with these details might already exist.');
          }
          throw new Error('Database error occurred while creating reminder.'); // Generic error
      }
    };
    
    /**
     * Finds pending reminders that are due to be sent.
     * Selects reminders where sendAt is past or within the next minute
     * and marks them as PROCESSING to prevent double sending.
     * @returns {Promise<Array<object>>} An array of due reminder objects marked for processing.
     */
    const findAndMarkDueReminders = async () => {
        const now = new Date();
        // Look slightly ahead to catch reminders exactly on the minute boundary
        const lookAheadTime = new Date(now.getTime() + 60 * 1000); // 1 minute ahead
    
        // 1. Find PENDING reminders that are due (using index [status, sendAt])
        const dueReminders = await prisma.reminder.findMany({
            where: {
                status: Status.PENDING,
                sendAt: {
                    lte: lookAheadTime, // Less than or equal to the look-ahead time
                },
            },
            take: 100, // Limit batch size to avoid overwhelming resources
            orderBy: {
                sendAt: 'asc', // Process older ones first
            },
        });
    
        if (dueReminders.length === 0) {
            return [];
        }
    
        const reminderIds = dueReminders.map(r => r.id);
        logger.info(`Found ${dueReminders.length} potential reminders to process. IDs: ${reminderIds.join(', ')}`);
    
        // 2. Attempt to mark them as PROCESSING atomically
        // This helps prevent multiple scheduler instances from picking up the same job
        try {
            const updateResult = await prisma.reminder.updateMany({
                where: {
                    id: {
                        in: reminderIds,
                    },
                    status: Status.PENDING, // Ensure it's still PENDING before updating
                },
                data: {
                    status: Status.PROCESSING,
                    updatedAt: new Date(), // Explicitly update timestamp
                },
            });
    
            // 3. If the update succeeded for some/all reminders, fetch the ones marked PROCESSING
            if (updateResult.count > 0) {
                 const processingReminders = await prisma.reminder.findMany({
                    where: {
                        id: { in: reminderIds },
                        status: Status.PROCESSING, // Fetch only those successfully marked
                    },
                });
                logger.info(`Successfully marked ${processingReminders.length} reminders as PROCESSING.`);
                return processingReminders;
            } else {
                logger.warn('Found due reminders, but failed to mark them as PROCESSING (possibly processed by another instance or status changed).');
                return [];
            }
        } catch (error) {
            logger.error(`Error marking reminders as PROCESSING: ${error.message}`, { error, reminderIds });
            return []; // Return empty on error to prevent processing potentially unmarked reminders
        }
    };
    
    
    /**
     * Updates the status of a reminder after processing.
     * @param {string} reminderId - The ID of the reminder to update.
     * @param {Status} status - The new status (SENT or ERROR).
     * @param {string} [infobipMessageId] - Optional Infobip message ID if sent successfully.
     * @param {string} [errorMessage] - Optional error message if sending failed.
     * @returns {Promise<object>} The updated reminder object.
     */
    const updateReminderStatus = async (reminderId, status, infobipMessageId = null, errorMessage = null) => {
      if (!reminderId || !status) {
        throw new Error('Reminder ID and status are required for update.');
      }
      if (![Status.SENT, Status.ERROR].includes(status)) {
          throw new Error(`Invalid final status: ${status}. Must be SENT or ERROR.`);
      }
    
      try {
          const updatedReminder = await prisma.reminder.update({
              where: { id: reminderId },
              data: {
                  status,
                  infobipMessageId: infobipMessageId,
                  // Truncate error message if it's too long for the DB field (if TEXT isn't used)
                  errorMessage: errorMessage ? errorMessage.substring(0, 1000) : null,
                  updatedAt: new Date(), // Explicitly set updatedAt
              },
          });
          logger.info(`Updated status for reminder ${reminderId} to ${status}.`);
          return updatedReminder;
      } catch (error) {
          logger.error(`Error updating status for reminder ${reminderId} to ${status}: ${error.message}`, { error });
          // Decide if this error should be re-thrown or handled (e.g., retry update?)
          throw new Error(`Database error occurred while updating reminder ${reminderId}.`);
      }
    };
    
    module.exports = {
      createReminder,
      findAndMarkDueReminders,
      updateReminderStatus,
    };
    • createReminder: Takes details, performs strict validation (expects E.164 number), and saves to the DB. Includes basic error handling for DB operations.
    • findAndMarkDueReminders: Queries for PENDING reminders using the index, fetches a batch, and attempts to atomically update their status to PROCESSING. Returns only the reminders successfully marked. Includes logging and error handling.
    • updateReminderStatus: Updates the reminder's status to SENT or ERROR. Includes error handling for the update operation.

3. Integrating with Infobip (SMS Sending Service)

Now, let's set up the Infobip SDK client and create a service to handle sending SMS messages.

  1. Obtain Infobip Credentials:

    • Log in to your Infobip account.

    • API Key: Navigate to the API Keys management section (often under your account settings or developer tools). Generate a new API key if you don't have one. Copy this key.

    • Base URL: Your Base URL is specific to your account and region. You can usually find this on the API Keys page or the main dashboard/homepage after logging in. It looks something like xxxxx.api.infobip.com. Copy this URL.

    • Sender ID: Decide on a Sender ID (the "from" name/number shown on the recipient's phone). For trial accounts, you might be restricted to specific shared numbers or need to register your own number. For paid accounts, you can often register alphanumeric Sender IDs (like "MyCompany"). Check Infobip's documentation for rules in your target countries.

    • Update .env: Paste your copied API Key, Base URL, and chosen Sender ID into the .env file:

      dotenv
      # .env (update these lines)
      INFOBIP_API_KEY="PASTE_YOUR_API_KEY_HERE"
      INFOBIP_BASE_URL="PASTE_YOUR_BASE_URL_HERE"
      INFOBIP_SENDER_ID="YourSenderID" # Or "InfoSMS", or a purchased number
    • Free Trial Limitation: Remember, if using a free trial, you can typically only send SMS messages to the phone number you verified during signup.

  2. Configure Infobip SDK Client (src/config/infobip.js):

    javascript
    // src/config/infobip.js
    const { Infobip, AuthType } = require('@infobip-api/sdk');
    require('dotenv').config(); // Ensure env vars are loaded if run standalone (less common)
    
    const apiKey = process.env.INFOBIP_API_KEY;
    const baseUrl = process.env.INFOBIP_BASE_URL;
    
    if (!apiKey || !baseUrl) {
      // Throw an error to prevent the application starting in a non-functional state
      throw new Error('Missing Infobip API Key or Base URL in environment variables. Application cannot start.');
    }
    
    const infobipClient = new Infobip({
      baseUrl: baseUrl,
      apiKey: apiKey,
      authType: AuthType.ApiKey, // Specify authentication type
    });
    
    module.exports = infobipClient;
    • This code imports the SDK, reads credentials from environment variables, throws an error if they are missing, and initializes the Infobip client instance. We export the client for use in other services.
  3. Create SMS Sending Service (src/services/smsService.js):

    javascript
    // src/services/smsService.js
    const infobipClient = require('../config/infobip');
    const logger = require('../config/logger');
    // dotenv is loaded in server.js, no need to load again here
    
    const defaultSender = process.env.INFOBIP_SENDER_ID || 'InfoSMS';
    
    /**
     * Sends an SMS message using the Infobip API.
     * @param {string} recipientPhoneNumber - The destination phone number (MUST be E.164 format).
     * @param {string} messageText - The text content of the SMS.
     * @param {string} [senderId] - Optional sender ID, defaults to INFOBIP_SENDER_ID from .env.
     * @returns {Promise<object>} The data part of the response object from Infobip API on success.
     * @throws {Error} If the API call fails or returns a non-successful status.
     */
    const sendSms = async (recipientPhoneNumber, messageText, senderId = defaultSender) => {
      if (!recipientPhoneNumber || !messageText) {
        throw new Error('Recipient phone number and message text are required for sending SMS.');
      }
      // Re-validate E.164 just before sending (belt-and-suspenders)
      if (!/^\+[1-9]\d{1,14}$/.test(recipientPhoneNumber)) {
          logger.error(`Invalid E.164 format passed to sendSms: ${recipientPhoneNumber}`);
          throw new Error(`Invalid phone number format for SMS recipient: ${recipientPhoneNumber}. Must be E.164.`);
      }
    
      logger.info(`Attempting to send SMS via Infobip to ${recipientPhoneNumber} from ${senderId}`);
    
      try {
        const response = await infobipClient.channels.sms.send({
          messages: [
            {
              destinations: [{ to: recipientPhoneNumber }],
              from: senderId,
              text: messageText,
              // Optional: Add validityPeriod, notifyUrl for DLRs, etc.
            },
          ],
          // Optional: Add bulkId, tracking, etc.
        });
    
        // Log the full response for debugging if needed (can be verbose)
        // logger.debug('Infobip API Response:', JSON.stringify(response.data, null, 2));
    
        // Check for successful submission indication in the response
        // Infobip response structure can vary slightly; adjust based on current API docs.
        if (response.data && response.data.messages && response.data.messages.length > 0) {
          const messageResult = response.data.messages[0];
          const status = messageResult.status;
    
          // Check based on groupName (more stable than groupId)
          // Common success groups: PENDING, ACCEPTED (meaning successfully sent to carrier)
          const successGroups = ['PENDING', 'ACCEPTED'];
    
          if (status && successGroups.includes(status.groupName)) {
            logger.info(`SMS submitted successfully to Infobip for ${recipientPhoneNumber}. Message ID: ${messageResult.messageId}, Status: ${status.groupName}`);
            return response.data; // Return the full data part of the response
          } else {
             const errorDetails = status ? `${status.groupName} (ID: ${status.groupId}) - ${status.description}` : 'Unknown status';
             logger.error(`Infobip SMS submission failed for ${recipientPhoneNumber}. Status: ${errorDetails}`);
             // Throw an error that includes details from the response
             throw new Error(`Infobip API Error: ${errorDetails}`);
          }
        } else {
            // Handle unexpected response structure
            logger.error(`Unexpected response structure from Infobip for ${recipientPhoneNumber}:`, response.data);
            throw new Error('Infobip API Error: Unexpected response structure.');
        }
    
      } catch (error) {
        // Log detailed error information, including potential API response errors
        let errorMessage = error.message;
        if (error.response && error.response.data) {
            logger.error(`Error sending SMS via Infobip to ${recipientPhoneNumber}. Status: ${error.response.status}. Response:`, error.response.data);
            // Try to extract a more specific error message from Infobip's response
            const serviceException = error.response.data?.requestError?.serviceException;
            if (serviceException) {
                errorMessage = `Infobip API Error: ${serviceException.messageId} - ${serviceException.text}`;
            } else {
                errorMessage = `Infobip API Error: Status ${error.response.status} - ${JSON.stringify(error.response.data)}`;
            }
        } else {
             logger.error(`Error sending SMS via Infobip to ${recipientPhoneNumber}: ${error.message}`, { error });
        }
    
        // Rethrow a potentially more informative error or the original error
        throw new Error(errorMessage);
      }
    };
    
    module.exports = {
      sendSms,
    };
    • Imports the configured infobipClient and logger.
    • Defines the sendSms function.
    • Performs validation, including checking E.164 format again.
    • Constructs the payload for infobipClient.channels.sms.send.
    • Includes logging for attempts and outcomes.
    • Performs response checking based on status.groupName (e.g., 'PENDING', 'ACCEPTED') for better robustness.
    • Throws detailed errors if the API call fails or indicates a non-successful submission status, extracting details from Infobip's error response if possible.

4. Implementing Scheduling Logic (node-cron)

We'll use node-cron to periodically check for due reminders and trigger the sending process.

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

    javascript
    // src/jobs/reminderScheduler.js
    const cron = require('node-cron');
    const { findAndMarkDueReminders, updateReminderStatus } = require('../services/reminderService');
    const { sendSms } = require('../services/smsService');
    const logger = require('../config/logger');
    const { Status } = require('@prisma/client');
    
    let taskIsRunning = false; // Simple lock to prevent overlapping runs
    
    // Schedule the task to run every minute
    // Cron pattern: second(opt) minute hour day-of-month month day-of-week
    // '* * * * *' means ""at every minute""
    const cronJob = cron.schedule('* * * * *', async () => {
        const jobStartTime = Date.now();
        logger.info(`[Cron Job] Running reminder check job...`);

Frequently Asked Questions

How to schedule SMS reminders using Node.js and Express?

This involves setting up a Node.js project with Express, integrating the Infobip API for SMS, using a database like PostgreSQL with Prisma as the ORM, and scheduling tasks with node-cron to trigger reminder checks and send messages at the specified time via the Infobip service.

What is the Infobip API used for in this project?

Infobip is a cloud communications platform used to send SMS messages. The Infobip API and Node.js SDK are integrated into the application to handle sending SMS reminders. The project uses the official `@infobip-api/sdk` package.

Why use Prisma with PostgreSQL in Node.js?

Prisma acts as an ORM (Object-Relational Mapper), simplifying database interactions. This makes it easier to work with PostgreSQL by providing a type-safe and convenient way to query and manage data within the Node.js application.

When should I set NODE_ENV to production?

Set `NODE_ENV` to `production` when deploying your application to a live server environment. This affects error handling and logging. In development mode, more verbose error details are shown, while in production, they are usually suppressed for security.

Can I use a different database than PostgreSQL?

The guide primarily uses PostgreSQL, but Prisma supports other databases as well. You would need to adjust the `datasource` configuration in your `schema.prisma` file and install the appropriate database driver for Prisma.

How to install required Node.js dependencies?

Use `npm install express @infobip-api/sdk pg node-cron dotenv libphonenumber-js` for core dependencies and `npm install --save-dev prisma` for Prisma (used in development). These commands install the packages listed in the `package.json` file.

What is node-cron used for?

`node-cron` is a task scheduler in Node.js. It's used to periodically trigger the function that checks for pending reminders and sends them via the Infobip SMS API.

How to structure a Node.js Express application effectively?

The guide recommends a structure that separates concerns: routes, controllers, services, configuration, and jobs. This organization makes the codebase cleaner and easier to manage and scale.

What is the role of the reminderService in the project?

The `reminderService.js` file handles the core logic for managing reminders. This includes creating new reminders, validating input, finding due reminders in the database, and updating their status after processing.

What is E.164 format and why is it important for phone numbers?

E.164 is an international standard for phone number formatting (e.g., +14155552671). Using a consistent format, like E.164, ensures that phone numbers are stored and processed correctly, especially when sending SMS internationally.

How does the system handle error scenarios like failed SMS delivery?

The `smsService.js` uses try...catch blocks to handle errors during the SMS sending process via Infobip. It also includes detailed logging of these errors and updates the reminder status in the database to `ERROR`, storing the error message if the sending fails.

What is the purpose of the PROCESSING status for reminders?

The `PROCESSING` status prevents a reminder from being sent multiple times. When the scheduler finds due reminders, it marks them as `PROCESSING` before sending the SMS. This ensures that even with multiple scheduler instances, the same reminder is not picked up and sent twice.