code examples

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

Node.js & Express Guide: Building a Scheduled SMS Reminder System with Sinch

A guide detailing how to build a Node.js/Express application using the Sinch SMS API to schedule and send appointment reminders.

This guide details how to build a robust application using Node.js, Express, and the Sinch SMS API to schedule and send appointment reminders via SMS. We'll cover everything from project setup and core scheduling logic to database integration, error handling, security, and deployment best practices.

This system solves the common business need for automated, timely communication, specifically for appointment reminders, which helps reduce no-shows and improves customer experience. By leveraging Sinch's scheduled send feature, we offload the timing complexity, making our application more reliable and easier to manage.

Technology Stack:

  • Node.js: A JavaScript runtime for building server-side applications.
  • Express: A minimal and flexible Node.js web application framework.
  • Sinch SMS API: Used via the @sinch/sdk-core for sending scheduled SMS messages.
  • MongoDB & Mongoose: For persisting appointment data (Strongly Recommended for production features like status tracking and retrieval; adapting the guide for non-persistent use requires significant code changes).
  • Luxon: For reliable date/time manipulation and timezone handling.
  • dotenv: To manage environment variables securely.
  • Winston: For robust logging.
  • express-validator: For input validation in the API layer.
  • express-rate-limit: To protect the API against brute-force attacks.

System Architecture:

(Note: ASCII diagrams may not render perfectly in all viewers. Consider replacing with an image for final publication.)

text
+-------------+       +----------------------+       +-----------------+       +----------------+       +-----------+
| End User/UI |------>|      API Endpoint    |------>|  Express Server |------>|  Sinch SMS API |------>| Recipient |
| (Optional)  |       | (POST /appointments) |       |  (Node.js App)  |       | (Scheduled Send)|       | (via SMS) |
+-------------+       +----------------------+       +--------+--------+       +-----------------+       +-----------+
                                                               |
                                                               | Stores/Reads (Recommended)
                                                               v
                                                        +-------------+
                                                        |  Database   |
                                                        | (MongoDB)   |
                                                        +-------------+

Prerequisites:

  • Node.js and npm (or yarn) installed.
  • A Sinch account with access to the SMS API.
  • Your Sinch Project ID, API Key ID, API Key Secret, and a provisioned Sinch phone number.
  • Important: For initial testing, Sinch often requires the recipient phone number (to number) to be verified in your Sinch account dashboard unless sending to specific regions/countries that don't require it. See the Troubleshooting section for more details.
  • Access to a MongoDB database (optional, but strongly recommended for the full functionality described).
  • Basic familiarity with Node.js, Express, and REST APIs.

Final Outcome:

By the end of this guide, you will have a functional Express API endpoint that accepts appointment details, validates them, persists them (if using the database), and schedules an SMS reminder using the Sinch API to be sent at a specified time before the appointment.

Note: This guide focuses on the backend API and scheduling logic. A separate front-end application would typically interact with this API.


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.

    bash
    mkdir sinch-scheduler-app
    cd sinch-scheduler-app
  2. Initialize npm: Initialize the project using npm, creating a package.json file. The -y flag accepts default settings.

    bash
    npm init -y
  3. Create Project Structure: Set up a basic directory structure for organization.

    bash
    mkdir src
    mkdir src/routes src/services src/models src/utils src/config
    touch src/app.js src/server.js .env .env.example .gitignore
    • src/: Contains all source code.
    • src/routes/: Express route definitions.
    • src/services/: Business logic, interactions with external APIs (like Sinch).
    • src/models/: Database schema definitions (if using a DB).
    • src/utils/: Helper functions.
    • src/config/: Configuration files (e.g., logger setup).
    • src/app.js: Express application setup (middleware, routes).
    • src/server.js: Server initialization (starts the HTTP server).
    • .env: Stores sensitive credentials and environment-specific variables (ignored by Git).
    • .env.example: Example structure for .env (committed to Git).
    • .gitignore: Specifies files/directories Git should ignore.
  4. Install Dependencies: Install the core dependencies needed for the application. Note: body-parser is no longer needed as a direct dependency with modern Express.

    bash
    npm install express dotenv @sinch/sdk-core luxon cors express-validator mongoose winston express-rate-limit
  5. Install Development Dependencies: Install nodemon for easier development, as it automatically restarts the server on file changes.

    bash
    npm install --save-dev nodemon
  6. Configure package.json Scripts: Add scripts to your package.json for running the application.

    json
    {
      "name": "sinch-scheduler-app",
      "version": "1.0.0",
      "description": "",
      "main": "src/server.js",
      "scripts": {
        "start": "node src/server.js",
        "dev": "nodemon src/server.js",
        "test": "echo \"Error: no test specified yet\" && exit 1"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "dependencies": {
        "@sinch/sdk-core": "^LATEST_VERSION",
        "cors": "^LATEST_VERSION",
        "dotenv": "^LATEST_VERSION",
        "express": "^LATEST_VERSION",
        "express-rate-limit": "^LATEST_VERSION",
        "express-validator": "^LATEST_VERSION",
        "luxon": "^LATEST_VERSION",
        "mongoose": "^LATEST_VERSION",
        "winston": "^LATEST_VERSION"
      },
      "devDependencies": {
        "nodemon": "^LATEST_VERSION"
      }
    }

    (Note: Replace ^LATEST_VERSION with actual installed versions from your package.json)

  7. Configure .gitignore: Add node_modules and .env to your .gitignore file to prevent committing them.

    text
    # .gitignore
    node_modules/
    .env
    npm-debug.log
  8. Configure Environment Variables (.env and .env.example): Define the necessary environment variables. Populate .env with your actual credentials (keep this file private). Copy the structure to .env.example without the secret values.

    ini
    # .env.example
    
    # Server Configuration
    PORT=3000
    NODE_ENV=development # Set to 'production' in deployment
    
    # Sinch API Credentials & Configuration
    # Get these from your Sinch Customer Dashboard -> APIs -> Your Project -> API Keys
    SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID
    SINCH_KEY_ID=YOUR_SINCH_KEY_ID
    SINCH_KEY_SECRET=YOUR_SINCH_KEY_SECRET
    
    # Your provisioned Sinch phone number (in E.164 format, e.g., +12025550111)
    # Found in your Sinch Customer Dashboard under Numbers
    SINCH_FROM_NUMBER=YOUR_SINCH_PHONE_NUMBER
    
    # The Sinch API region (e.g., 'us' or 'eu')
    # Choose based on where your account is primarily based or targeted
    SINCH_REGION=us
    
    # Database Configuration (Optional but Recommended - for MongoDB)
    MONGO_URI=mongodb://localhost:27017/sinch_scheduler
    
    # Logging Level (e.g., info, warn, error, debug)
    LOG_LEVEL=info
    
    # Rate Limiting (Optional)
    RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
    RATE_LIMIT_MAX_REQUESTS=100
    
    # CORS Configuration (Example for production - adjust as needed)
    # ALLOWED_ORIGINS=https://yourfrontenddomain.com,https://anotherdomain.com

    Explanation:

    • PORT: The port your Express server will listen on.
    • NODE_ENV: Set to development or production. Affects error handling verbosity.
    • SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET: Your specific Sinch API credentials. Obtain these from the "Access Keys" section within your project on the Sinch Customer Dashboard. Treat the KEY_SECRET like a password.
    • SINCH_FROM_NUMBER: The phone number purchased or verified in your Sinch account that will be used as the sender ID for the SMS.
    • SINCH_REGION: Specifies the regional Sinch API endpoint (us or eu) to use. Performance may be better if this matches your primary user base or account location.
    • MONGO_URI: Connection string for your MongoDB database (adjust if using a different host or cloud provider). Leave blank or remove if not using the database.
    • LOG_LEVEL: Controls the verbosity of application logs.
    • RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX_REQUESTS: Configuration for API rate limiting.
    • ALLOWED_ORIGINS: Comma-separated list of allowed origins for CORS in production.

2. Implementing Error Handling and Logging First

It's good practice to set up logging early. We'll create the logger configuration now, which will be used by other services.

  1. Setup Logger (src/config/logger.js): Configure Winston for flexible logging.

    javascript
    // src/config/logger.js
    const winston = require('winston');
    const dotenv = require('dotenv');
    
    dotenv.config();
    
    const logFormat = winston.format.printf(({ level, message, timestamp, stack, ...metadata }) => {
        let msg = `${timestamp} [${level}]: ${message}`;
        // Add metadata if present
        if (metadata && Object.keys(metadata).length > 0) {
             // Avoid logging large objects if not needed, especially stack in non-error levels
             if(level !== 'error' && metadata.stack) delete metadata.stack;
             try {
                  // Simple stringify, consider masking sensitive fields in real apps
                  msg += ` ${JSON.stringify(metadata)}`;
             } catch (e) {
                  msg += ` [Metadata serialization error]`;
             }
        }
        // Add stack trace for errors
        if (stack) {
            msg += `\nStack: ${stack}`;
        }
        return msg;
    });
    
    const logger = winston.createLogger({
        level: process.env.LOG_LEVEL || 'info', // Default to 'info'
        format: winston.format.combine(
            // Use colorize only for console transport if desired
            // winston.format.colorize(),
            winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
            winston.format.errors({ stack: true }), // Log stack traces
            logFormat
        ),
        transports: [
            new winston.transports.Console({
                 // Use colorize format specifically for the console
                 format: winston.format.combine(
                     winston.format.colorize(),
                     logFormat
                 ),
                 handleExceptions: true // Handle uncaught exceptions via console
            }),
            // Add other transports for production (e.g., file, log service)
            // new winston.transports.File({ filename: 'error.log', level: 'error', handleExceptions: true }),
            // new winston.transports.File({ filename: 'combined.log' }),
        ],
        exitOnError: false, // Do not exit on handled exceptions
    });
    
    // Stream for Morgan (HTTP request logger) if you decide to use it
    // logger.stream = {
    //     write: function(message, encoding) {
    //         logger.info(message.trim());
    //     },
    // };
    
    module.exports = { logger };

3. Implementing Core Functionality (Sinch Service)

We'll create a dedicated service to handle interactions with the Sinch API.

  1. Create Sinch Service File (src/services/sinchService.js):

    javascript
    // src/services/sinchService.js
    const { SinchClient } = require('@sinch/sdk-core');
    const { logger } = require('../config/logger'); // Uses the logger created in the previous step
    const dotenv = require('dotenv');
    // const retry = require('async-retry'); // Uncomment if using the retry mechanism below
    
    dotenv.config(); // Load environment variables
    
    // Ensure required Sinch variables are set
    if (!process.env.SINCH_PROJECT_ID || !process.env.SINCH_KEY_ID || !process.env.SINCH_KEY_SECRET || !process.env.SINCH_REGION || !process.env.SINCH_FROM_NUMBER) {
        logger.error('CRITICAL: Missing required Sinch environment variables (SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET, SINCH_REGION, SINCH_FROM_NUMBER). Application cannot function correctly.');
        // In a real app, throw an error or exit gracefully, preventing startup without config.
        process.exit(1);
    }
    
    const sinchClient = new SinchClient({
        projectId: process.env.SINCH_PROJECT_ID,
        keyId: process.env.SINCH_KEY_ID,
        keySecret: process.env.SINCH_KEY_SECRET,
        // Note: While the core client takes region, SMS API routing is often handled
        // internally by the SDK based on number prefixes or defaults. Explicitly setting
        // it here primarily benefits non-SMS APIs within the same client instance.
        // If SMS region issues arise, verify your account settings and target numbers.
    });
    
    /**
     * Schedules an SMS reminder using the Sinch API.
     * @param {string} recipientPhoneNumber - The recipient's phone number in E.164 format (e.g., +14155552671).
     * @param {string} messageBody - The text content of the SMS.
     * @param {string} sendAtISO - The scheduled send time in ISO 8601 UTC format (e.g., "2025-12-31T23:59:00.000Z").
     * @returns {Promise<object>} The Sinch API response object for the batch send operation.
     * @throws {Error} If the Sinch API call fails after potential retries.
     */
    const scheduleSmsReminder = async (recipientPhoneNumber, messageBody, sendAtISO) => {
        logger.info(`Attempting to schedule SMS to ${recipientPhoneNumber} at ${sendAtISO}`);
    
        // --- Conceptual Retry Logic Integration ---
        // Uncomment the 'async-retry' require at the top and this block to enable retries.
        /*
        try {
            const response = await retry(async (bail, attempt) => {
                logger.info(`Sinch API call attempt ${attempt} to schedule SMS for ${recipientPhoneNumber}`);
                // If the error is permanent (e.g., 4xx), don't retry
                try {
                    const result = await sinchClient.sms.batches.send({
                        sendSMSRequestBody: {
                            to: [recipientPhoneNumber],
                            from: process.env.SINCH_FROM_NUMBER,
                            body: messageBody,
                            send_at: sendAtISO,
                            // delivery_report: 'full', // Strongly recommended for status tracking
                        },
                    });
                    return result; // Success! Return result from retry block
                } catch (error) {
                    // Check for non-retriable Sinch errors (e.g., bad request, invalid number)
                    if (error.status && error.status >= 400 && error.status < 500) {
                        logger.error(`Non-retriable Sinch API error (${error.status}): ${error.message}. Not retrying.`);
                        // Use bail() to stop retrying and propagate the error immediately
                        bail(new Error(`Non-retriable Sinch API error: ${error.status} - ${error.message || 'Client error'}`));
                        return; // bail doesn't return the error, it throws it. Need to stop execution here.
                    }
                    // For server errors (5xx) or network issues, throw to trigger retry
                    logger.warn(`Retrying Sinch API call due to potentially transient error: ${error.message}`);
                    throw error; // Throw error to signal retry is needed
                }
            }, {
                retries: 3,           // Number of retries
                factor: 2,            // Exponential backoff factor
                minTimeout: 1000,     // Initial delay 1 second
                maxTimeout: 10000,    // Maximum delay 10 seconds
                randomize: true,
                onRetry: (error, attempt) => {
                     logger.warn(`Sinch API call failed on attempt ${attempt}. Retrying... Error: ${error.message}`);
                }
            });
    
            logger.info(`Successfully scheduled SMS via Sinch (potentially after retries). Batch ID: ${response.id}, Send time: ${response.send_at}`);
            return response;
    
        } catch (error) {
             // This catches errors after retries are exhausted or if bail() was called
             logger.error(`Failed to schedule SMS via Sinch after all retries or due to non-retriable error: ${error.message}`, {
                 status: error.originalError?.status, // Access original error if wrapped by retry
                 code: error.originalError?.code,
                 responseBody: error.originalError?.responseBody,
                 stack: error.stack,
                 recipient: recipientPhoneNumber,
                 sendAt: sendAtISO,
             });
             // Re-throw a standardized error
             throw new Error(`Sinch API Error: ${error.message || 'Failed to schedule SMS after retries'}`);
        }
        */
    
        // --- Original Code (Without Retry Logic) ---
        // Comment out or remove this block if using the retry logic above
        try {
            const response = await sinchClient.sms.batches.send({
                sendSMSRequestBody: {
                    to: [recipientPhoneNumber],
                    from: process.env.SINCH_FROM_NUMBER,
                    body: messageBody,
                    send_at: sendAtISO,
                    // Optional parameters:
                    // delivery_report: 'full', // Request delivery reports - RECOMMENDED for status tracking
                    // expire_at: 'ISO_8601_EXPIRY_TIME' // If message isn't delivered by then, cancel it
                },
            });
    
            logger.info(`Successfully scheduled SMS via Sinch. Batch ID: ${response.id}, Send time: ${response.send_at}`);
            // console.log('Sinch API Response:', JSON.stringify(response, null, 2)); // Detailed logging if needed
            return response; // Return the full response including the batch ID, scheduled time, etc.
    
        } catch (error) {
            logger.error(`Failed to schedule SMS via Sinch: ${error.message}`, {
                status: error.status, // Sinch SDK errors often include status
                code: error.code,     // and error codes
                responseBody: error.responseBody, // Potentially useful diagnostic info
                stack: error.stack,
                recipient: recipientPhoneNumber,
                sendAt: sendAtISO,
            });
            // Re-throw a more specific error or handle it as needed
            throw new Error(`Sinch API Error: ${error.message || 'Failed to schedule SMS'}`);
        }
        // --- End of Original Code ---
    };
    
    module.exports = {
        scheduleSmsReminder,
    };

    Explanation:

    • We initialize the SinchClient using credentials loaded from environment variables. A critical check ensures these variables are present.
    • The scheduleSmsReminder function handles scheduling. It takes the recipient's number, message, and the desired send time (ISO 8601 UTC).
    • It calls sinchClient.sms.batches.send with to, from, body, and the crucial send_at parameter.
    • Error handling logs detailed information from Sinch API errors.
    • Retry Logic (Commented Out): A conceptual implementation using async-retry is included (commented out). This demonstrates how to handle transient errors by retrying the API call with exponential backoff, while avoiding retries for non-recoverable errors (like 4xx status codes).
    • Delivery Reports: Note the commented-out delivery_report: 'full' parameter. This is highly recommended for production to track message status (see Section 12).

This section covers the Mongoose setup for persisting appointment data. If you are not using MongoDB, you can skip creating the model and adjust the route logic in the next section accordingly (e.g., by removing database save/update operations).

  1. Create Appointment Model (src/models/Appointment.js): Define a Mongoose schema.

    javascript
    // src/models/Appointment.js
    const mongoose = require('mongoose');
    
    // Check if MongoDB URI is provided, otherwise skip model definition
    if (!process.env.MONGO_URI) {
        console.warn('MONGO_URI not found in environment variables. Skipping Appointment model definition.');
        // Export null or an empty object if you need to check for its existence elsewhere
        module.exports = null;
    } else {
        const appointmentSchema = new mongoose.Schema({
            patientName: {
                type: String,
                required: true,
                trim: true,
            },
            doctorName: {
                type: String,
                required: true,
                trim: true,
            },
            phoneNumber: {
                type: String,
                required: true,
                trim: true,
                // Basic validation, consider more robust E.164 validation library
                match: [/^\+[1-9]\d{1,14}$/, 'Phone number must be in E.164 format (e.g., +12125551234).']
            },
            appointmentTime: {
                type: Date,
                required: true,
            },
            reminderTime: { // Store the calculated UTC time the reminder should be sent
                type: Date,
                required: true,
            },
            reminderMessage: { // Store the actual message body sent
                type: String,
                required: true,
            },
            sinchBatchId: { // Store the ID from the Sinch scheduling response
                type: String,
                required: false, // Initially null until scheduled
                index: true, // Index for potential lookups by batch ID (e.g., from webhooks)
            },
            status: { // Track the status
                type: String,
                enum: ['pending_schedule', 'scheduled', 'schedule_failed', 'sent', 'delivered', 'failed', 'canceled'],
                default: 'pending_schedule',
                index: true, // Index for querying by status
            },
            // Add fields for tracking delivery status if implementing webhooks
            // deliveryStatus: { type: String, enum: ['pending', 'delivered', 'failed', 'expired', null], default: null },
            // deliveryTimestamp: { type: Date },
            // failureCode: { type: Number }, // From Sinch delivery report
        }, {
            timestamps: true, // Adds createdAt and updatedAt automatically
        });
    
        // Index for efficient querying by appointment time or reminder time
        appointmentSchema.index({ appointmentTime: 1 });
        appointmentSchema.index({ reminderTime: 1 });
    
        module.exports = mongoose.model('Appointment', appointmentSchema);
    }

    (This code now checks for MONGO_URI before defining the model.)

  2. Database Connection (src/app.js): Connection logic will be added in the main app.js setup (Section 10).

  3. Data Access: The route handler will use this model if it's defined.

  4. Migrations: Not covered, use dedicated tools for complex schema changes.

  5. Performance/Scale: Indexes are included. Ensure adequate DB resources.


5. Building the API Layer (Routes and Validation)

We'll create the Express route to handle incoming appointment requests.

  1. Create Validation Rules (src/utils/validators.js): Define reusable validation rules using express-validator.

    javascript
    // src/utils/validators.js
    const { body, validationResult } = require('express-validator');
    const { DateTime } = require('luxon');
    
    const appointmentValidationRules = () => {
        return [
            body('patientName').trim().notEmpty().withMessage('Patient name is required.'),
            body('doctorName').trim().notEmpty().withMessage('Doctor name is required.'),
            body('phoneNumber').trim().matches(/^\+[1-9]\d{1,14}$/).withMessage('Phone number must be in E.164 format (e.g., +12125551234).'),
            body('appointmentTime')
                .trim()
                .isISO8601({ strict: true, require_tld: false }) // Be stricter with ISO format
                .withMessage('Appointment time must be a valid ISO 8601 string with timezone offset or Z (e.g., 2025-12-20T14:30:00-05:00 or 2025-12-20T19:30:00Z).')
                .custom((value) => {
                    const appointmentDateTime = DateTime.fromISO(value, { setZone: true }); // Keep zone info
                    if (!appointmentDateTime.isValid) {
                        // This should technically be caught by isISO8601, but good fallback.
                        throw new Error(`Invalid appointment date/time format: ${appointmentDateTime.invalidReason || 'Unknown reason'}`);
                    }
                    // Ensure appointment is reasonably in the future (e.g., > 10 mins from now)
                    if (appointmentDateTime <= DateTime.now().plus({ minutes: 10 })) {
                        throw new Error('Appointment time must be at least 10 minutes in the future.');
                    }
                    return true; // Validation passed
                }),
            body('reminderMinutesBefore')
                .optional({ checkFalsy: true }) // Allow 0? If so, adjust min. If not, remove checkFalsy.
                .isInt({ min: 5, max: 60 * 24 * 7 }) // Min 5 mins, max 1 week (adjust as needed)
                .withMessage('Reminder minutes before must be an integer between 5 and 10080.')
                .toInt(), // Convert to integer
        ];
    };
    
    const validate = (req, res, next) => {
        const errors = validationResult(req);
        if (errors.isEmpty()) {
            return next(); // Proceed if no validation errors
        }
    
        // Format errors for a cleaner response
        const extractedErrors = errors.array().map(err => ({
             field: err.param,
             message: err.msg,
             value: err.value, // Optionally include the invalid value
        }));
    
    
        return res.status(422).json({ // 422 Unprocessable Entity
            message: 'Validation failed.',
            errors: extractedErrors,
        });
    };
    
    module.exports = {
        appointmentValidationRules,
        validate,
    };
  2. Create Appointment Routes (src/routes/appointmentRoutes.js):

    javascript
    // src/routes/appointmentRoutes.js
    const express = require('express');
    const { DateTime } = require('luxon');
    const { scheduleSmsReminder } = require('../services/sinchService');
    const Appointment = require('../models/Appointment'); // Will be null if MONGO_URI is not set
    const { appointmentValidationRules, validate } = require('../utils/validators');
    const { logger } = require('../config/logger');
    
    const router = express.Router();
    
    // POST /api/appointments - Schedule a new appointment reminder
    router.post('/', appointmentValidationRules(), validate, async (req, res, next) => {
        const {
            patientName,
            doctorName,
            phoneNumber,
            appointmentTime: appointmentTimeString, // ISO String from request
            reminderMinutesBefore = 120, // Default to 2 hours (120 mins) if not provided
        } = req.body;
    
        let newAppointment = null; // Variable to hold the DB record if used
    
        try {
            // 1. Parse appointment time and calculate reminder time (use Luxon)
            // Use setZone: true to retain the original zone info for display purposes if needed
            const appointmentDateTime = DateTime.fromISO(appointmentTimeString, { setZone: true });
            if (!appointmentDateTime.isValid) {
                 // Should be caught by validator, but belt-and-suspenders check
                logger.error("Invalid appointment time format passed validation.", { time: appointmentTimeString });
                throw new Error('Internal Server Error: Invalid appointment date/time format.');
            }
    
            const reminderDateTime = appointmentDateTime.minus({ minutes: reminderMinutesBefore });
    
            // 2. --- Crucial Step: Ensure reminder time is valid for Sinch ---
            // Sinch needs `send_at` to be in the future (usually > few mins ahead)
            // Add a buffer (e.g., 5 minutes) to account for processing time and Sinch constraints.
             const minimumLeadTime = DateTime.now().plus({ minutes: 5 });
             if (reminderDateTime <= minimumLeadTime) {
                  logger.warn(`Calculated reminder time (${reminderDateTime.toISO()}) is too soon.`, { appointmentTime: appointmentTimeString, reminderMinutes: reminderMinutesBefore });
                  return res.status(400).json({
                      message: `Calculated reminder time (${reminderDateTime.toISO()}) is too close to the current time or in the past. Ensure appointment time and reminder offset allow sufficient lead time (at least 5 minutes).`,
                  });
             }
    
            // Convert reminder time to UTC ISO string for Sinch API
            const reminderTimeISO_UTC = reminderDateTime.toUTC().toISO();
    
            // 3. Construct the reminder message
            // Use original appointment time with its zone for user-friendly message
            const messageBody = `Hi ${patientName}, reminder: Appointment with Dr. ${doctorName} on ${appointmentDateTime.toLocaleString(DateTime.DATETIME_MED)} (${appointmentDateTime.offsetNameShort || appointmentDateTime.zoneName}).`;
    
            // 4. (Optional but Recommended) Create Appointment record in DB
            if (Appointment) { // Only proceed if the Appointment model was loaded (MONGO_URI was set)
                newAppointment = new Appointment({
                    patientName,
                    doctorName,
                    phoneNumber,
                    appointmentTime: appointmentDateTime.toJSDate(), // Store as standard JS Date (UTC)
                    reminderTime: reminderDateTime.toJSDate(), // Store calculated reminder time (UTC)
                    reminderMessage: messageBody,
                    status: 'pending_schedule', // Initial status before calling Sinch
                });
                await newAppointment.save();
                logger.info(`Appointment record created with ID: ${newAppointment._id}`);
            } else {
                logger.info('Database not configured. Skipping appointment record creation.');
            }
    
            // 5. Schedule the SMS via Sinch Service
            const appointmentIdForLog = newAppointment ? newAppointment._id : 'N/A (DB disabled)';
            logger.info(`Scheduling reminder for appointment ${appointmentIdForLog} at ${reminderTimeISO_UTC}`);
    
            const sinchResponse = await scheduleSmsReminder(
                phoneNumber,
                messageBody,
                reminderTimeISO_UTC
            );
    
            // 6. (Optional) Update Appointment record with Sinch Batch ID and status
            if (newAppointment) { // Check if we have a DB record to update
                newAppointment.sinchBatchId = sinchResponse.id; // Use the ID from Sinch response
                newAppointment.status = 'scheduled';
                await newAppointment.save();
                logger.info(`Appointment ${newAppointment._id} updated with Sinch Batch ID: ${sinchResponse.id} and status 'scheduled'.`);
            } else {
                 logger.info(`Sinch scheduling successful (Batch ID: ${sinchResponse.id}). Skipping DB update.`);
            }
    
            // 7. Send Success Response
            res.status(201).json({
                message: 'Appointment reminder scheduled successfully.',
                appointmentId: newAppointment ? newAppointment._id : null, // Include DB ID if available
                sinchBatchId: sinchResponse.id,
                scheduledTimeUTC: sinchResponse.send_at, // Confirm scheduled time from Sinch
                recipient: phoneNumber,
                messageBody: messageBody, // Echo back the message sent
            });
    
        } catch (error) {
            logger.error(`Error scheduling appointment reminder: ${error.message}`, {
                stack: error.stack,
                requestBody: req.body, // Log request body for debugging (mask sensitive data in production!)
                appointmentId: newAppointment ? newAppointment._id : 'N/A',
            });
    
            // If DB was used and scheduling failed after record creation, update status
            if (newAppointment && newAppointment.status !== 'scheduled') {
                try {
                    newAppointment.status = 'schedule_failed';
                    await newAppointment.save();
                    logger.info(`Appointment ${newAppointment._id} status updated to 'schedule_failed'.`);
                } catch (dbError) {
                    logger.error(`Failed to update appointment status to 'schedule_failed' after Sinch error: ${dbError.message}`, { appointmentId: newAppointment._id });
                }
            }
    
            // Pass error to the centralized error handler (to be defined in app.js)
            next(error);
        }
    });
    
    module.exports = router;

Frequently Asked Questions

How to schedule SMS reminders with Sinch?

Use the Sinch SMS API's scheduled send feature. This API endpoint accepts appointment details, validates them, and schedules an SMS reminder to be sent at a specified time before the appointment. The API request must include patient name, doctor name, phone number in E.164 format, appointment time in ISO 8601 format, and optionally, the number of minutes before the appointment to send the reminder.

What is the technology stack for Sinch SMS scheduler?

The core technologies are Node.js with Express, the Sinch SMS API, and Luxon for date/time handling. MongoDB and Mongoose are strongly recommended for data persistence, with Winston for logging and express-validator/express-rate-limit for security.

Why does this guide recommend MongoDB?

MongoDB, coupled with Mongoose, allows for robust data persistence, which is crucial for production features like appointment status tracking and retrieval. While the system can function without a database, adapting the provided code requires significant changes.

When should I set SINCH_REGION to 'eu'?

Set `SINCH_REGION` to 'eu' if your Sinch account is primarily based in Europe or if your target recipients are mostly in Europe. This might improve API performance.

What is the role of Luxon in this Node.js application?

Luxon provides reliable date and time manipulation, including timezone handling, which is essential for accurately calculating reminder times and formatting appointment information in different timezones.

How to install dependencies for the Sinch SMS scheduler?

Run `npm install express dotenv @sinch/sdk-core luxon cors express-validator mongoose winston express-rate-limit` to install the required packages. For development, also install nodemon with `npm install --save-dev nodemon` for automatic server restarts.

How to handle errors when scheduling SMS reminders?

The provided code includes detailed logging using Winston and demonstrates error handling specifically for Sinch API interactions. It also includes conceptual retry logic using `async-retry` to address transient errors and avoids retrying non-recoverable errors (like 4xx status codes).

What is the purpose of express-validator?

express-validator is used to implement input validation for the appointment details sent via the API POST request. This helps prevent bad data from being stored and ensures the Sinch API receives correctly formatted information.

How to structure a Node.js project for a Sinch SMS scheduler?

The guide recommends a structure with directories for routes, services, models, utils, and config within a 'src' folder. It also emphasizes using '.env' for sensitive data, '.env.example' as a template, and '.gitignore' to exclude unnecessary files from version control.

How to send scheduled SMS messages with Node.js?

By using the Sinch SMS API with Node.js, you can schedule messages to be sent at a specific future time. This is done via a dedicated 'send_at' parameter in the API request, using ISO 8601 UTC format.

How to get Sinch project credentials?

You obtain your Sinch Project ID, API Key ID, and API Key Secret from the Access Keys section within your project on the Sinch Customer Dashboard.

What is the use of 'send_at' in the Sinch API?

The 'send_at' parameter allows you to specify the exact time in the future when you want the SMS message to be sent. It's crucial for scheduling reminders and must be provided in ISO 8601 UTC format.

How to set up logging in Node.js with Winston?

Import the Winston library, create a logger instance, configure the format (timestamp, log level), and add transports (console, file) to direct log messages to desired locations.

What environment variables are required for Sinch?

Essential environment variables are: `SINCH_PROJECT_ID`, `SINCH_KEY_ID`, `SINCH_KEY_SECRET`, `SINCH_REGION`, and `SINCH_FROM_NUMBER`. Ensure they are set correctly in your `.env` file. These can be found on the Sinch Customer Dashboard, usually within the 'API Keys' section of your project settings.