messaging channels

Sent logo
Sent TeamMar 8, 2026 / messaging channels / Sinch

Sinch SMS Scheduling with Node.js: Build Automated Appointment Reminders (2025 Guide)

Build production-ready SMS appointment reminders with Sinch SMS API and Node.js. Complete tutorial with send_at parameter scheduling, Luxon timezone handling, database persistence, and secure Express implementation. No cron jobs needed.

Learn how to build a production-ready SMS appointment reminder system using Sinch SMS API, Node.js, and Express. This comprehensive guide shows you how to schedule SMS messages with the send_at parameter—no cron jobs or background workers required. Sinch handles all message scheduling server-side, simplifying your infrastructure.

The hospital appointment reminder example adapts to multiple use cases:

  • Meeting reminders for business scheduling
  • Event notifications for conferences or webinars
  • Subscription renewal alerts for SaaS applications
  • Service maintenance notifications

Sinch's built-in scheduling eliminates complex local scheduling libraries and persistent background processes. The API handles message delivery server-side, reducing your infrastructure complexity.

Prerequisites:

Environment:

  • Node.js v18 or v20 LTS (v18 supported until May 2025)
  • npm or yarn package manager
  • Download Node.js

Sinch Account & Credentials:

  • Active Sinch account (Sign up)
  • Project ID (from Sinch Customer Dashboard)
  • API Key ID and Secret (from Access Keys section)
  • Provisioned SMS-enabled phone number in your target region
  • Verified recipient numbers for testing (required during trial/development)

Dependencies:

  • @sinch/sdk-core v1.2.1
  • @sinch/sms v1.2.1
  • Express v4.18+
  • Luxon v3.7.2+

Technical Knowledge:

  • Node.js and Express fundamentals
  • JavaScript async/await patterns
  • HTML forms and REST APIs

Source: Sinch Node.js SDK npm repository (@sinch/sdk-core v1.2.1, @sinch/sms v1.2.1); Node.js LTS release schedule; Sinch SMS API documentation (send_at parameter accepts ISO 8601 format, defaults to UTC if no offset specified)

What You'll Build:

  • Express web application with appointment scheduling UI
  • Backend validation and SMS scheduling via Sinch send_at parameter
  • Secure credential management with environment variables
  • Error handling and structured logging
  • Production-ready database persistence and deployment strategies

System Architecture:

mermaid
graph LR
    A[User Browser] -- HTTP Request (Submit Form) --> B(Node.js/Express App);
    B -- Schedule SMS (API Call w/ send_at) --> C(Sinch SMS API);
    C -- Scheduled Time --> D(Sends SMS);
    D -- SMS --> E(Recipient Phone);
    B -- HTTP Response (Success/Error Page) --> A;
    B -- Store Appointment (DB Write) --> F[(Database)];
    B -- Retrieve Appointment (DB Read) --> F;
  1. User Browser: Submits appointment details via HTML form
  2. Node.js/Express App:
    • Serves HTML form
    • Validates input data
    • Calls Sinch API with send_at parameter to schedule delivery
    • Persists appointment data to database (production requirement)
    • Renders confirmation or error page
  3. Sinch SMS API:
    • Receives API request
    • Stores message with scheduled send_at timestamp
    • Sends SMS at specified time
  4. Recipient Phone: Receives SMS reminder
  5. Database: Stores appointment details, ensuring persistence across server restarts

1. Project Setup: Installing Sinch Node.js SDK and Dependencies

  1. Create Project Directory:

    bash
    mkdir sinch-appointment-reminder
    cd sinch-appointment-reminder
  2. Initialize Node.js Project:

    bash
    npm init -y
  3. Create Project Structure:

    bash
    # Create directories
    mkdir public public/css views
    
    # Create files (use `touch` on macOS/Linux or `echo. >` on Windows)
    touch .env .gitignore app.js routes.js public/css/style.css views/patient_details.ejs views/success.ejs
    • .env: Environment variables (API keys, secrets) – never commit to Git
    • .gitignore: Files and folders Git should ignore
    • app.js: Express application entry point
    • routes.js: Application routing and request logic
    • public/: Static assets (CSS, JavaScript, images)
    • views/: EJS templates
  4. Install Dependencies:

    bash
    npm install express @sinch/sdk-core dotenv luxon express-session connect-flash ejs
    • express: Web framework for Node.js
    • @sinch/sdk-core: Official Sinch Node.js SDK
    • dotenv: Loads environment variables from .env
    • luxon: Date/time library (v3.7.2) with native timezone support
    • express-session: Session management middleware
    • connect-flash: Temporary message display middleware
    • ejs: HTML templating engine

    Note: Avoid sessionstorage-for-nodejs in production – data is lost on server restarts. Use express-session for temporary data and a database for persistent storage (see Section 6).

Source: Luxon npm package (v3.7.2); Luxon documentation (native timezone support)

  1. Install Development Dependency:

    bash
    npm install --save-dev nodemon
  2. Configure .gitignore:

    plaintext
    # .gitignore
    
    # Dependencies
    node_modules
    
    # Environment variables
    .env
    
    # Logs
    logs
    *.log
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    
    # OS generated files
    .DS_Store
    Thumbs.db
  3. Configure Environment Variables (.env):

    Replace placeholder values with your actual credentials. Generate SESSION_SECRET using a cryptographically secure method:

    bash
    # Generate secure session secret
    node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
    dotenv
    # .env
    
    # Sinch API Credentials & Configuration
    # Obtain from Sinch Customer Dashboard -> Access Keys -> Create API Key
    KEY_ID='YOUR_SINCH_KEY_ID'
    KEY_SECRET='YOUR_SINCH_KEY_SECRET'
    PROJECT_ID='YOUR_SINCH_PROJECT_ID'
    
    # Sinch Phone Number (Must be SMS enabled and assigned to your Project)
    # Obtain from Sinch Customer Dashboard -> Numbers -> Your Numbers
    FROM_NUMBER='+1xxxxxxxxxx' # Use E.164 format (e.g., +12025550181)
    
    # Sinch SMS API Region (e.g., 'us' or 'eu')
    # Determines the API endpoint and potentially default country code logic
    SMS_REGION='us'
    
    # Application Configuration
    PORT=3000
    # Generate a strong random string for production!
    SESSION_SECRET='replace_with_a_strong_random_secret_string'
    
    # Optional: Default Country Code (Used for basic phone formatting logic)
    # Example for US
    DEFAULT_COUNTRY_CODE='+1'
    # Example for UK
    # DEFAULT_COUNTRY_CODE='+44'
    • KEY_ID, KEY_SECRET, PROJECT_ID: Access Keys page in Sinch Customer Dashboard (treat Secret like a password)
    • FROM_NUMBER: SMS-enabled virtual number from Sinch (E.164 format required)
    • SMS_REGION: Sinch API region (us, eu) affecting endpoints and compliance
    • PORT: Express application port
    • SESSION_SECRET: Cryptographically secure random string (32+ bytes) for session cookie signing
    • DEFAULT_COUNTRY_CODE: Prepends country code to phone numbers without one
  4. Set Up nodemon Script:

    json
    // package.json ("scripts" section)
    "scripts": {
      "start": "node app.js",
      "dev": "nodemon app.js",
      "test": "echo \"Error: no test specified\" && exit 1"
    },

    Run npm run dev to start the development server.

  5. Configure Application Entry Point (app.js):

    javascript
    // app.js
    
    const express = require('express');
    const session = require('express-session');
    const flash = require('connect-flash');
    const path = require('path');
    const routes = require('./routes'); // Import routes
    require('dotenv').config(); // Load .env variables early
    
    const app = express();
    const port = process.env.PORT || 3000;
    
    // View engine setup (EJS)
    app.set('views', path.join(__dirname, 'views'));
    app.set('view engine', 'ejs');
    
    // Middleware
    // Serve static files (CSS, client-side JS) from 'public' directory
    app.use(express.static(path.join(__dirname, 'public')));
    // Parse URL-encoded bodies (as sent by HTML forms)
    app.use(express.urlencoded({ extended: false }));
    
    // Session middleware configuration
    app.use(
      session({
        secret: process.env.SESSION_SECRET || 'default_fallback_secret', // Use env var
        resave: false, // Don't save session if unmodified
        saveUninitialized: false, // Don't create session until something stored
        cookie: {
            maxAge: 3600000, // Cookie expires in 1 hour (in milliseconds)
            secure: process.env.NODE_ENV === 'production', // Use secure cookies in production (HTTPS only)
            httpOnly: true, // Helps prevent XSS attacks by blocking client-side JS access
            sameSite: 'strict' // CSRF protection
        }
      })
    );
    // Flash message middleware (depends on session)
    app.use(flash());
    
    // Global variables for views (e.g., making flash messages available)
    app.use((req, res, next) => {
      res.locals.success_msg = req.flash('success_msg');
      res.locals.error_msg = req.flash('error_msg');
      // Make environment variables available to templates if needed (use with caution)
      res.locals.process = { env: { DEFAULT_COUNTRY_CODE: process.env.DEFAULT_COUNTRY_CODE } };
      next();
    });
    
    // Mount the routes defined in routes.js
    app.use('/', routes);
    
    // Basic Error Handler (optional but recommended)
    app.use((err, req, res, next) => {
      console.error(err.stack);
      res.status(500).send('Something broke!');
    });
    
    // Start the server
    app.listen(port, () => {
      console.log(`Server started on http://localhost:${port}`);
      console.log(`Sinch Project ID: ${process.env.PROJECT_ID}`);
      console.log(`Sinch SMS Region: ${process.env.SMS_REGION}`);
    });

    Key configurations:

    • Load environment variables with dotenv.config()
    • Configure express.static and express.urlencoded middleware
    • Set up express-session with 1-hour cookie lifetime
    • Enable security options: secure (HTTPS-only), httpOnly (XSS prevention), sameSite (CSRF protection)
    • Make flash messages available globally in EJS views via res.locals
    • Mount routes from routes.js
    • Start server on configured PORT

Source: Express security best practices documentation (secure cookies require HTTPS, httpOnly prevents client-side JS access, sameSite provides CSRF protection)


2. Implementing SMS Scheduling with Sinch send_at Parameter

The Sinch SMS API's send_at parameter is the key to serverless SMS scheduling. It accepts an ISO 8601 UTC timestamp and handles all message delivery server-side. When you make the API call, Sinch stores the message in a queue and automatically sends it at the specified time—no cron jobs, no background workers, no node-cron library needed. This architecture dramatically simplifies scheduled SMS implementation compared to traditional polling-based systems.

javascript
// routes.js

const express = require('express');
const router = express.Router();
const { DateTime } = require('luxon'); // For date/time handling
const { SinchClient } = require('@sinch/sdk-core');
require('dotenv').config(); // Ensure env vars are loaded

// --- Sinch Client Initialization ---
if (!process.env.PROJECT_ID || !process.env.KEY_ID || !process.env.KEY_SECRET || !process.env.SMS_REGION || !process.env.FROM_NUMBER) {
    console.error("FATAL ERROR: Missing required Sinch environment variables in .env file.");
    console.error("Check KEY_ID, KEY_SECRET, PROJECT_ID, SMS_REGION, FROM_NUMBER.");
    process.exit(1);
}

const sinchClient = new SinchClient({
  projectId: process.env.PROJECT_ID,
  keyId: process.env.KEY_ID,
  keySecret: process.env.KEY_SECRET,
  region: process.env.SMS_REGION, // Use region from .env
});

// --- Helper Function to Send Scheduled SMS ---
async function scheduleReminder(to, message, sendAtIsoString) {
  console.log('Scheduling SMS:');
  console.log(`  To: ${to}`);
  console.log(`  Message: "${message}"`);
  console.log(`  Send Time (UTC): ${sendAtIsoString}`);

  try {
    const response = await sinchClient.sms.batches.send({
      sendSMSRequestBody: {
        to: [to],
        from: process.env.FROM_NUMBER,
        body: message,
        send_at: sendAtIsoString, // ISO 8601 UTC timestamp
      },
    });

    console.log('Sinch API Response:', JSON.stringify(response, null, 2));
    // Successful response means Sinch accepted the scheduling request
    return { success: true, batchId: response.id };
  } catch (error) {
    console.error('Error scheduling SMS:', error.response ? JSON.stringify(error.response.data, null, 2) : error.message);
    let errorMessage = 'Failed to schedule reminder.';
    if (error.response?.data?.text) {
        errorMessage += ` Details: ${error.response.data.text}`;
    } else if (error.message) {
        errorMessage += ` Details: ${error.message}`;
    }
    return { success: false, error: errorMessage };
  }
}

// --- Route Definitions ---

// GET / : Redirect to the appointment form
router.get('/', (req, res) => {
  res.redirect('/appointment');
});

// GET /appointment : Display the appointment scheduling form
router.get('/appointment', (req, res) => {
  // Render the form. Flash messages are available globally
  res.render('patient_details');
});

// POST /appointment : Handle form submission, validate, and schedule SMS
router.post('/appointment', async (req, res, next) => {
  const { patientName, doctorName, appointmentDate, appointmentTime, patientPhone } = req.body;

  // --- 1. Input Validation ---
  if (!patientName || !doctorName || !appointmentDate || !appointmentTime || !patientPhone) {
      req.flash('error_msg', 'Please fill in all fields.');
      return res.redirect('/appointment');
  }

  // --- 2. Date/Time Processing with Luxon ---
  let appointmentDateTimeLocal;
  try {
    // Combine date and time, parse using Luxon in local timezone
    appointmentDateTimeLocal = DateTime.fromISO(`${appointmentDate}T${appointmentTime}`, { zone: 'local' });

    if (!appointmentDateTimeLocal.isValid) {
        req.flash('error_msg', `Invalid date or time format. Error: ${appointmentDateTimeLocal.invalidReason || 'Unknown reason'}`);
        return res.redirect('/appointment');
    }
  } catch (parseError) {
      console.error('Error parsing date/time:', parseError);
      req.flash('error_msg', 'Could not parse the provided date or time.');
      return res.redirect('/appointment');
  }


  // --- 3. Scheduling Validation ---
  try {
    // Ensure appointment is reasonably in the future
    // Reminder is set 2 hours before appointment
    const reminderLeadTime = { hours: 2 };
    const minimumBookingLeadTime = { hours: 2, minutes: 5 };
    const nowLocal = DateTime.local();
    const reminderDateTimeLocal = appointmentDateTimeLocal.minus(reminderLeadTime);

    // Check if appointment is far enough in the future
    if (appointmentDateTimeLocal < nowLocal.plus(minimumBookingLeadTime)) {
      req.flash('error_msg', `Appointment must be at least ${minimumBookingLeadTime.hours} hours and ${minimumBookingLeadTime.minutes} minutes from now.`);
      return res.redirect('/appointment');
    }

    // --- 4. Prepare Data for Sinch ---
    // Format reminder time as UTC ISO 8601 string (required by Sinch send_at)
    const reminderDateTimeUtcISO = reminderDateTimeLocal.toUTC().toISO();

    // Format phone number (basic implementation)
    let formattedPhone = patientPhone.trim();
    if (!formattedPhone.startsWith('+')) {
        const defaultCountryCode = process.env.DEFAULT_COUNTRY_CODE || '+1';
        formattedPhone = defaultCountryCode + formattedPhone.replace(/[^0-9]/g, '');
    }
    // PRODUCTION WARNING: Use libphonenumber-js for robust phone validation
    // npm install libphonenumber-js

    // Construct SMS message body
    const messageBody = `Hi ${patientName}, this is a reminder for your appointment with Dr. ${doctorName} scheduled for ${appointmentDateTimeLocal.toLocaleString(DateTime.DATETIME_MED_WITH_WEEKDAY)}.`;

    // --- 5. Schedule SMS via Sinch ---
    const scheduleResult = await scheduleReminder(formattedPhone, messageBody, reminderDateTimeUtcISO);

    if (!scheduleResult.success) {
        req.flash('error_msg', `Failed to schedule reminder. ${scheduleResult.error || ''}`);
        return res.redirect('/appointment');
    }

    // --- 6. Persist Data (Database Required for Production!) ---
    // IMPORTANT: Save to persistent database (See Section 6)
    // Store details in session temporarily for success page display
    req.session.appointmentDetails = {
        patient: patientName,
        doctor: doctorName,
        phone: formattedPhone,
        appointmentDateTimeDisplay: appointmentDateTimeLocal.toLocaleString(DateTime.DATETIME_MED_WITH_WEEKDAY),
        reminderDateTimeDisplay: reminderDateTimeLocal.toLocaleString(DateTime.DATETIME_MED_WITH_WEEKDAY),
        sinchBatchId: scheduleResult.batchId || 'N/A'
    };
    // In database implementation, store record and save ID: req.session.appointmentId = dbRecord.id

    req.flash('success_msg', 'Appointment reminder scheduled successfully!');
    res.redirect('/success');

  } catch (error) {
      console.error('Error processing appointment:', error);
      req.flash('error_msg', 'An unexpected error occurred while processing the appointment.');
      res.redirect('/appointment');
  }
});

// GET /success : Display confirmation page
router.get('/success', (req, res) => {
  // Retrieve data from session for display
  const appointmentDetails = req.session.appointmentDetails;

  // Clear temporary session data
  if (req.session.appointmentDetails) {
      delete req.session.appointmentDetails;
  }

  // PRODUCTION NOTE: In production, retrieve from database using ID
  // Example: const appointmentDetails = await prisma.appointment.findUnique({ where: { id: req.session.appointmentId } })

  if (!appointmentDetails) {
      req.flash('error_msg', 'Confirmation details expired or unavailable. Please check your schedule or contact support.');
      return res.redirect('/appointment');
  }

  res.render('success', {
    patient: appointmentDetails.patient,
    doctor: appointmentDetails.doctor,
    phone: appointmentDetails.phone,
    appointmentTimeDisplay: appointmentDetails.appointmentDateTimeDisplay,
    reminderTimeDisplay: appointmentDetails.reminderDateTimeDisplay,
    batchId: appointmentDetails.sinchBatchId
  });
});

module.exports = router;
  • Sinch Client: Initialized using credentials from .env with validation for missing variables
  • scheduleReminder Function: Calls the Sinch API (sinchClient.sms.batches.send) with UTC ISO 8601 for send_at
  • Routes:
    • /: Redirects to appointment form
    • /appointment (GET): Renders patient_details.ejs
    • /appointment (POST):
      1. Validates required fields
      2. Parses and validates date/time using Luxon
      3. Calculates reminder time (2 hours prior) and validates future scheduling
      4. Formats reminder time to UTC ISO 8601 and constructs message
      5. Calls scheduleReminder to schedule via Sinch
      6. Stores confirmation details temporarily in session (database required for production)
    • /success (GET): Retrieves and displays confirmation from session, then clears temporary data

3. Building the Appointment Scheduling Interface

Create HTML forms for user interaction using EJS templates.

views/patient_details.ejs:

html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Schedule Appointment Reminder</title>
    <link rel="stylesheet" href="/css/style.css">
</head>
<body>
    <h1>Schedule Sinch Hospital Appointment Reminder</h1>

    <% if (typeof error_msg !== 'undefined' && error_msg && error_msg.length > 0) { %>
        <div class="alert alert-danger"><%= error_msg %></div>
    <% } %>

    <form action="/appointment" method="POST">
        <div>
            <label for="patientName">Patient Name:</label>
            <input type="text" id="patientName" name="patientName" required>
        </div>
        <div>
            <label for="doctorName">Doctor Name:</label>
            <input type="text" id="doctorName" name="doctorName" required>
        </div>
        <div>
            <label for="appointmentDate">Appointment Date:</label>
            <input type="date" id="appointmentDate" name="appointmentDate" required>
        </div>
        <div>
            <label for="appointmentTime">Appointment Time:</label>
            <input type="time" id="appointmentTime" name="appointmentTime" required>
        </div>
        <div>
            <label for="patientPhone">Patient Phone Number:</label>
            <input type="tel" id="patientPhone" name="patientPhone" required placeholder="+15551234567 or 5551234567">
            <small>Enter number with country code (e.g., +1555…) or without if in the default region (<%= process.env.DEFAULT_COUNTRY_CODE || '+1' %>).</small>
        </div>
        <button type="submit">Schedule Reminder</button>
    </form>

</body>
</html>

views/success.ejs:

html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Reminder Scheduled</title>
    <link rel="stylesheet" href="/css/style.css">
</head>
<body>
    <h1>Appointment Reminder Scheduled Successfully!</h1>

    <% if (typeof success_msg !== 'undefined' && success_msg && success_msg.length > 0) { %>
        <div class="alert alert-success"><%= success_msg %></div>
    <% } %>

    <% if (typeof patient !== 'undefined' && patient) { %>
        <p>An SMS reminder has been scheduled for:</p>
        <ul>
            <li><strong>Patient:</strong> <%= patient %></li>
            <li><strong>Doctor:</strong> <%= doctor %></li>
            <li><strong>Appointment Time:</strong> <%= appointmentTimeDisplay %></li>
            <li><strong>Patient Phone:</strong> <%= phone %></li>
            <li><strong>Reminder Scheduled Time:</strong> <%= reminderTimeDisplay %> (approx, local time)</li>
            <li><strong>Sinch Batch ID:</strong> <%= batchId %></li>
        </ul>
        <p><em>Note: This confirmation shows details based on submission. Ensure data is saved persistently for reliability.</em></p>
    <% } else { %>
        <p>Confirmation details could not be retrieved (session might have expired or data was unavailable).</p>
    <% } %>


    <p><a href="/appointment">Schedule another reminder</a></p>

</body>
</html>

public/css/style.css:

css
/* public/css/style.css */
body {
    font-family: sans-serif;
    padding: 20px;
    line-height: 1.6;
}

h1 {
    color: #333;
    border-bottom: 1px solid #eee;
    padding-bottom: 10px;
}

form div {
    margin-bottom: 15px;
}

label {
    display: block;
    margin-bottom: 5px;
    font-weight: bold;
}

input[type="text"],
input[type="date"],
input[type="time"],
input[type="tel"] {
    width: 95%;
    max-width: 400px;
    padding: 8px;
    border: 1px solid #ccc;
    border-radius: 4px;
}

small {
    display: block;
    font-size: 0.8em;
    color: #666;
    margin-top: 3px;
}

button {
    padding: 10px 15px;
    background-color: #007bff;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 1em;
}

button:hover {
    background-color: #0056b3;
}

.alert {
    padding: 15px;
    margin-bottom: 20px;
    border: 1px solid transparent;
    border-radius: 4px;
}

.alert-danger {
    color: #721c24;
    background-color: #f8d7da;
    border-color: #f5c6cb;
}

.alert-success {
    color: #155724;
    background-color: #d4edda;
    border-color: #c3e6cb;
}

ul {
    list-style: none;
    padding: 0;
}

li {
    margin-bottom: 8px;
}

4. Sinch SMS API Integration and Credentials Setup

Integration happens primarily in routes.js:

  1. Credentials: Securely loaded from .env
  2. Client Initialization: SinchClient instantiated using Project ID, Key ID, Key Secret, and Region
  3. Scheduling API Call: scheduleReminder function uses sinchClient.sms.batches.send, passing recipient (to), sender (from), message body, and the send_at parameter (UTC ISO 8601 timestamp)

Obtaining Credentials:

  1. Log in to your Sinch Customer Dashboard
  2. Navigate to Access Keys in the left menu
  3. Click Create Key and give it a name (e.g., "NodeReminderApp")
  4. Immediately copy the Key ID and Key Secret – the Secret is only shown once. Store these securely in your .env file
  5. Your Project ID is visible at the top of the dashboard or under API settings. Add it to .env
  6. Navigate to NumbersYour Numbers. Copy your active SMS-enabled number (E.164 format, e.g., +1xxxxxxxxxx) and add it as FROM_NUMBER in .env
  7. Note the Region (us, eu, etc.) associated with your service plan/project and set SMS_REGION in .env

5. Production Error Handling and Logging Best Practices

The current implementation includes basic error handling:

  • Missing Environment Variables: routes.js checks for essential Sinch variables on startup and exits if missing
  • API Call Errors: scheduleReminder function uses try...catch around sinchClient.sms.batches.send, logs errors, and returns failure status
  • Input Validation: Basic checks for empty fields and appointment time validity in POST /appointment handler, with flash messages for user feedback
  • General Errors: Fallback try...catch in POST /appointment handler catches unexpected errors during processing
  • Logging: Currently uses console.log and console.error with consistent formatting

Production Enhancements:

  1. Structured Logging: Use winston or pino for structured JSON logs

    bash
    npm install winston

    Replace console.log/console.error with logger instances (e.g., logger.info(), logger.error()). Configure transports to write logs to files or external logging services.

  2. Detailed API Error Parsing: Check error.response.data from Sinch API errors for specific error codes to provide more informative feedback or trigger specific actions (e.g., retries for transient network issues)

  3. Centralized Error Handling Middleware: Implement more robust Express error-handling middleware in app.js to catch unhandled errors gracefully

  4. Retry Mechanisms: For transient network errors, consider implementing a simple retry strategy (1-2 retries with short delay). However, since Sinch handles the sending schedule, retrying the scheduling call might result in duplicate scheduled messages if the first call succeeded but the response was lost. Log failures and alert administrators instead.


6. Database Persistence for Scheduled SMS Appointments

Using only express-session is NOT persistent storage. Scheduled jobs require data that survives server restarts and deployments. If the server restarts between scheduling via the API and rendering the success page (or if the user's session expires), confirmation data will be lost. The application has no record of scheduled appointments after server restarts.

A database is mandatory for production.

Conceptual Database Schema (PostgreSQL or MySQL):

sql
CREATE TABLE appointments (
    id SERIAL PRIMARY KEY, -- Or UUID for distributed systems
    patient_name VARCHAR(255) NOT NULL,
    doctor_name VARCHAR(255) NOT NULL,
    patient_phone VARCHAR(30) NOT NULL, -- Store in E.164 format ideally
    appointment_datetime TIMESTAMPTZ NOT NULL, -- Store with time zone (UTC recommended)
    reminder_datetime TIMESTAMPTZ NOT NULL, -- Store scheduled reminder time (UTC recommended)
    sinch_batch_id VARCHAR(100) UNIQUE, -- Store ID returned by Sinch API for tracking
    status VARCHAR(50) DEFAULT 'scheduled', -- e.g., 'scheduled', 'sent', 'failed', 'cancelled'
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- Optional: Index for querying by phone or appointment time
CREATE INDEX idx_appointments_phone ON appointments(patient_phone);
CREATE INDEX idx_appointments_datetime ON appointments(appointment_datetime);

Data Layer Implementation Steps:

  1. Choose a Database: Select PostgreSQL, MySQL, or MongoDB
  2. Install ORM/Driver:
    • PostgreSQL: npm install pg (driver) or npm install sequelize / npm install @prisma/client (ORMs)
    • MySQL: npm install mysql2 (driver) or npm install sequelize / npm install @prisma/client (ORMs)
    • MongoDB: npm install mongodb (driver) or npm install mongoose (ODM)
  3. Configure Connection: Set up database connection details using environment variables
  4. Define Model/Schema: Create the table/collection schema using your chosen tool's syntax
  5. Modify routes.js:
    • Import your database client/model
    • In POST /appointment handler, after successfully scheduling with Sinch (scheduleResult.success), save appointment details (including patientName, doctorName, formattedPhone, appointmentDateTimeLocal.toJSDate(), reminderDateTimeLocal.toJSDate(), scheduleResult.batchId) to database
    • Store only the database record's ID in session: req.session.appointmentId = newAppointmentRecord.id
    • In GET /success handler, retrieve appointmentId from session and query database for full appointment details. Render success.ejs using database data. Clear req.session.appointmentId afterwards
  6. Phone Number Validation: Implement robust validation using libphonenumber-js before saving to database and sending to Sinch. Ensure numbers are stored consistently (E.164 format).

This ensures appointment data persists reliably even after application restarts, providing a foundation for future features like viewing scheduled appointments, cancellation, or status tracking.


Frequently Asked Questions

How does Sinch SMS scheduling work with the send_at parameter?

The Sinch SMS API's send_at parameter accepts an ISO 8601 timestamp in UTC format. When you include this parameter in your API call, Sinch stores the message and automatically sends it at the specified future time. You don't need local cron jobs or background workers – Sinch handles the entire scheduling mechanism server-side.

What version of the Sinch Node.js SDK should I use for SMS scheduling?

Use @sinch/sdk-core v1.2.1 and @sinch/sms v1.2.1 (latest as of January 2025). These versions provide full support for the send_at scheduling parameter and are compatible with Node.js v16+ (v18 or v20 LTS recommended for production as of 2025).

How do I handle timezones when scheduling SMS reminders with Luxon?

Luxon (v3.7.2) provides native timezone support. Parse user input in local time with DateTime.fromISO(), perform calculations (like subtracting 2 hours for reminders), then convert to UTC using .toUTC().toISO() before sending to Sinch. Always store appointment times with timezone information (TIMESTAMPTZ in PostgreSQL) for accurate scheduling across regions.

Why is express-session not suitable for production appointment storage?

The default MemoryStore for express-session loses all data when your server restarts, which means scheduled appointments would be lost. For production, you must use a persistent database (PostgreSQL, MySQL, MongoDB) to store appointment details. Use express-session only for temporary data like flash messages or passing confirmation IDs between requests.

Enable three critical security options: secure: true (HTTPS-only cookies), httpOnly: true (prevents XSS attacks by blocking client-side JavaScript access), and sameSite: 'strict' (CSRF protection). Also set app.set('trust proxy', 1) if running behind a load balancer or reverse proxy.

Can I schedule SMS messages more than 24 hours in advance with Sinch?

Yes, Sinch supports scheduling SMS messages days or even weeks in advance using the send_at parameter. There's no documented hard limit, but for very long-term scheduling (months), consider storing appointments in your database and implementing a daily or weekly job to schedule upcoming reminders within a reasonable window (e.g., 7 days ahead).

How do I validate international phone numbers before sending to Sinch?

Use the libphonenumber-js library for robust phone number validation: npm install libphonenumber-js. Parse numbers with parsePhoneNumber(input, defaultCountry), validate with .isValid(), and format to E.164 using .format('E.164'). This ensures all numbers are correctly formatted (e.g., +442071234567) before sending to Sinch.

What happens if my Express server restarts after scheduling an SMS?

If you only store appointment data in express-session, it's lost on restart. However, the SMS message itself is already scheduled with Sinch's servers and will send at the specified time. For production reliability, persist all appointment details (including Sinch batch IDs) to a database so you can track, modify, or cancel scheduled messages even after server restarts.

How do I cancel or update a scheduled SMS reminder in Sinch?

Use the Sinch batch ID returned when scheduling (stored in response.id) to modify or cancel messages. Call sinchClient.sms.batches.update() with the batch ID to change the message or send time, or sinchClient.sms.batches.cancel() to prevent delivery. Store batch IDs in your database alongside appointment records for full management capabilities.

Example:

javascript
// Cancel a scheduled batch
await sinchClient.sms.batches.cancel({ batch_id: 'your-batch-id' });

// Update a scheduled batch
await sinchClient.sms.batches.update({
  batch_id: 'your-batch-id',
  sendSMSRequestBody: {
    to: ['+15551234567'],
    from: process.env.FROM_NUMBER,
    body: 'Updated message content',
    send_at: 'new-iso-timestamp'
  }
});

What database schema should I use for storing scheduled appointment reminders?

Create an appointments table with: id (primary key), patient_name, doctor_name, patient_phone (E.164 format), appointment_datetime (TIMESTAMPTZ in UTC), reminder_datetime (TIMESTAMPTZ in UTC), sinch_batch_id (unique, for tracking), status (scheduled/sent/failed/cancelled), and created_at/updated_at timestamps. Add indexes on patient_phone and appointment_datetime for efficient queries.