sms compliance

Sent logo
Sent TeamMar 8, 2026 / sms compliance / NestJS

NestJS SMS Scheduling Tutorial: Build Automated Reminders with Plivo & Cron Jobs

Complete guide to building automated SMS scheduling in NestJS with Plivo API. Learn cron jobs, TypeORM integration, retry logic, and production-ready SMS reminder systems for Node.js applications.

How to Build SMS Scheduling and Automated Reminders with Plivo and NestJS

Build a production-ready SMS scheduling system using Plivo's SMS API with NestJS. This complete tutorial walks you through creating automated scheduled reminders, implementing reliable cron jobs with @nestjs/schedule, and managing SMS delivery with proper error handling for appointment reminders, payment notifications, and event alerts.

What you'll learn: Integrate Plivo SDK v4.x with NestJS v10.x for automated SMS scheduling, set up TypeORM v0.3.x with PostgreSQL for reminder storage, implement timezone-aware cron jobs using @nestjs/schedule v4.x, handle message queuing and retry logic, and deploy a scalable reminder system with transaction-based locking for distributed environments.

Prerequisites: Node.js v18 LTS or v20 LTS, PostgreSQL v14+, and a Plivo account with API credentials (sign up here).

Why Use NestJS and Plivo for Scheduled SMS Reminders?

Scheduled SMS reminders are essential for modern applications – whether you're building appointment scheduling systems, subscription renewal alerts, payment reminders, or event notifications. Automated SMS delivery ensures your users never miss critical information.

NestJS provides a robust framework with built-in dependency injection and task scheduling support, making it ideal for complex SMS scheduling logic. Combined with Plivo's reliable SMS API and competitive pricing (starting at $0.0040 per SMS in the US), you can build enterprise-grade reminder systems with:

  • Timezone-aware scheduling – Send messages at the right local time for each recipient
  • Automatic retry logic – Handle delivery failures gracefully with exponential backoff
  • Database persistence – Track every reminder's status and delivery history
  • Scalable architecture – Run multiple instances without duplicate sends using transaction-based locking
  • Type safety – Leverage TypeScript for compile-time validation and better developer experience

This tutorial guides you through building a complete NestJS SMS scheduling system from scratch, with production-ready patterns you can deploy immediately.

How to Set Up Your NestJS Development Environment for SMS Scheduling

Install Node.js and Initialize Your NestJS Project

Start by ensuring you have Node.js v18 LTS or v20 LTS installed. These versions provide the best stability and long-term support as of October 2025. If you need to install or upgrade Node.js, download it from nodejs.org or use a version manager like nvm.

bash
# Verify your Node.js version
node --version

# Create a new NestJS project
npx @nestjs/cli new plivo-reminders

# Navigate to your project directory
cd plivo-reminders

When prompted, choose npm as your package manager for consistency with this tutorial.

Install Required Dependencies for SMS Scheduling

Install all necessary packages for scheduling, database management, and SMS delivery:

bash
# Core dependencies
npm install @nestjs/schedule @nestjs/typeorm @nestjs/config typeorm pg plivo dotenv

# Development dependencies for TypeScript support
npm install --save-dev @types/node @types/cron

Package overview:

  • @nestjs/schedule v4.x – Provides cron job decorators and scheduler registry
  • @nestjs/typeorm – NestJS integration for TypeORM v0.3.x
  • @nestjs/config – Environment variable management
  • typeorm v0.3.x – Object-relational mapping with PostgreSQL support
  • pg – PostgreSQL client driver
  • plivo v4.x – Official Plivo SDK for SMS operations
  • dotenv – Environment variable loader

Configure Environment Variables for Plivo Integration

Create a .env file in your project root to store sensitive configuration:

bash
# Database configuration
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=your_db_user
DB_PASSWORD=your_db_password
DB_DATABASE=plivo_reminders

# Plivo credentials (find these in your Plivo console at https://console.plivo.com/)
PLIVO_AUTH_ID=your_auth_id
PLIVO_AUTH_TOKEN=your_auth_token
PLIVO_PHONE_NUMBER=+14155552671

# Scheduling configuration
REMINDER_CHECK_CRON_TIME=*/5 * * * *

Cron expression syntax: The value */5 * * * * means "run every 5 minutes." The five positions represent: minute (0–59), hour (0–23), day of month (1–31), month (1–12), and day of week (0–6, Sunday = 0). Learn more at crontab.guru.

Security note: Never commit your .env file to version control. Add it to your .gitignore file immediately.

Set Up PostgreSQL Database

Create a dedicated database for your reminder system. If you don't have PostgreSQL installed, download it from postgresql.org or use Docker:

bash
# Using Docker (recommended for development)
docker run --name plivo-postgres -e POSTGRES_PASSWORD=your_password -p 5432:5432 -d postgres:14

# Or connect to an existing PostgreSQL instance
psql -U postgres

# Create the database
CREATE DATABASE plivo_reminders;

# Grant privileges (if using a specific user)
GRANT ALL PRIVILEGES ON DATABASE plivo_reminders TO your_db_user;

Test your connection:

bash
psql -h localhost -U your_db_user -d plivo_reminders

How to Create the SMS Reminder Entity with TypeORM

Define your reminder data structure using TypeORM entities. This entity represents each scheduled SMS reminder in your database.

Create the Entity File

Generate the reminders module structure:

bash
# Generate module, controller, and service
nest generate module reminders
nest generate service reminders

Create src/reminders/entities/reminder.entity.ts with this schema:

typescript
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
  Index,
} from 'typeorm';

@Entity('reminders')
export class Reminder {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 20 })
  @Index()
  phoneNumber: string;

  @Column({ type: 'text' })
  message: string;

  @Column({ type: 'timestamp with time zone' })
  @Index()
  scheduledFor: Date;

  @Column({
    type: 'enum',
    enum: ['PENDING', 'PROCESSING', 'SENT', 'FAILED'],
    default: 'PENDING',
  })
  @Index()
  status: 'PENDING' | 'PROCESSING' | 'SENT' | 'FAILED';

  @Column({ type: 'varchar', nullable: true })
  plivoMessageUuid: string;

  @Column({ type: 'text', nullable: true })
  errorMessage: string;

  @Column({ type: 'int', default: 0 })
  retryCount: number;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

Key design decisions:

  • UUID primary key – Provides globally unique identifiers, useful for distributed systems
  • Indexed columnsphoneNumber, scheduledFor, and status columns have indexes for query performance
  • Timestamp with time zone – Ensures accurate scheduling across different timezones
  • Status enum – Tracks reminder lifecycle with four distinct states
  • PROCESSING status – Critical for preventing duplicate sends in distributed environments
  • Retry tracking – Enables exponential backoff for failed deliveries

Configure TypeORM with DataSource

TypeORM v0.3.x uses the DataSource API instead of the deprecated ConnectionOptions pattern. Create src/data-source.ts for migration CLI support:

typescript
import { DataSource, DataSourceOptions } from 'typeorm';
import { config } from 'dotenv';
import { Reminder } from './reminders/entities/reminder.entity';

config();

export const dataSourceOptions: DataSourceOptions = {
  type: 'postgres',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '5432', 10),
  username: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_DATABASE,
  entities: [Reminder],
  migrations: ['src/migrations/*{.ts,.js}'],
  synchronize: false,
  logging: true,
};

const dataSource = new DataSource(dataSourceOptions);
export default dataSource;

Critical configuration notes:

  • synchronize: false – Essential for production safety. Never use auto-schema-sync with migrations
  • Import actual entities – TypeORM v0.3.x requires explicit entity imports rather than glob patterns for CLI operations
  • Migration path – Points to your migrations directory for typeorm-ts-node-commonjs execution

Update src/app.module.ts to initialize TypeORM:

typescript
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { ConfigModule } from '@nestjs/config';
import { dataSourceOptions } from './data-source';
import { RemindersModule } from './reminders/reminders.module';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
    }),
    TypeOrmModule.forRoot(dataSourceOptions),
    ScheduleModule.forRoot(),
    RemindersModule,
  ],
})
export class AppModule {}

Generate and Run Your First Migration

Use TypeORM's migration system to create your database schema safely:

bash
# Generate migration from entity changes (TypeORM v0.3.x syntax)
npx typeorm-ts-node-commonjs migration:generate src/migrations/CreateReminderTable -d src/data-source.ts

# Run the migration
npx typeorm-ts-node-commonjs migration:run -d src/data-source.ts

# To rollback if needed
npx typeorm-ts-node-commonjs migration:revert -d src/data-source.ts

TypeORM v0.3.x migration commands:

  • Use typeorm-ts-node-commonjs for TypeScript projects
  • The -d flag specifies your DataSource file path
  • Always review generated migrations before running them in production

Migration best practices:

  • Review generated SQL before applying to production
  • Test migrations on a staging database first
  • Keep migrations small and focused on specific changes
  • Never edit migrations after they've run in production

How to Integrate Plivo SMS API with NestJS

Create a dedicated service to handle all Plivo SMS operations. This separation of concerns makes your code more testable and maintainable.

Create the Plivo Service

Generate a new service for Plivo integration:

bash
nest generate service plivo

Implement src/plivo/plivo.service.ts with comprehensive error handling:

typescript
import { Injectable, Logger } from '@nestjs/common';
import * as plivo from 'plivo';

export interface SendSMSResult {
  success: boolean;
  messageUuid?: string;
  error?: string;
}

@Injectable()
export class PlivoService {
  private readonly logger = new Logger(PlivoService.name);
  private plivoClient: plivo.Client;

  constructor() {
    this.plivoClient = new plivo.Client(
      process.env.PLIVO_AUTH_ID,
      process.env.PLIVO_AUTH_TOKEN,
    );
  }

  /**
   * Send an SMS message using Plivo API
   *
   * @param to - Recipient phone number in E.164 format (e.g., '+14155552671')
   * @param message - Message content (up to 1,600 characters)
   * @param from - Sender phone number or alphanumeric sender ID
   * @returns Promise with success status and message UUID or error details
   */
  async sendSMS(
    to: string,
    message: string,
    from: string = process.env.PLIVO_PHONE_NUMBER,
  ): Promise<SendSMSResult> {
    try {
      if (!this.isValidE164(to)) {
        throw new Error(`Invalid phone number format: ${to}. Use E.164 format like +14155552671`);
      }

      const response = await this.plivoClient.messages.create({
        src: from,
        dst: to,
        text: message,
      });

      this.logger.log(`SMS sent successfully to ${to}. Message UUID: ${response.messageUuid}`);

      return {
        success: true,
        messageUuid: response.messageUuid[0],
      };
    } catch (error) {
      this.logger.error(`Failed to send SMS to ${to}: ${error.message}`, error.stack);

      return {
        success: false,
        error: error.message || 'Unknown error occurred',
      };
    }
  }

  /**
   * Validate phone number format (E.164)
   * E.164 format: + followed by country code and national number
   */
  private isValidE164(phoneNumber: string): boolean {
    const e164Regex = /^\+[1-9]\d{1,14}$/;
    return e164Regex.test(phoneNumber);
  }
}

Implementation highlights:

  • E.164 validation – Ensures phone numbers follow the international standard format
  • Structured logging – Uses NestJS Logger for consistent log formatting
  • Error encapsulation – Returns structured results instead of throwing exceptions
  • Environment-based configuration – Keeps credentials secure in environment variables

SMS character limits: Plivo supports up to 1,600 characters per message. Messages longer than 160 characters (or 70 characters for Unicode) are automatically segmented into multiple SMS parts, each billed separately. See Plivo's SMS character limit documentation for details.

Register Plivo Module

Create src/plivo/plivo.module.ts to make your service available across the application:

typescript
import { Module } from '@nestjs/common';
import { PlivoService } from './plivo.service';

@Module({
  providers: [PlivoService],
  exports: [PlivoService],
})
export class PlivoModule {}

Import this module in your RemindersModule to enable dependency injection.

How to Implement NestJS Cron Jobs for SMS Reminder Scheduling

Build the core scheduling logic that checks for due reminders and sends SMS messages using transaction-based locking for safety in distributed environments.

Create the Reminders Service with Cron Jobs

Update src/reminders/reminders.service.ts with scheduling logic:

typescript
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThanOrEqual, In } from 'typeorm';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Reminder } from './entities/reminder.entity';
import { PlivoService } from '../plivo/plivo.service';

@Injectable()
export class RemindersService {
  private readonly logger = new Logger(RemindersService.name);
  private readonly MAX_RETRIES = 3;

  constructor(
    @InjectRepository(Reminder)
    private reminderRepository: Repository<Reminder>,
    private plivoService: PlivoService,
  ) {}

  /**
   * Cron job that checks for due reminders
   *
   * IMPORTANT: The @Cron decorator reads process.env.REMINDER_CHECK_CRON_TIME
   * at application startup, not dynamically. To change the schedule at runtime,
   * use SchedulerRegistry.addCronJob() with dynamic values instead.
   *
   * @see https://docs.nestjs.com/techniques/task-scheduling#dynamic-schedule-module-api
   */
  @Cron(process.env.REMINDER_CHECK_CRON_TIME || CronExpression.EVERY_MINUTE, {
    name: 'reminder-check',
    timeZone: 'UTC',
  })
  async handleCron() {
    this.logger.debug('Checking for due reminders...');

    await this.reminderRepository.manager.transaction(async transactionalEntityManager => {
      const dueReminders = await transactionalEntityManager.find(Reminder, {
        where: {
          scheduledFor: LessThanOrEqual(new Date()),
          status: 'PENDING',
        },
        take: 100,
        order: {
          scheduledFor: 'ASC',
        },
      });

      if (dueReminders.length === 0) {
        this.logger.debug('No due reminders found');
        return;
      }

      this.logger.log(`Found ${dueReminders.length} due reminder(s)`);

      await transactionalEntityManager.update(
        Reminder,
        { id: In(dueReminders.map(r => r.id)) },
        { status: 'PROCESSING' }
      );

      for (const reminder of dueReminders) {
        await this.processReminder(reminder);
      }
    });
  }

  /**
   * Process a single reminder with retry logic
   */
  private async processReminder(reminder: Reminder): Promise<void> {
    try {
      this.logger.log(`Processing reminder ${reminder.id} for ${reminder.phoneNumber}`);

      const result = await this.plivoService.sendSMS(
        reminder.phoneNumber,
        reminder.message,
      );

      if (result.success) {
        await this.reminderRepository.update(reminder.id, {
          status: 'SENT',
          plivoMessageUuid: result.messageUuid,
        });

        this.logger.log(`Reminder ${reminder.id} sent successfully`);
      } else {
        await this.handleFailedReminder(reminder, result.error);
      }
    } catch (error) {
      this.logger.error(
        `Error processing reminder ${reminder.id}: ${error.message}`,
        error.stack,
      );
      await this.handleFailedReminder(reminder, error.message);
    }
  }

  /**
   * Handle failed SMS delivery with exponential backoff retry
   */
  private async handleFailedReminder(
    reminder: Reminder,
    errorMessage: string,
  ): Promise<void> {
    const newRetryCount = reminder.retryCount + 1;

    if (newRetryCount <= this.MAX_RETRIES) {
      const delayMinutes = Math.pow(5, newRetryCount - 1);
      const newScheduledTime = new Date();
      newScheduledTime.setMinutes(newScheduledTime.getMinutes() + delayMinutes);

      await this.reminderRepository.update(reminder.id, {
        status: 'PENDING',
        retryCount: newRetryCount,
        scheduledFor: newScheduledTime,
        errorMessage,
      });

      this.logger.warn(
        `Reminder ${reminder.id} failed. Retry ${newRetryCount}/${this.MAX_RETRIES} scheduled for ${newScheduledTime}`,
      );
    } else {
      await this.reminderRepository.update(reminder.id, {
        status: 'FAILED',
        errorMessage: `Max retries (${this.MAX_RETRIES}) exceeded. Last error: ${errorMessage}`,
      });

      this.logger.error(`Reminder ${reminder.id} permanently failed after ${this.MAX_RETRIES} retries`);
    }
  }

  /**
   * Create a new reminder
   */
  async createReminder(
    phoneNumber: string,
    message: string,
    scheduledFor: Date,
  ): Promise<Reminder> {
    const reminder = this.reminderRepository.create({
      phoneNumber,
      message,
      scheduledFor,
      status: 'PENDING',
    });

    return await this.reminderRepository.save(reminder);
  }

  /**
   * Find all reminders
   */
  async findAll(): Promise<Reminder[]> {
    return await this.reminderRepository.find({
      order: { scheduledFor: 'DESC' },
    });
  }

  /**
   * Find one reminder by ID
   */
  async findOne(id: string): Promise<Reminder> {
    return await this.reminderRepository.findOne({ where: { id } });
  }

  /**
   * Remove a reminder by ID
   */
  async remove(id: string): Promise<void> {
    await this.reminderRepository.delete(id);
  }
}

Key implementation details:

  • Transaction-based locking – The PROCESSING status change within a database transaction ensures that only one application instance processes each reminder, even in distributed deployments with multiple servers
  • Batch processing – The take: 100 limit prevents memory exhaustion when processing large reminder backlogs
  • Exponential backoff – Failed messages retry after 1, 5, and 25 minutes before permanent failure
  • Timezone awareness – Always use timeZone: 'UTC' in cron configuration to match PostgreSQL's timestamp with time zone storage
  • Crash recovery – If the process crashes during reminder processing, reminders stuck in PROCESSING status will remain there. Implement a separate cleanup job to reset stuck reminders after 30 minutes

Update Reminders Module

Ensure all dependencies are properly registered in src/reminders/reminders.module.ts:

typescript
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RemindersService } from './reminders.service';
import { Reminder } from './entities/reminder.entity';
import { PlivoModule } from '../plivo/plivo.module';

@Module({
  imports: [
    TypeOrmModule.forFeature([Reminder]),
    PlivoModule,
  ],
  providers: [RemindersService],
  exports: [RemindersService],
})
export class RemindersModule {}

How to Handle SMS Delivery Failures and Retry Logic

Robust error handling is critical for production SMS scheduling systems. This section covers retry strategies, exponential backoff, and failure recovery patterns.

Retry Logic with Exponential Backoff

The retry logic is implemented in the RemindersService.handleFailedReminder() method. Here's how the exponential backoff strategy works:

Exponential backoff calculation:

  • Retry 1: 5⁰ = 1 minute delay
  • Retry 2: 5¹ = 5 minutes delay
  • Retry 3: 5² = 25 minutes delay

After 3 failed attempts, the reminder is marked as permanently FAILED.

Why exponential backoff? This strategy prevents overwhelming the Plivo API during service disruptions while giving transient failures time to resolve. For permanent failures (like invalid phone numbers), the reminder fails quickly without unnecessary retries.

Common Failure Scenarios

Network failures: Temporary connectivity issues with Plivo API

  • Retry strategy: Exponential backoff usually resolves these automatically

Invalid phone numbers: Incorrectly formatted or disconnected numbers

  • Error message example: "Invalid phone number format" or "Number not reachable"
  • Resolution: Validate phone numbers before creating reminders using the E.164 format checker

Rate limiting: Exceeding Plivo's API rate limits

  • Plivo error code: 429 Too Many Requests
  • Resolution: Implement rate limiting in your application or increase batch processing interval

Insufficient credits: Plivo account balance too low

  • Plivo error code: 402 Payment Required
  • Resolution: Add funds to your Plivo account and set up balance alerts

For each scenario, the retry logic handles gracefully by storing error details and attempting redelivery. To prevent duplicate messages, each reminder is assigned a unique UUID and tracked through its entire lifecycle.

How to Create REST API Endpoints for SMS Reminder Management

Build API endpoints for creating, listing, and managing reminders.

Create the Reminders Controller

Generate a new controller for reminders:

bash
nest generate controller reminders

Implement src/reminders/reminders.controller.ts with proper validation and error handling:

typescript
import { Controller, Post, Body, Get, Param, Delete, ValidationPipe } from '@nestjs/common';
import { RemindersService } from './reminders.service';
import { CreateReminderDto } from './dto/create-reminder.dto';

@Controller('reminders')
export class RemindersController {
  constructor(private readonly remindersService: RemindersService) {}

  @Post()
  create(@Body(new ValidationPipe()) createReminderDto: CreateReminderDto) {
    return this.remindersService.createReminder(
      createReminderDto.phoneNumber,
      createReminderDto.message,
      createReminderDto.scheduledFor,
    );
  }

  @Get()
  findAll() {
    return this.remindersService.findAll();
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.remindersService.findOne(id);
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.remindersService.remove(id);
  }
}

Production security note: Add authentication and authorization middleware before deploying this API. Never expose reminder management endpoints without proper access controls. Consider using @nestjs/passport with JWT tokens or API keys.

Create the DTO for Input Validation

Install the validation package:

bash
npm install class-validator class-transformer

Create src/reminders/dto/create-reminder.dto.ts:

typescript
import { IsString, IsPhoneNumber, IsDateString, MinLength, MaxLength } from 'class-validator';

export class CreateReminderDto {
  @IsPhoneNumber(null, { message: 'Phone number must be in valid E.164 format' })
  phoneNumber: string;

  @IsString()
  @MinLength(1, { message: 'Message cannot be empty' })
  @MaxLength(1600, { message: 'Message exceeds SMS character limit' })
  message: string;

  @IsDateString({}, { message: 'Scheduled time must be a valid ISO date string' })
  scheduledFor: Date;
}

Valid request example:

json
{
  "phoneNumber": "+14155552671",
  "message": "Your appointment is scheduled for tomorrow at 3:00 PM. Reply CANCEL to reschedule.",
  "scheduledFor": "2025-10-08T15:00:00Z"
}

Invalid request example:

json
{
  "phoneNumber": "4155552671",
  "message": "",
  "scheduledFor": "tomorrow"
}

API endpoint highlights:

  • Validation – Use NestJS validation pipes to validate incoming requests
  • Error handling – Return proper HTTP status codes and error messages
  • Security – Validate user input and prevent injection attacks
  • Consistency – Use consistent naming conventions and response formats

How to Test Your NestJS SMS Scheduling System

Unit Tests

Test your services and entities with NestJS testing utilities.

typescript
// src/reminders/reminders.service.spec.ts
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { RemindersService } from './reminders.service';
import { PlivoService } from '../plivo/plivo.service';
import { Reminder } from './entities/reminder.entity';

describe('RemindersService', () => {
  let service: RemindersService;
  let plivoService: PlivoService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        RemindersService,
        {
          provide: getRepositoryToken(Reminder),
          useValue: {
            create: jest.fn(),
            save: jest.fn(),
            find: jest.fn(),
            update: jest.fn(),
            manager: {
              transaction: jest.fn(),
            },
          },
        },
        {
          provide: PlivoService,
          useValue: {
            sendSMS: jest.fn(),
          },
        },
      ],
    }).compile();

    service = module.get<RemindersService>(RemindersService);
    plivoService = module.get<PlivoService>(PlivoService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  it('should create a reminder', async () => {
    const mockReminder = {
      id: '123e4567-e89b-12d3-a456-426614174000',
      phoneNumber: '+14155552671',
      message: 'Test reminder',
      scheduledFor: new Date(),
      status: 'PENDING',
    };

    jest.spyOn(service['reminderRepository'], 'create').mockReturnValue(mockReminder as any);
    jest.spyOn(service['reminderRepository'], 'save').mockResolvedValue(mockReminder as any);

    const result = await service.createReminder(
      '+14155552671',
      'Test reminder',
      new Date(),
    );

    expect(result).toEqual(mockReminder);
  });

  it('should handle exponential backoff correctly', async () => {
    const mockReminder = {
      id: '123e4567-e89b-12d3-a456-426614174000',
      phoneNumber: '+14155552671',
      message: 'Test reminder',
      scheduledFor: new Date(),
      status: 'PENDING',
      retryCount: 0,
    };

    jest.spyOn(service['reminderRepository'], 'update').mockResolvedValue(null);

    await service['handleFailedReminder'](mockReminder as Reminder, 'Test error');

    expect(service['reminderRepository'].update).toHaveBeenCalledWith(
      mockReminder.id,
      expect.objectContaining({
        status: 'PENDING',
        retryCount: 1,
      })
    );
  });
});

Integration Testing Best Practices

  • Use a separate test database – Avoid polluting production data by configuring a dedicated test database in your .env.test file
  • Mock external API calls – Mock Plivo API calls to avoid costs and rate limits during testing
  • Test transaction rollback – Verify that failed transactions don't leave partial data
  • Test cron job execution – Use @nestjs/schedule testing utilities to trigger cron jobs manually without waiting

Example test database configuration:

bash
# .env.test
DB_DATABASE=plivo_reminders_test
PLIVO_AUTH_ID=test_auth_id
PLIVO_AUTH_TOKEN=test_auth_token

Production Deployment Considerations for NestJS SMS Systems

Environment Configuration

Ensure all environment variables are properly set in production:

  • Use managed secrets – Store credentials in AWS Secrets Manager, Azure Key Vault, Google Secret Manager, or similar services
  • Never hardcode credentials – All sensitive values must come from environment variables
  • Validate on startup – Add startup checks to verify all required environment variables are present and valid

Example startup validation:

typescript
// src/main.ts
async function bootstrap() {
  const requiredEnvVars = [
    'DB_HOST', 'DB_USERNAME', 'DB_PASSWORD', 'DB_DATABASE',
    'PLIVO_AUTH_ID', 'PLIVO_AUTH_TOKEN', 'PLIVO_PHONE_NUMBER'
  ];

  for (const envVar of requiredEnvVars) {
    if (!process.env[envVar]) {
      throw new Error(`Missing required environment variable: ${envVar}`);
    }
  }

  const app = await NestJSFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

Monitoring and Alerting

Set up monitoring for these critical metrics:

  • Failed reminder count – Alert when failed reminders exceed 5% of total reminders in the last hour
  • Stuck PROCESSING reminders – Alert when reminders remain in PROCESSING status for more than 30 minutes
  • Plivo API errors – Alert on 429 (rate limit) or 402 (insufficient credits) errors
  • Database connection pool – Alert when connection pool utilization exceeds 80%

Recommended tools: Prometheus + Grafana for metrics, Sentry for error tracking, or DataDog for all-in-one monitoring.

Scaling Considerations

  • Run multiple instances – Deploy multiple application instances behind a load balancer (AWS ALB, NGINX, etc.)
  • Transaction-based locking – The PROCESSING status prevents duplicate sends across instances
  • Database connection pooling – Configure TypeORM connection pool size based on instance count (default: 10 connections per instance)
  • Redis for distributed locking – For high-scale deployments (10+ instances), consider Redis-based distributed locking instead of database transactions

Example connection pool configuration:

typescript
// src/data-source.ts
export const dataSourceOptions: DataSourceOptions = {
  // ... other options
  extra: {
    max: 20, // Maximum connections in pool
    min: 5,  // Minimum connections maintained
    idleTimeoutMillis: 30000,
  },
};

Troubleshooting Common SMS Scheduling Issues

Reminders Not Sending

Check cron job execution:

bash
# View application logs for "Checking for due reminders" messages
grep "Checking for due reminders" logs/app.log

Database connectivity:

sql
-- Test database connection
SELECT NOW();

-- Check for due reminders
SELECT * FROM reminders WHERE status = 'PENDING' AND scheduled_for <= NOW();

Plivo credentials:

bash
# Test Plivo API credentials
curl -X GET https://api.plivo.com/v1/Account/{auth_id}/ \
  -u {auth_id}:{auth_token}

Phone number format:

typescript
// Verify E.164 format compliance
const validFormat = /^\+[1-9]\d{1,14}$/;
console.log(validFormat.test('+14155552671')); // true
console.log(validFormat.test('4155552671'));   // false

Duplicate SMS Messages

If users receive duplicate messages:

  • Verify transaction locking – Check logs for concurrent processing warnings
  • Inspect database isolation level – Ensure PostgreSQL transaction isolation is set to READ COMMITTED or higher
  • Review application instances – Confirm all instances use the same database

Query to find potential duplicates:

sql
-- Find reminders processed multiple times
SELECT phone_number, message, COUNT(*) as send_count
FROM reminders
WHERE status = 'SENT'
GROUP BY phone_number, message, DATE_TRUNC('hour', scheduled_for)
HAVING COUNT(*) > 1;

Performance Degradation

Batch size optimization:

typescript
// Adjust batch size in RemindersService
take: 100, // Increase to 200 for high throughput, decrease to 50 for slower systems

Index maintenance:

sql
-- Check for index bloat
SELECT schemaname, tablename, attname, n_distinct, correlation
FROM pg_stats
WHERE tablename = 'reminders';

-- Rebuild indexes if needed
REINDEX TABLE reminders;

Connection pool monitoring:

sql
-- View active database connections
SELECT COUNT(*), state
FROM pg_stat_activity
WHERE datname = 'plivo_reminders'
GROUP BY state;

Find stuck reminders:

sql
-- Reminders stuck in PROCESSING for more than 30 minutes
SELECT * FROM reminders
WHERE status = 'PROCESSING'
AND updated_at < NOW() - INTERVAL '30 minutes';

Next Steps and Advanced Features

Potential Enhancements

Recurring reminders: Add support for daily, weekly, or monthly schedules using cron expressions stored in the database. Create a separate recurring_reminders table with a cron_schedule column.

Template management: Create reusable SMS templates with variable substitution:

typescript
const template = "Hi {{name}}, your appointment is at {{time}}";
const message = template.replace('{{name}}', 'John').replace('{{time}}', '3:00 PM');

Timezone detection: Automatically determine recipient timezone from phone number using the Google libphonenumber library.

Delivery webhooks: Implement a webhook endpoint to receive real-time delivery status from Plivo:

typescript
@Post('plivo/status')
handleDeliveryStatus(@Body() payload: PlivoDeliveryStatus) {
  // Update reminder status based on Plivo callback
}

Analytics dashboard: Track reminder success rates, delivery times, and failure patterns using aggregated database queries or a separate analytics database.

User preferences: Allow recipients to opt out or customize reminder timing by storing preferences in a user_preferences table.

Frequently Asked Questions About NestJS SMS Scheduling

How do I schedule SMS messages in NestJS?

Use the @nestjs/schedule package with the @Cron decorator to create scheduled tasks. Combine this with a database (like PostgreSQL with TypeORM) to store reminder data and an SMS provider (like Plivo) to send messages. The cron job checks the database for due reminders and triggers SMS delivery.

Can I use NestJS cron jobs for appointment reminders?

Yes, NestJS cron jobs are ideal for appointment reminders. Store appointment details in a database with a scheduled send time, then use a cron job to check for due reminders every few minutes. The transaction-based locking pattern prevents duplicate messages in distributed deployments.

What's the best SMS API for NestJS applications?

Plivo, Twilio, and MessageBird are popular choices for NestJS SMS integration. Plivo offers competitive pricing (starting at $0.0040 per SMS), reliable delivery, and a comprehensive Node.js SDK that integrates easily with NestJS dependency injection.

How do I prevent duplicate SMS in distributed NestJS apps?

Use transaction-based locking by wrapping your reminder queries in a database transaction and immediately updating the status to PROCESSING. This ensures only one instance processes each reminder, even when running multiple NestJS servers behind a load balancer.

Conclusion

You've built a production-ready SMS scheduling system using Plivo and NestJS with:

✓ Timezone-aware cron job scheduling ✓ Transaction-based locking for distributed deployments ✓ Exponential backoff retry logic ✓ Comprehensive error handling ✓ TypeORM database persistence ✓ RESTful API endpoints

This foundation can scale to handle thousands of reminders per day while maintaining reliability and preventing duplicate messages. Deploy your system, monitor performance metrics, and iterate based on real-world usage patterns.

Next steps:

  1. Add authentication to your API endpoints
  2. Set up monitoring and alerting
  3. Deploy to production (AWS, Azure, Google Cloud, or your preferred platform)
  4. Test with real phone numbers and monitor delivery rates

For questions or issues, refer to Plivo's documentation or NestJS documentation.