Building Production-Ready SMS Scheduling & Reminders with Fastify and Infobip
A guide on creating a production-ready SMS scheduling and reminder system using Fastify, Infobip, Prisma, and Node.js, covering setup, implementation, and best practices.
This guide provides a complete walkthrough for building a robust SMS scheduling and reminder system using the Fastify web framework for Node.js and the Infobip communications platform. We will cover everything from project setup to deployment, focusing on production-readiness, best practices, and real-world considerations.
By the end of this tutorial, you will have a functional application capable of:
Scheduling SMS messages to be sent at a specific future time.
Retrieving details of scheduled messages.
Rescheduling messages to a new time.
Checking the status of scheduled messages (e.g., PENDING, CANCELED).
Canceling scheduled messages before they are sent.
Storing and managing scheduled job metadata in a database.
This system solves the common business need for automated, timed communication like appointment reminders, payment due date notifications, event follow-ups, and abandoned cart recovery messages, helping to reduce no-shows, improve engagement, and save manual effort.
Project Overview and Goals
We aim to build a backend service using Fastify that exposes a RESTful API for managing scheduled SMS messages via Infobip.
Technologies Used:
Node.js: The runtime environment for our JavaScript backend (ES Module syntax).
Fastify: A high-performance, low-overhead web framework for Node.js, chosen for its speed, extensibility, and developer-friendly features (like built-in validation and logging).
Infobip API: The third-party service used for sending and managing SMS messages. Chosen for its global reach, reliability, and specific features for scheduling and managing bulk messages.
Axios: A promise-based HTTP client for making requests to the Infobip API from our Node.js application.
Prisma: A modern ORM (Object-Relational Mapper) for Node.js and TypeScript, used for database interactions (schema definition, migrations, type-safe queries). We'll use it with SQLite for simplicity in this guide, but it easily adapts to PostgreSQL, MySQL, etc.
dotenv: A module to load environment variables from a .env file into process.env.
Client: Initiates requests to schedule, view, reschedule, or cancel SMS messages.
Fastify App: Receives requests, validates input, interacts with the InfobipService and DatabaseService.
InfobipService: Contains logic to interact with the Infobip API endpoints for scheduling and management.
DatabaseService (via Prisma): Stores metadata about scheduled jobs (like the bulkId returned by Infobip) and potentially links it to internal application data.
Infobip API: The external service that actually handles the SMS scheduling and sending.
Database: Persists information about the scheduled jobs managed by our application.
Prerequisites:
Node.js (v18 or later recommended) and npm/yarn installed.
An active Infobip account (Sign up for free). You'll need your API Key and Base URL.
Basic understanding of JavaScript (including ES Modules), Node.js, REST APIs, and asynchronous programming.
A code editor (like VS Code).
Optional: Docker Desktop installed for containerization.
Optional: Postman or curl for testing API endpoints.
Final Outcome:
A deployable Fastify application with API endpoints to fully manage the lifecycle of scheduled SMS messages via Infobip, complete with logging, error handling, database persistence, and basic security considerations.
1. Setting up the Project
Let's initialize our Fastify project and set up the basic structure and dependencies.
Step 1: Initialize Node.js Project
Open your terminal and create a new project directory:
Bash
mkdir fastify-infobip-scheduler
cd fastify-infobip-scheduler
npm init -y
Step 2: Configure package.json for ES Modules
Since we'll be using import/export syntax throughout the project, add "type": "module" to your package.json:
JSON
// package.json{"name":"fastify-infobip-scheduler","version":"1.0.0","description":"","main":"src/server.js","type":"module","scripts":{// ... scripts will be added below ...},"keywords":[],"author":"","license":"ISC"// ... dependencies will be added below ...}
Why: This tells Node.js to interpret .js files in this project as ES Modules, allowing the use of import and export statements natively.
# .env.template
# Infobip Credentials (Get from your Infobip account dashboard)
INFOBIP_BASE_URL=your_infobip_base_url_here # e.g., xyz.api.infobip.com
INFOBIP_API_KEY=your_infobip_api_key_here
# Application Settings
PORT=3000
HOST=0.0.0.0
LOG_LEVEL=info
NODE_ENV=development # Set to 'production' in production
# Database (Using SQLite for this guide)
DATABASE_URL="file:./prisma/dev.db"
# Sentry DSN (Optional - for error tracking in Section 10)
# SENTRY_DSN=your_sentry_dsn_here
Now, create a .env file by copying .env.template and fill in your actual Infobip Base URL and API Key. Never commit your .env file to version control.
Why: Using @fastify/env provides automatic validation and type coercion for environment variables, preventing runtime errors due to misconfiguration. It centralizes environment management.
Step 10: Configure Prisma (prisma/schema.prisma)
Define the database schema. We'll create a simple table to track our scheduled jobs.
Prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite" // Or postgresql, mysql, etc.
url = env("DATABASE_URL")
}
// Model to store information about scheduled SMS jobs
model ScheduledSmsJob {
id String @id @default(cuid()) // Unique job ID in our system
bulkId String @unique // The Bulk ID returned by Infobip
status String // e.g., PENDING, CANCELED, (Could be an enum later)
scheduledAt DateTime // The time the message is scheduled to be sent
recipient String // Store the recipient phone number
messageText String // Store the message content (optional, for reference)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add indexes for frequently queried fields
@@index([status])
@@index([scheduledAt])
}
Why: This schema allows us to map the Infobip bulkId to an internal record, track its intended scheduledAt time, status (which we'll manage based on Infobip responses), and other relevant details like the recipient and message text for easier debugging or application logic. @unique on bulkId ensures we don't have duplicate entries for the same Infobip job. Added indexes on status and scheduledAt for potential performance improvements.
Step 11: Initialize Prisma Database
Run the following command to create the initial database file and migration:
Bash
npx prisma migrate dev --name init
This will:
Create the prisma/migrations directory containing the SQL migration file.
Create the dev.db SQLite file (or prompt for database creation if using another provider).
Apply the migration.
Generate the Prisma Client (@prisma/client).
You can inspect the database using Prisma Studio:
Bash
npx prisma studio
2. Implementing Core Functionality (Services)
Now, let's create the services that encapsulate the logic for interacting with Infobip and our database.
Step 1: Implement Database Service (src/services/databaseService.js)
This service will handle all interactions with our ScheduledSmsJob table using Prisma Client.
JavaScript
// src/services/databaseService.jsimport { PrismaClient } from'@prisma/client';
// Instantiate Prisma Client - it manages connection poolingconst prisma = newPrismaClient();
asyncfunctionsaveJob(data) {
try {
returnawait prisma.scheduledSmsJob.create({ data });
} catch (error) {
// Log the error appropriately using a proper logger in a real appconsole.error('Error saving job to database:', error);
// Consider throwing a custom error classthrownewError('Failed to save job metadata');
}
}
asyncfunctionfindJobByBulkId(bulkId) {
try {
returnawait prisma.scheduledSmsJob.findUnique({ where: { bulkId } });
} catch (error) {
console.error(`Error finding job by bulkId ${bulkId}:`, error);
thrownewError('Failed to retrieve job metadata');
}
}
asyncfunctionupdateJobStatus(bulkId, status, scheduledAt = undefined) {
try {
const updateData = { status, updatedAt: newDate() }; // Ensure updatedAt is updatedif (scheduledAt) {
updateData.scheduledAt = scheduledAt;
}
returnawait prisma.scheduledSmsJob.update({
where: { bulkId },
data: updateData,
});
} catch (error) {
// Prisma throws specific errors (e.g., P2025 for record not found)// Handle these specifically if neededconsole.error(`Error updating job status for bulkId ${bulkId}:`, error);
thrownewError('Failed to update job status');
}
}
// Graceful shutdown for Prisma ClientasyncfunctiondisconnectPrisma() {
await prisma.$disconnect();
}
exportdefault {
prisma, // Export instance if needed elsewhere (e.g., health check)
saveJob,
findJobByBulkId,
updateJobStatus,
disconnectPrisma,
// Export other functions
};
Why: Encapsulating database logic in a service makes the code modular, testable, and easier to manage. It separates database concerns from API/controller logic. Includes basic error handling and exports the Prisma instance and a disconnect function for graceful shutdown.
Step 2: Implement Infobip Service (src/services/infobipService.js)
This service handles all communication with the Infobip API.
JavaScript
// src/services/infobipService.jsimport axios from'axios';
import { v4 as uuidv4 } from'uuid';
// Configuration will be injected via initializeInfobipServiceletINFOBIP_BASE_URL;
letINFOBIP_API_KEY;
let infobipApi; // Axios instance/**
* Initializes the Infobip service with configuration.
* Call this once during application startup (e.g., in app.js).
* Note: A dependency injection pattern (using Fastify decorators or a DI container)
* is generally preferred in larger applications over this explicit initialization.
*/functioninitializeInfobipService(config) {
if (!config.INFOBIP_BASE_URL || !config.INFOBIP_API_KEY) {
thrownewError('Infobip Base URL and API Key must be configured.');
}
INFOBIP_BASE_URL = config.INFOBIP_BASE_URL;
INFOBIP_API_KEY = config.INFOBIP_API_KEY;
// Create Axios instance after config is loaded
infobipApi = axios.create();
// Add interceptors to the instance
infobipApi.interceptors.request.use((axiosConfig) => {
axiosConfig.baseURL = `https://${INFOBIP_BASE_URL}`;
axiosConfig.headers['Authorization'] = `App ${INFOBIP_API_KEY}`;
axiosConfig.headers['Content-Type'] = 'application/json';
axiosConfig.headers['Accept'] = 'application/json';
return axiosConfig;
}, (error) => {
returnPromise.reject(error);
});
}
/**
* Schedules an SMS message for future delivery.
* IMPORTANT: Endpoint path and response structure must be verified against
* official Infobip API documentation. This implementation is based on common patterns.
* @param {string} recipient - E.164 format recommended.
* @param {string} messageText - SMS content.
* @param {string} sendAt - ISO 8601 format (e.g., ""2025-07-01T16:00:00.000Z"").
* @param {string} [sender=""InfoSMS""] - Sender ID (check regulations).
* @returns {Promise<{bulkId: string, status: string, sendAt: string, apiResponse: object}>}
*/asyncfunctionscheduleSms(recipient, messageText, sendAt, sender = 'InfoSMS') {
if (!infobipApi) thrownewError('Infobip service not initialized.');
const bulkId = `bulk-${uuidv4()}`; // Generate unique ID client-sideconst payload = {
messages: [
{
destinations: [{ to: recipient }],
from: sender,
text: messageText,
},
],
bulkId: bulkId,
sendAt: sendAt,
};
const endpoint = '/sms/2/bulks'; // VERIFY THIS ENDPOINT PATHconsole.info(`Scheduling SMS via Infobip: POST ${endpoint} for recipient ${recipient}`);
try {
const response = await infobipApi.post(endpoint, payload);
// --- VERIFICATION NEEDED ---// Verify the exact structure of the success response from Infobip for scheduling.// Does it return the bulkId? Does it return an initial status?const returnedBulkId = response.data?.bulkId; // Example path, confirm!const initialStatus = response.data?.status || 'PENDING'; // Example path/default, confirm!if (!returnedBulkId || returnedBulkId !== bulkId) {
console.warn(`Infobip schedule response bulkId mismatch or missing. Sent: ${bulkId}, Received: ${returnedBulkId}`);
// Decide how to handle: throw error, use generated bulkId anyway? Using generated one for now.
}
return {
bulkId: bulkId, // Return the ID we generated and sentstatus: initialStatus, // Use status from response if available, otherwise assume PENDING (NEEDS CONFIRMATION)sendAt: sendAt,
apiResponse: response.data// Return full response for logging/debugging
};
} catch (error) {
const errorData = error.response?.data;
const errorMessage = errorData?.requestError?.serviceException?.text || error.message;
console.error(`Infobip API Error (scheduleSms - POST ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData);
thrownewError(`Failed to schedule SMS via Infobip: ${errorMessage}`);
}
}
/**
* Retrieves details of scheduled messages for a specific bulk ID.
* IMPORTANT: Endpoint path and response structure must be verified.
* @param {string} bulkId
* @returns {Promise<object>} - The bulk details from Infobip API response.
*/asyncfunctiongetScheduledSms(bulkId) {
if (!infobipApi) thrownewError('Infobip service not initialized.');
if (!bulkId) thrownewError('Bulk ID is required.');
const endpoint = '/sms/2/bulks'; // VERIFY THIS ENDPOINT PATHconsole.info(`Getting scheduled SMS via Infobip: GET ${endpoint}?bulkId=${bulkId}`);
try {
const response = await infobipApi.get(endpoint, { params: { bulkId } });
// --- VERIFICATION NEEDED ---// Verify the response structure when querying by bulkId.// Does it return an array `bulks`? Is the bulk data directly in `response.data`?const bulks = response.data?.bulks; // Example path, confirm!if (bulks && bulks.length > 0) {
return bulks[0]; // Return the first matching bulk
} elseif (response.data?.bulkId === bulkId) {
// Alternative structure check: if the response IS the bulk object directlyreturn response.data;
} else {
thrownewError(`Scheduled bulk ${bulkId} not found in Infobip response.`);
}
} catch (error) {
const errorData = error.response?.data;
const errorMessage = errorData?.requestError?.serviceException?.text || error.message;
console.error(`Infobip API Error (getScheduledSms - GET ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData);
if (error.response?.status === 404 || errorMessage.toLowerCase().includes('not found')) {
thrownewError(`Scheduled bulk ${bulkId} not found.`);
}
thrownewError(`Failed to retrieve scheduled SMS ${bulkId}: ${errorMessage}`);
}
}
/**
* Reschedules a bulk of messages to a new time.
* IMPORTANT: Endpoint path and response structure must be verified.
* @param {string} bulkId
* @param {string} newSendAt - ISO 8601 format.
* @returns {Promise<object>} - The updated bulk info from Infobip API response.
*/asyncfunctionrescheduleSms(bulkId, newSendAt) {
if (!infobipApi) thrownewError('Infobip service not initialized.');
if (!bulkId || !newSendAt) thrownewError('Bulk ID and new SendAt time are required.');
const endpoint = '/sms/2/bulks'; // VERIFY THIS ENDPOINT PATH (often PUT /sms/2/bulks?bulkId=...)const payload = { sendAt: newSendAt };
console.info(`Rescheduling SMS via Infobip: PUT ${endpoint}?bulkId=${bulkId}`);
try {
// Note: PUT might require bulkId in query params, check docsconst response = await infobipApi.put(endpoint, payload, { params: { bulkId } });
// --- VERIFICATION NEEDED ---// Verify the success response structure for rescheduling.// Does it return { bulkId, sendAt }?return response.data;
} catch (error) {
const errorData = error.response?.data;
const errorMessage = errorData?.requestError?.serviceException?.text || error.message;
console.error(`Infobip API Error (rescheduleSms - PUT ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData);
if (error.response?.status === 404 || errorMessage.toLowerCase().includes('not found')) {
thrownewError(`Scheduled bulk ${bulkId} not found for rescheduling.`);
}
// Handle other errors e.g., trying to reschedule already sent bulk (might be 400/409)thrownewError(`Failed to reschedule SMS ${bulkId}: ${errorMessage}`);
}
}
/**
* Retrieves the status of a scheduled bulk.
* IMPORTANT: Endpoint path and response structure must be verified.
* @param {string} bulkId
* @returns {Promise<object>} - Status info from Infobip API response (e.g., { bulkId, status }).
*/asyncfunctiongetScheduledSmsStatus(bulkId) {
if (!infobipApi) thrownewError('Infobip service not initialized.');
if (!bulkId) thrownewError('Bulk ID is required.');
const endpoint = '/sms/2/bulks/status'; // VERIFY THIS ENDPOINT PATHconsole.info(`Getting status via Infobip: GET ${endpoint}?bulkId=${bulkId}`);
try {
const response = await infobipApi.get(endpoint, { params: { bulkId } });
// --- VERIFICATION NEEDED ---// Verify the response structure for status query.// Does it return an array `bulks` with status?const bulks = response.data?.bulks; // Example path, confirm!if (bulks && bulks.length > 0) {
return bulks[0]; // Return status info { bulkId, status }
} elseif (response.data?.bulkId === bulkId && response.data?.status) {
// Alternative structure checkreturn response.data;
} else {
thrownewError(`Scheduled bulk status for ${bulkId} not found in Infobip response.`);
}
} catch (error) {
const errorData = error.response?.data;
const errorMessage = errorData?.requestError?.serviceException?.text || error.message;
console.error(`Infobip API Error (getScheduledSmsStatus - GET ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData);
if (error.response?.status === 404 || errorMessage.toLowerCase().includes('not found')) {
thrownewError(`Scheduled bulk status for ${bulkId} not found.`);
}
thrownewError(`Failed to get scheduled SMS status for ${bulkId}: ${errorMessage}`);
}
}
/**
* Updates the status of a scheduled bulk (e.g., cancels it).
* IMPORTANT: Endpoint path and response structure must be verified.
* @param {string} bulkId
* @param {string} status - New status (e.g., ""CANCELED"").
* @returns {Promise<object>} - Updated status info from Infobip API response.
*/asyncfunctionupdateScheduledSmsStatus(bulkId, status) {
if (!infobipApi) thrownewError('Infobip service not initialized.');
if (!bulkId || !status) thrownewError('Bulk ID and status are required.');
// Only support cancellation for now via this specific functionif (status.toUpperCase() !== 'CANCELED') {
thrownewError('Only cancellation (""CANCELED"") is supported via this status update function.');
}
const endpoint = '/sms/2/bulks/status'; // VERIFY THIS ENDPOINT PATHconst payload = { status: status.toUpperCase() }; // Infobip likely expects uppercase statusconsole.info(`Updating status via Infobip: PUT ${endpoint}?bulkId=${bulkId}`);
try {
// Note: PUT might require bulkId in query params, check docsconst response = await infobipApi.put(endpoint, payload, { params: { bulkId } });
// --- VERIFICATION NEEDED ---// Verify the success response structure for status update.// Does it return { bulkId, status }?return response.data;
} catch (error) {
const errorData = error.response?.data;
const errorMessage = errorData?.requestError?.serviceException?.text || error.message;
console.error(`Infobip API Error (updateScheduledSmsStatus - PUT ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData);
if (error.response?.status === 404 || errorMessage.toLowerCase().includes('not found')) {
thrownewError(`Scheduled bulk ${bulkId} not found for status update.`);
}
// Handle other errors e.g., trying to cancel already sent bulk (might be 400/409)thrownewError(`Failed to update scheduled SMS status for ${bulkId}: ${errorMessage}`);
}
}
exportdefault {
initializeInfobipService,
scheduleSms,
getScheduledSms,
rescheduleSms,
getScheduledSmsStatus,
updateScheduledSmsStatus,
};
Why: This service abstracts Infobip API interactions.
Uses a centralized axios instance with interceptors.
Maps functions to business actions and potential Infobip endpoints.
Generates unique bulkId client-side using uuid.
Includes stronger comments emphasizing the need to verify Infobip endpoints and response structures against official documentation.
Basic error handling logs Infobip errors and throws application errors.
Uses an initialize function for simplicity, with notes on DI being a better pattern for larger apps.
3. Building the API Layer (Routes and Controllers)
Let's expose the service functionality through Fastify routes and controllers.
Handles requests, validates input, calls services, and formats responses.
JavaScript
// src/controllers/smsController.jsimport infobipService from'../services/infobipService.js';
import databaseService from'../services/databaseService.js';
// --- Request Validation Schemas ---// Suggestion: Use a library like libphonenumber-js for more robust validation in productionconst phoneRegex = '^\\+[1-9]\\d{1,14}$'; // Basic E.164 check, anchored start/endconst scheduleSmsSchema = {
body: {
type: 'object',
required: ['recipient', 'messageText', 'sendAt'],
properties: {
recipient: {
type: 'string',
description: 'E.164 format required (e.g., +12223334444)',
pattern: phoneRegex, // Basic anchored E.164 checkerrorMessage: 'Recipient must be a valid phone number in E.164 format (e.g., +12223334444)'
},
messageText: { type: 'string', minLength: 1, maxLength: 1600 }, // Check SMS length limitssendAt: {
type: 'string',
format: 'date-time', // Validates ISO 8601 formatdescription: 'ISO 8601 format required (e.g., 2025-09-01T12:00:00.000Z)',
errorMessage: 'sendAt must be a valid ISO 8601 date-time string'
},
sender: { type: 'string', description: 'Optional sender ID' },
},
additionalProperties: false,
},
response: {
201: { // Use 201 Created for successful schedulingdescription: 'SMS scheduled successfully',
type: 'object',
properties: {
message: { type: 'string' },
jobId: { type: 'string', description: 'Internal application job ID' },
bulkId: { type: 'string', description: 'Infobip bulk ID' },
status: { type: 'string', description: 'Initial status (e.g., PENDING)' },
scheduledAt: { type: 'string', format: 'date-time' },
},
},
// Add other response codes (e.g., 400, 500) using Fastify's standard error handling or setErrorHandler
},
};
const bulkIdParamsSchema = {
params: {
type: 'object',
required: ['bulkId'],
properties: {
bulkId: { type: 'string', description: 'The Infobip Bulk ID (usually starts with bulk-)' }
},
additionalProperties: false,
}
};
const rescheduleSmsSchema = {
...bulkIdParamsSchema, // Include params validationbody: {
type: 'object',
required: ['sendAt'],
properties: {
sendAt: {
type: 'string',
format: 'date-time',
description: 'New ISO 8601 scheduled time',
errorMessage: 'sendAt must be a valid ISO 8601 date-time string'
},
},
additionalProperties: false,
},
response: {
200: {
description: 'SMS rescheduled successfully',
type: 'object',
properties: {
message: { type: 'string' },
bulkId: { type: 'string' },
newScheduledAt: { type: 'string', format: 'date-time', description: 'Confirmed new schedule time from Infobip' },
},
},
},
};
const updateStatusSchema = {
...bulkIdParamsSchema, // Include params validationbody: {
type: 'object',
required: ['status'],
properties: {
status: {
type: 'string',
enum: ['CANCELED'], // Only allow canceling explicitly via this endpointdescription: 'Must be "CANCELED" to cancel the scheduled SMS',
errorMessage: 'Status must be "CANCELED"'
}
},
additionalProperties: false,
},
response: {
200: {
description: 'SMS status updated successfully',
type: 'object',
properties: {
message: { type: 'string' },
bulkId: { type: 'string' },
status: { type: 'string', description: 'Confirmed status from Infobip (e.g., CANCELED)' },
},
},
},
};
// --- Controller Functions ---asyncfunctionscheduleSmsHandler(request, reply) {
const { recipient, messageText, sendAt, sender } = request.body;
// Additional business logic validation (optional)const scheduleDate = newDate(sendAt);
if (scheduleDate <= newDate()) {
return reply.code(400).send({ error: 'Bad Request', message: 'sendAt must be in the future.' });
}
// Add check for maximum schedule date if neededtry {
request.log.info(`Received schedule request for ${recipient} at ${sendAt}`);
// 1. Call Infobip to scheduleconst infobipResponse = await infobipService.scheduleSms(recipient, messageText, sendAt, sender);
request.log.info(`Infobip scheduling initiated for bulkId: ${infobipResponse.bulkId}, initial status: ${infobipResponse.status}`);
// 2. Save job metadata to our databaseconst jobData = {
bulkId: infobipResponse.bulkId,
status: infobipResponse.status, // Use status from Infobip responsescheduledAt: newDate(sendAt), // Store as Date objectrecipient: recipient,
messageText: messageText, // Store for reference
};
const savedJob = await databaseService.saveJob(jobData);
request.log.info(`Saved scheduled job metadata with internal ID: ${savedJob.id}`);
// 3. Return success responsereturn reply.code(201).send({
message: 'SMS scheduled successfully.',
jobId: savedJob.id,
bulkId: savedJob.bulkId,
status: savedJob.status,
scheduledAt: savedJob.scheduledAt.toISOString(),
});
} catch (error) {
request.log.error({ err: error }, `Error scheduling SMS for ${recipient}: ${error.message}`);
// Let Fastify's default error handler (or a custom one) handle the response// It will typically return a 500 Internal Server Error// You might want to check error types (e.g., Infobip vs DB errors) for more specific responsesthrow error; // Re-throw for Fastify's error handling
}
}
// ... (Rest of the controller functions: getSmsHandler, rescheduleSmsHandler, getStatusHandler, cancelSmsHandler would go here) ...// Export necessary itemsexport {
scheduleSmsSchema,
bulkIdParamsSchema,
rescheduleSmsSchema,
updateStatusSchema,
scheduleSmsHandler,
// Export other handlers...
};
Frequently asked questions
You can schedule SMS messages by sending a POST request to the /sms/2/bulks endpoint of your Fastify application. This request should include the recipient's phone number, the message text, the desired send time (in ISO 8601 format), and optionally a sender ID. The application will then interact with the Infobip API to schedule the message.
Prisma acts as an Object-Relational Mapper (ORM), simplifying database interactions. It is used to define the database schema, manage migrations, and execute type-safe queries. In this project, Prisma interacts with an SQLite database to store metadata about scheduled SMS jobs, but it's compatible with other databases like PostgreSQL and MySQL as well.
Fastify is a high-performance Node.js web framework known for its speed, extensibility, and developer-friendly features. It offers built-in validation, logging, and a plugin-based architecture, making it a suitable choice for building robust and efficient backend services like this SMS scheduler.
A unique bulk ID, generated using the uuid library, should be created client-side before making the API call to Infobip to schedule a message. This bulkId helps to track and manage the scheduled message within your system and correlate it with Infobip's responses.
Create a .env file in the project's root directory. Store sensitive information like the Infobip API key and base URL, database URL, and port number. Use the @fastify/env module along with a schema defined in src/config/environment.js for secure loading and validation.
You can retrieve the status of a scheduled message by sending a GET request to the /sms/2/bulks/status endpoint with the bulkId as a query parameter. The response from the Infobip API, routed through your Fastify app, will indicate whether the message is pending, canceled, or sent.
The ScheduledSmsJob model, managed via Prisma, stores key information about scheduled SMS jobs: a unique internal id, the Infobip bulkId, current status, scheduledAt timestamp, recipient phone number, message content, and timestamps for creation and updates.
While optional, Sentry integration is recommended for production applications. Include your Sentry DSN in the .env file and configure Sentry within your Fastify application to automatically capture and report errors, enhancing monitoring and debugging capabilities.
The system follows a client-server architecture where a client (e.g., UI or Postman) interacts with a Fastify backend application. The backend communicates with an Infobip API for sending and scheduling SMS messages and a database (SQLite) for storing scheduling metadata.
Yes, you can reschedule SMS messages through a PUT request to the /sms/2/bulks endpoint. Supply the bulkId and the new desired send time (sendAt in ISO 8601 format) in the request body.
The databaseService.js file contains functions for interacting with the database. These functions, built upon Prisma, allow you to save job details, retrieve jobs by their bulkId, and update job status information.
You need Node.js (v18 or later is recommended), npm or yarn, an active Infobip account (signup is available online), basic understanding of JavaScript and RESTful APIs, a code editor, and optionally, Docker and Postman or cURL for testing.
Axios is a promise-based HTTP client used for making API requests to the Infobip platform from your Node.js application. It simplifies the process of sending HTTP requests and handling responses within the project.
Pino is the high-performance logger employed within this project. It automatically formats logs in JSON for improved analysis and debugging. For human-readable logs during development, Pino Pretty can be used through npm scripts.
This guide provides a complete walkthrough for building a robust SMS scheduling and reminder system using the Fastify web framework for Node.js and the Infobip communications platform. We will cover everything from project setup to deployment, focusing on production-readiness, best practices, and real-world considerations.
By the end of this tutorial, you will have a functional application capable of:
This system solves the common business need for automated, timed communication like appointment reminders, payment due date notifications, event follow-ups, and abandoned cart recovery messages, helping to reduce no-shows, improve engagement, and save manual effort.
Project Overview and Goals
We aim to build a backend service using Fastify that exposes a RESTful API for managing scheduled SMS messages via Infobip.
Technologies Used:
.envfile intoprocess.env.System Architecture:
InfobipServiceandDatabaseService.bulkIdreturned by Infobip) and potentially links it to internal application data.Prerequisites:
curlfor testing API endpoints.Final Outcome:
A deployable Fastify application with API endpoints to fully manage the lifecycle of scheduled SMS messages via Infobip, complete with logging, error handling, database persistence, and basic security considerations.
1. Setting up the Project
Let's initialize our Fastify project and set up the basic structure and dependencies.
Step 1: Initialize Node.js Project
Open your terminal and create a new project directory:
mkdir fastify-infobip-scheduler cd fastify-infobip-scheduler npm init -yStep 2: Configure
package.jsonfor ES ModulesSince we'll be using
import/exportsyntax throughout the project, add"type": "module"to yourpackage.json:// package.json { "name": "fastify-infobip-scheduler", "version": "1.0.0", "description": "", "main": "src/server.js", "type": "module", "scripts": { // ... scripts will be added below ... }, "keywords": [], "author": "", "license": "ISC" // ... dependencies will be added below ... }.jsfiles in this project as ES Modules, allowing the use ofimportandexportstatements natively.Step 3: Install Fastify and Core Dependencies
fastify: The core framework.@fastify/env: For loading and validating environment variables.axios: For making HTTP requests to the Infobip API.prisma,@prisma/client: The Prisma CLI and runtime client.dotenv: Loads.envfile (though@fastify/envhandles this too,dotenvcan be useful for scripts like seeding).uuid: To generate unique bulk IDs for Infobip requests.Step 4: Install Development Dependencies
nodemon: Automatically restarts the server during development on file changes.concurrently: Runs multiple commands concurrently (useful for watching files and running the server).pino-pretty: For human-readable logs during development (used only by thedevscript).typescript,ts-node,@types/node: Although we write JS, Prisma works best with TS tooling installed for generation.jest,@types/jest: Testing framework (as discussed in Section 13).Step 5: Configure
package.jsonScriptsAdd the following scripts to your
package.json:// package.json { // ... other configurations like name, version, type: "module" ... "scripts": { "dev": "nodemon src/server.js | pino-pretty", "start": "node src/server.js", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", "prisma:seed": "node prisma/seed.js", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js" }, // ... dependencies and devDependencies ... }testscript includesnode --experimental-vm-moduleswhich is often required for Jest to work correctly with ES Modules.Step 6: Set up Project Structure
Create the following directories and files:
Step 7: Create
.gitignoreCreate a
.gitignorefile in the root directory. Migration SQL files should be committed.Step 8: Create
.env.templateand.envCreate
.env.templatewith the required variables:Now, create a
.envfile by copying.env.templateand fill in your actual Infobip Base URL and API Key. Never commit your.envfile to version control.Step 9: Configure Environment Loading (
src/config/environment.js)This file defines the schema for
@fastify/envto validate and load environment variables.// src/config/environment.js const environmentSchema = { type: 'object', required: ['INFOBIP_BASE_URL', 'INFOBIP_API_KEY', 'DATABASE_URL', 'PORT', 'HOST'], properties: { INFOBIP_BASE_URL: { type: 'string', description: 'Base URL for Infobip API' }, INFOBIP_API_KEY: { type: 'string', description: 'API Key for Infobip' }, DATABASE_URL: { type: 'string', description: 'Connection URL for the database' }, PORT: { type: 'number', default: 3000 }, HOST: { type: 'string', default: '0.0.0.0' }, LOG_LEVEL: { type: 'string', default: 'info' }, NODE_ENV: { type: 'string', enum: ['development', 'production', 'test'], default: 'development'}, SENTRY_DSN: { type: 'string', description: 'Sentry DSN for error tracking (Optional)' } }, }; const options = { confKey: 'config', // Access variables via `fastify.config` schema: environmentSchema, dotenv: true, // Load .env file // data: process.env // Optional: Explicitly pass process.env if needed }; export default options;@fastify/envprovides automatic validation and type coercion for environment variables, preventing runtime errors due to misconfiguration. It centralizes environment management.Step 10: Configure Prisma (
prisma/schema.prisma)Define the database schema. We'll create a simple table to track our scheduled jobs.
bulkIdto an internal record, track its intendedscheduledAttime,status(which we'll manage based on Infobip responses), and other relevant details like the recipient and message text for easier debugging or application logic.@uniqueonbulkIdensures we don't have duplicate entries for the same Infobip job. Added indexes onstatusandscheduledAtfor potential performance improvements.Step 11: Initialize Prisma Database
Run the following command to create the initial database file and migration:
This will:
prisma/migrationsdirectory containing the SQL migration file.dev.dbSQLite file (or prompt for database creation if using another provider).@prisma/client).You can inspect the database using Prisma Studio:
2. Implementing Core Functionality (Services)
Now, let's create the services that encapsulate the logic for interacting with Infobip and our database.
Step 1: Implement Database Service (
src/services/databaseService.js)This service will handle all interactions with our
ScheduledSmsJobtable using Prisma Client.// src/services/databaseService.js import { PrismaClient } from '@prisma/client'; // Instantiate Prisma Client - it manages connection pooling const prisma = new PrismaClient(); async function saveJob(data) { try { return await prisma.scheduledSmsJob.create({ data }); } catch (error) { // Log the error appropriately using a proper logger in a real app console.error('Error saving job to database:', error); // Consider throwing a custom error class throw new Error('Failed to save job metadata'); } } async function findJobByBulkId(bulkId) { try { return await prisma.scheduledSmsJob.findUnique({ where: { bulkId } }); } catch (error) { console.error(`Error finding job by bulkId ${bulkId}:`, error); throw new Error('Failed to retrieve job metadata'); } } async function updateJobStatus(bulkId, status, scheduledAt = undefined) { try { const updateData = { status, updatedAt: new Date() }; // Ensure updatedAt is updated if (scheduledAt) { updateData.scheduledAt = scheduledAt; } return await prisma.scheduledSmsJob.update({ where: { bulkId }, data: updateData, }); } catch (error) { // Prisma throws specific errors (e.g., P2025 for record not found) // Handle these specifically if needed console.error(`Error updating job status for bulkId ${bulkId}:`, error); throw new Error('Failed to update job status'); } } // Graceful shutdown for Prisma Client async function disconnectPrisma() { await prisma.$disconnect(); } export default { prisma, // Export instance if needed elsewhere (e.g., health check) saveJob, findJobByBulkId, updateJobStatus, disconnectPrisma, // Export other functions };Step 2: Implement Infobip Service (
src/services/infobipService.js)This service handles all communication with the Infobip API.
// src/services/infobipService.js import axios from 'axios'; import { v4 as uuidv4 } from 'uuid'; // Configuration will be injected via initializeInfobipService let INFOBIP_BASE_URL; let INFOBIP_API_KEY; let infobipApi; // Axios instance /** * Initializes the Infobip service with configuration. * Call this once during application startup (e.g., in app.js). * Note: A dependency injection pattern (using Fastify decorators or a DI container) * is generally preferred in larger applications over this explicit initialization. */ function initializeInfobipService(config) { if (!config.INFOBIP_BASE_URL || !config.INFOBIP_API_KEY) { throw new Error('Infobip Base URL and API Key must be configured.'); } INFOBIP_BASE_URL = config.INFOBIP_BASE_URL; INFOBIP_API_KEY = config.INFOBIP_API_KEY; // Create Axios instance after config is loaded infobipApi = axios.create(); // Add interceptors to the instance infobipApi.interceptors.request.use((axiosConfig) => { axiosConfig.baseURL = `https://${INFOBIP_BASE_URL}`; axiosConfig.headers['Authorization'] = `App ${INFOBIP_API_KEY}`; axiosConfig.headers['Content-Type'] = 'application/json'; axiosConfig.headers['Accept'] = 'application/json'; return axiosConfig; }, (error) => { return Promise.reject(error); }); } /** * Schedules an SMS message for future delivery. * IMPORTANT: Endpoint path and response structure must be verified against * official Infobip API documentation. This implementation is based on common patterns. * @param {string} recipient - E.164 format recommended. * @param {string} messageText - SMS content. * @param {string} sendAt - ISO 8601 format (e.g., ""2025-07-01T16:00:00.000Z""). * @param {string} [sender=""InfoSMS""] - Sender ID (check regulations). * @returns {Promise<{bulkId: string, status: string, sendAt: string, apiResponse: object}>} */ async function scheduleSms(recipient, messageText, sendAt, sender = 'InfoSMS') { if (!infobipApi) throw new Error('Infobip service not initialized.'); const bulkId = `bulk-${uuidv4()}`; // Generate unique ID client-side const payload = { messages: [ { destinations: [{ to: recipient }], from: sender, text: messageText, }, ], bulkId: bulkId, sendAt: sendAt, }; const endpoint = '/sms/2/bulks'; // VERIFY THIS ENDPOINT PATH console.info(`Scheduling SMS via Infobip: POST ${endpoint} for recipient ${recipient}`); try { const response = await infobipApi.post(endpoint, payload); // --- VERIFICATION NEEDED --- // Verify the exact structure of the success response from Infobip for scheduling. // Does it return the bulkId? Does it return an initial status? const returnedBulkId = response.data?.bulkId; // Example path, confirm! const initialStatus = response.data?.status || 'PENDING'; // Example path/default, confirm! if (!returnedBulkId || returnedBulkId !== bulkId) { console.warn(`Infobip schedule response bulkId mismatch or missing. Sent: ${bulkId}, Received: ${returnedBulkId}`); // Decide how to handle: throw error, use generated bulkId anyway? Using generated one for now. } return { bulkId: bulkId, // Return the ID we generated and sent status: initialStatus, // Use status from response if available, otherwise assume PENDING (NEEDS CONFIRMATION) sendAt: sendAt, apiResponse: response.data // Return full response for logging/debugging }; } catch (error) { const errorData = error.response?.data; const errorMessage = errorData?.requestError?.serviceException?.text || error.message; console.error(`Infobip API Error (scheduleSms - POST ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData); throw new Error(`Failed to schedule SMS via Infobip: ${errorMessage}`); } } /** * Retrieves details of scheduled messages for a specific bulk ID. * IMPORTANT: Endpoint path and response structure must be verified. * @param {string} bulkId * @returns {Promise<object>} - The bulk details from Infobip API response. */ async function getScheduledSms(bulkId) { if (!infobipApi) throw new Error('Infobip service not initialized.'); if (!bulkId) throw new Error('Bulk ID is required.'); const endpoint = '/sms/2/bulks'; // VERIFY THIS ENDPOINT PATH console.info(`Getting scheduled SMS via Infobip: GET ${endpoint}?bulkId=${bulkId}`); try { const response = await infobipApi.get(endpoint, { params: { bulkId } }); // --- VERIFICATION NEEDED --- // Verify the response structure when querying by bulkId. // Does it return an array `bulks`? Is the bulk data directly in `response.data`? const bulks = response.data?.bulks; // Example path, confirm! if (bulks && bulks.length > 0) { return bulks[0]; // Return the first matching bulk } else if (response.data?.bulkId === bulkId) { // Alternative structure check: if the response IS the bulk object directly return response.data; } else { throw new Error(`Scheduled bulk ${bulkId} not found in Infobip response.`); } } catch (error) { const errorData = error.response?.data; const errorMessage = errorData?.requestError?.serviceException?.text || error.message; console.error(`Infobip API Error (getScheduledSms - GET ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData); if (error.response?.status === 404 || errorMessage.toLowerCase().includes('not found')) { throw new Error(`Scheduled bulk ${bulkId} not found.`); } throw new Error(`Failed to retrieve scheduled SMS ${bulkId}: ${errorMessage}`); } } /** * Reschedules a bulk of messages to a new time. * IMPORTANT: Endpoint path and response structure must be verified. * @param {string} bulkId * @param {string} newSendAt - ISO 8601 format. * @returns {Promise<object>} - The updated bulk info from Infobip API response. */ async function rescheduleSms(bulkId, newSendAt) { if (!infobipApi) throw new Error('Infobip service not initialized.'); if (!bulkId || !newSendAt) throw new Error('Bulk ID and new SendAt time are required.'); const endpoint = '/sms/2/bulks'; // VERIFY THIS ENDPOINT PATH (often PUT /sms/2/bulks?bulkId=...) const payload = { sendAt: newSendAt }; console.info(`Rescheduling SMS via Infobip: PUT ${endpoint}?bulkId=${bulkId}`); try { // Note: PUT might require bulkId in query params, check docs const response = await infobipApi.put(endpoint, payload, { params: { bulkId } }); // --- VERIFICATION NEEDED --- // Verify the success response structure for rescheduling. // Does it return { bulkId, sendAt }? return response.data; } catch (error) { const errorData = error.response?.data; const errorMessage = errorData?.requestError?.serviceException?.text || error.message; console.error(`Infobip API Error (rescheduleSms - PUT ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData); if (error.response?.status === 404 || errorMessage.toLowerCase().includes('not found')) { throw new Error(`Scheduled bulk ${bulkId} not found for rescheduling.`); } // Handle other errors e.g., trying to reschedule already sent bulk (might be 400/409) throw new Error(`Failed to reschedule SMS ${bulkId}: ${errorMessage}`); } } /** * Retrieves the status of a scheduled bulk. * IMPORTANT: Endpoint path and response structure must be verified. * @param {string} bulkId * @returns {Promise<object>} - Status info from Infobip API response (e.g., { bulkId, status }). */ async function getScheduledSmsStatus(bulkId) { if (!infobipApi) throw new Error('Infobip service not initialized.'); if (!bulkId) throw new Error('Bulk ID is required.'); const endpoint = '/sms/2/bulks/status'; // VERIFY THIS ENDPOINT PATH console.info(`Getting status via Infobip: GET ${endpoint}?bulkId=${bulkId}`); try { const response = await infobipApi.get(endpoint, { params: { bulkId } }); // --- VERIFICATION NEEDED --- // Verify the response structure for status query. // Does it return an array `bulks` with status? const bulks = response.data?.bulks; // Example path, confirm! if (bulks && bulks.length > 0) { return bulks[0]; // Return status info { bulkId, status } } else if (response.data?.bulkId === bulkId && response.data?.status) { // Alternative structure check return response.data; } else { throw new Error(`Scheduled bulk status for ${bulkId} not found in Infobip response.`); } } catch (error) { const errorData = error.response?.data; const errorMessage = errorData?.requestError?.serviceException?.text || error.message; console.error(`Infobip API Error (getScheduledSmsStatus - GET ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData); if (error.response?.status === 404 || errorMessage.toLowerCase().includes('not found')) { throw new Error(`Scheduled bulk status for ${bulkId} not found.`); } throw new Error(`Failed to get scheduled SMS status for ${bulkId}: ${errorMessage}`); } } /** * Updates the status of a scheduled bulk (e.g., cancels it). * IMPORTANT: Endpoint path and response structure must be verified. * @param {string} bulkId * @param {string} status - New status (e.g., ""CANCELED""). * @returns {Promise<object>} - Updated status info from Infobip API response. */ async function updateScheduledSmsStatus(bulkId, status) { if (!infobipApi) throw new Error('Infobip service not initialized.'); if (!bulkId || !status) throw new Error('Bulk ID and status are required.'); // Only support cancellation for now via this specific function if (status.toUpperCase() !== 'CANCELED') { throw new Error('Only cancellation (""CANCELED"") is supported via this status update function.'); } const endpoint = '/sms/2/bulks/status'; // VERIFY THIS ENDPOINT PATH const payload = { status: status.toUpperCase() }; // Infobip likely expects uppercase status console.info(`Updating status via Infobip: PUT ${endpoint}?bulkId=${bulkId}`); try { // Note: PUT might require bulkId in query params, check docs const response = await infobipApi.put(endpoint, payload, { params: { bulkId } }); // --- VERIFICATION NEEDED --- // Verify the success response structure for status update. // Does it return { bulkId, status }? return response.data; } catch (error) { const errorData = error.response?.data; const errorMessage = errorData?.requestError?.serviceException?.text || error.message; console.error(`Infobip API Error (updateScheduledSmsStatus - PUT ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData); if (error.response?.status === 404 || errorMessage.toLowerCase().includes('not found')) { throw new Error(`Scheduled bulk ${bulkId} not found for status update.`); } // Handle other errors e.g., trying to cancel already sent bulk (might be 400/409) throw new Error(`Failed to update scheduled SMS status for ${bulkId}: ${errorMessage}`); } } export default { initializeInfobipService, scheduleSms, getScheduledSms, rescheduleSms, getScheduledSmsStatus, updateScheduledSmsStatus, };axiosinstance with interceptors.bulkIdclient-side usinguuid.initializefunction for simplicity, with notes on DI being a better pattern for larger apps.3. Building the API Layer (Routes and Controllers)
Let's expose the service functionality through Fastify routes and controllers.
Step 1: Create SMS Controller (
src/controllers/smsController.js)Handles requests, validates input, calls services, and formats responses.
// src/controllers/smsController.js import infobipService from '../services/infobipService.js'; import databaseService from '../services/databaseService.js'; // --- Request Validation Schemas --- // Suggestion: Use a library like libphonenumber-js for more robust validation in production const phoneRegex = '^\\+[1-9]\\d{1,14}$'; // Basic E.164 check, anchored start/end const scheduleSmsSchema = { body: { type: 'object', required: ['recipient', 'messageText', 'sendAt'], properties: { recipient: { type: 'string', description: 'E.164 format required (e.g., +12223334444)', pattern: phoneRegex, // Basic anchored E.164 check errorMessage: 'Recipient must be a valid phone number in E.164 format (e.g., +12223334444)' }, messageText: { type: 'string', minLength: 1, maxLength: 1600 }, // Check SMS length limits sendAt: { type: 'string', format: 'date-time', // Validates ISO 8601 format description: 'ISO 8601 format required (e.g., 2025-09-01T12:00:00.000Z)', errorMessage: 'sendAt must be a valid ISO 8601 date-time string' }, sender: { type: 'string', description: 'Optional sender ID' }, }, additionalProperties: false, }, response: { 201: { // Use 201 Created for successful scheduling description: 'SMS scheduled successfully', type: 'object', properties: { message: { type: 'string' }, jobId: { type: 'string', description: 'Internal application job ID' }, bulkId: { type: 'string', description: 'Infobip bulk ID' }, status: { type: 'string', description: 'Initial status (e.g., PENDING)' }, scheduledAt: { type: 'string', format: 'date-time' }, }, }, // Add other response codes (e.g., 400, 500) using Fastify's standard error handling or setErrorHandler }, }; const bulkIdParamsSchema = { params: { type: 'object', required: ['bulkId'], properties: { bulkId: { type: 'string', description: 'The Infobip Bulk ID (usually starts with bulk-)' } }, additionalProperties: false, } }; const rescheduleSmsSchema = { ...bulkIdParamsSchema, // Include params validation body: { type: 'object', required: ['sendAt'], properties: { sendAt: { type: 'string', format: 'date-time', description: 'New ISO 8601 scheduled time', errorMessage: 'sendAt must be a valid ISO 8601 date-time string' }, }, additionalProperties: false, }, response: { 200: { description: 'SMS rescheduled successfully', type: 'object', properties: { message: { type: 'string' }, bulkId: { type: 'string' }, newScheduledAt: { type: 'string', format: 'date-time', description: 'Confirmed new schedule time from Infobip' }, }, }, }, }; const updateStatusSchema = { ...bulkIdParamsSchema, // Include params validation body: { type: 'object', required: ['status'], properties: { status: { type: 'string', enum: ['CANCELED'], // Only allow canceling explicitly via this endpoint description: 'Must be "CANCELED" to cancel the scheduled SMS', errorMessage: 'Status must be "CANCELED"' } }, additionalProperties: false, }, response: { 200: { description: 'SMS status updated successfully', type: 'object', properties: { message: { type: 'string' }, bulkId: { type: 'string' }, status: { type: 'string', description: 'Confirmed status from Infobip (e.g., CANCELED)' }, }, }, }, }; // --- Controller Functions --- async function scheduleSmsHandler(request, reply) { const { recipient, messageText, sendAt, sender } = request.body; // Additional business logic validation (optional) const scheduleDate = new Date(sendAt); if (scheduleDate <= new Date()) { return reply.code(400).send({ error: 'Bad Request', message: 'sendAt must be in the future.' }); } // Add check for maximum schedule date if needed try { request.log.info(`Received schedule request for ${recipient} at ${sendAt}`); // 1. Call Infobip to schedule const infobipResponse = await infobipService.scheduleSms(recipient, messageText, sendAt, sender); request.log.info(`Infobip scheduling initiated for bulkId: ${infobipResponse.bulkId}, initial status: ${infobipResponse.status}`); // 2. Save job metadata to our database const jobData = { bulkId: infobipResponse.bulkId, status: infobipResponse.status, // Use status from Infobip response scheduledAt: new Date(sendAt), // Store as Date object recipient: recipient, messageText: messageText, // Store for reference }; const savedJob = await databaseService.saveJob(jobData); request.log.info(`Saved scheduled job metadata with internal ID: ${savedJob.id}`); // 3. Return success response return reply.code(201).send({ message: 'SMS scheduled successfully.', jobId: savedJob.id, bulkId: savedJob.bulkId, status: savedJob.status, scheduledAt: savedJob.scheduledAt.toISOString(), }); } catch (error) { request.log.error({ err: error }, `Error scheduling SMS for ${recipient}: ${error.message}`); // Let Fastify's default error handler (or a custom one) handle the response // It will typically return a 500 Internal Server Error // You might want to check error types (e.g., Infobip vs DB errors) for more specific responses throw error; // Re-throw for Fastify's error handling } } // ... (Rest of the controller functions: getSmsHandler, rescheduleSmsHandler, getStatusHandler, cancelSmsHandler would go here) ... // Export necessary items export { scheduleSmsSchema, bulkIdParamsSchema, rescheduleSmsSchema, updateStatusSchema, scheduleSmsHandler, // Export other handlers... };