code examples
code examples
How to Build SMS Marketing Campaigns with Sinch API and NestJS 11
Learn how to build production-ready SMS marketing campaigns with Sinch API, NestJS 11, and PostgreSQL. Send bulk SMS to 1,000+ subscribers with batch processing, webhook opt-out handling, scheduled campaigns, and TypeScript.
Build SMS Marketing Campaigns with Sinch, NestJS 11, and Node.js
Learn how to build a production-ready SMS marketing campaign system using NestJS 11 and the Sinch SMS API. This comprehensive tutorial walks you through creating a scalable NestJS application that manages subscriber lists, sends bulk SMS messages to 1,000 recipients per batch using Sinch's batch endpoint, processes webhook opt-outs, schedules automated campaigns with cron jobs, and integrates with PostgreSQL 17 using Prisma ORM 6. Master SMS marketing automation with TypeScript, implement TCPA/GDPR compliance, and deploy a reliable bulk messaging system.
Architecture Overview:
| Component | Purpose |
|---|---|
| Subscriber Module | Manage opt-in/opt-out status and subscriber data with caching |
| Campaign Module | Create, schedule, and send bulk SMS campaigns with batch processing |
| Sinch Module | Handle API integration with retry logic and exponential backoff |
| Webhooks Module | Process inbound messages for opt-out handling |
| Prisma ORM | Type-safe database layer with PostgreSQL 17 |
| Cron Jobs | Automated scheduled campaign delivery |
This tutorial covers project setup with Node.js v22 LTS and Docker, core functionality (subscriber management, campaign creation, Sinch API integration), security best practices, error handling, retry logic with exponential backoff, and production deployment considerations.
Why Use Sinch SMS API with NestJS for Marketing Campaigns?
What You're Building:
Build a NestJS-based API service that enables:
- Subscriber Management: Add, update, delete, and list subscribers who have opted into receiving SMS marketing messages.
- Campaign Creation: Define SMS marketing campaigns with specific messages targeted at subscriber segments or the entire list.
- SMS Sending via Sinch: Integrate with the Sinch SMS API to send individual and bulk messages reliably to up to 1,000 recipients per batch.
- Opt-Out Handling: Manage subscriber opt-outs based on keywords (e.g., STOP) received via Sinch webhooks.
- Basic Scheduling: Implement campaign scheduling with cron jobs.
Problem Solved:
This system provides a foundational backend to power SMS marketing efforts, automating subscriber management and message delivery through Sinch. The platform ensures compliance with regulations like TCPA, GDPR, and CAN-SPAM by implementing opt-out mechanisms and honoring subscriber preferences. Sinch's batch messaging API supports up to 1,000 recipients per request (increased from 100), with regional endpoints and FIFO message queuing for reliability.
Key Compliance Features:
- TCPA Compliance: Explicit opt-in tracking and immediate opt-out processing
- GDPR Compliance: Subscriber data management with delete capabilities
- CAN-SPAM Compliance: Clear identification and opt-out mechanisms
Sinch vs. Alternative Providers:
| Feature | Sinch | Twilio | Vonage |
|---|---|---|---|
| Batch Size | 1,000 recipients | 1 recipient (bulk via Messaging Service) | 1,000 recipients |
| Regional Endpoints | Yes (US, EU) | Global | Global |
| Message Queuing | FIFO per service plan | Priority-based | FIFO |
| Pricing Model | Per message | Per message | Per message |
| API Complexity | Simple REST | Complex (multiple APIs) | Moderate |
1. How to Set Up Your NestJS Project for SMS Marketing
Initialize your NestJS project and set up the basic structure and dependencies. This section uses Node.js v22 LTS (released October 2024), which provides Long Term Support through April 2027.
1. Install NestJS CLI:
Install the NestJS command-line interface globally.
npm install -g @nestjs/cli2. Create New NestJS Project:
Generate a new project named sms-campaign-service.
nest new sms-campaign-service
cd sms-campaign-service3. Install Dependencies:
Install packages for configuration, HTTP requests, validation, database interaction, scheduling, and throttling.
# Core dependencies
npm install @nestjs/config class-validator class-transformer @nestjs/axios axios prisma @prisma/client
# Optional but recommended
npm install @nestjs/schedule @nestjs/terminus # For scheduled tasks & health checks
npm install @nestjs/throttler # For rate limiting
npm install @nestjs/swagger swagger-ui-express # For API documentation
npm install helmet # For security headers
npm install @nestjs/cache-manager cache-manager # For caching
# npm install cache-manager-redis-store redis # If using Redis cache
# Development dependencies
npm install -D prisma
npm install -D ts-node # For running prisma seed scriptWhy Each Dependency Matters:
| Package | Purpose |
|---|---|
@nestjs/config | Load environment variables securely |
class-validator & class-transformer | Validate and transform request data |
@nestjs/axios & axios | Make HTTP requests to Sinch API |
prisma & @prisma/client | Type-safe database access and migrations |
@nestjs/schedule | Run cron jobs for scheduled campaigns |
@nestjs/throttler | Implement rate limiting to protect your API |
@nestjs/swagger | Auto-generate API documentation |
helmet | Add security headers to HTTP responses |
@nestjs/cache-manager | Cache subscriber data for performance |
4. Set up Prisma:
Initialize Prisma in your project. This creates a prisma directory with a schema.prisma file and a .env file for database credentials.
npx prisma init --datasource-provider postgresql5. Define Prisma Schema:
Create your database schema in prisma/schema.prisma.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Subscriber {
id Int @id @default(autoincrement())
phone String @unique
name String?
optedIn Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([optedIn])
}
model Campaign {
id Int @id @default(autoincrement())
name String
message String
status CampaignStatus @default(DRAFT)
targetSegment String?
scheduledAt DateTime?
sentAt DateTime?
statusReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, scheduledAt])
}
enum CampaignStatus {
DRAFT
SCHEDULED
SENDING
SENT
FAILED
}Run the migration to create your database tables:
npx prisma migrate dev --name init6. Configure Environment Variables:
Update the DATABASE_URL in the newly created .env file with your PostgreSQL connection string.
# .env
DATABASE_URL="postgresql://user:password@localhost:5432/sms_campaigns?schema=public"
# Sinch Credentials
SINCH_SERVICE_PLAN_ID="YOUR_SERVICE_PLAN_ID"
SINCH_API_TOKEN="YOUR_API_TOKEN"
SINCH_FROM_NUMBER="YOUR_SINCH_NUMBER" # e.g., +12025550147Add .env to .gitignore to prevent committing secrets to version control.
7. Configure Docker for Local Database:
Create a docker-compose.yml file in the project root for easy local PostgreSQL setup.
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:17 # PostgreSQL 17 released September 2024, latest stable version
restart: always
environment:
POSTGRES_USER: user # Match .env
POSTGRES_PASSWORD: password # Match .env
POSTGRES_DB: sms_campaigns # Match .env
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:Start the database:
docker-compose up -d8. Project Structure:
NestJS promotes a modular structure. Your project will follow this organization:
sms-campaign-service/
├── src/
│ ├── app.module.ts
│ ├── main.ts
│ ├── prisma/
│ │ ├── prisma.module.ts
│ │ └── prisma.service.ts
│ ├── subscribers/
│ │ ├── subscribers.module.ts
│ │ ├── subscribers.controller.ts
│ │ ├── subscribers.service.ts
│ │ └── dto/
│ ├── campaigns/
│ │ ├── campaigns.module.ts
│ │ ├── campaigns.controller.ts
│ │ ├── campaigns.service.ts
│ │ └── dto/
│ ├── sinch/
│ │ ├── sinch.module.ts
│ │ └── sinch.service.ts
│ ├── webhooks/
│ │ ├── webhooks.module.ts
│ │ └── webhooks.controller.ts
│ └── health/
│ ├── health.module.ts
│ └── health.controller.ts
├── prisma/
│ └── schema.prisma
├── docker-compose.yml
└── .env
9. Configure Application Module:
Set up @nestjs/config to load environment variables from .env.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
// Import other modules later
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Make config available everywhere
}),
// Other modules will be added here
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}Explanation of Choices:
- NestJS: Provides structure, dependency injection, and TypeScript support, accelerating development and improving maintainability.
- Prisma: Simplifies database interactions with type safety and powerful migration tools.
@nestjs/config: Standard way to manage environment variables securely in NestJS.- Docker: Ensures a consistent database environment locally and potentially in CI/CD.
2. How to Implement Subscriber Management and Campaign Logic
Build the core modules: Subscribers, Campaigns, and the Sinch interaction service. These modules work together: Subscribers stores your audience, Campaigns orchestrates bulk sends, and Sinch handles API communication with retry logic.
Generate Modules and Services:
nest g module prisma
nest g service prisma --no-spec
nest g module subscribers
nest g controller subscribers --no-spec
nest g service subscribers --no-spec
nest g module campaigns
nest g controller campaigns --no-spec
nest g service campaigns --no-spec
nest g module sinch
nest g service sinch --no-spec
nest g module webhooks # For webhook handling
nest g controller webhooks --no-spec # For webhook handling
nest g module health # For health checks
nest g controller health --no-spec # For health checksUse the --no-spec flag here to keep the guide concise; however, generating and maintaining test spec files (.spec.ts) is a best practice in NestJS development.
Prisma Service (src/prisma/prisma.service.ts):
Set up the Prisma client service.
// src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}Make PrismaService available globally by updating PrismaModule.
// src/prisma/prisma.module.ts
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global() // Make PrismaService available globally
@Module({
providers: [PrismaService],
exports: [PrismaService], // Export for injection
})
export class PrismaModule {}Import PrismaModule into AppModule.
// src/app.module.ts
// … other imports
import { PrismaModule } from './prisma/prisma.module';
import { ConfigModule } from '@nestjs/config'; // Ensure ConfigModule is imported
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule, // Add PrismaModule
// Other modules…
],
// … controllers, providers
})
export class AppModule {}Subscribers Module (src/subscribers/):
DTO (Data Transfer Object): Define shape and validation rules for subscriber data.
// src/subscribers/dto/create-subscriber.dto.ts
import { IsString, IsPhoneNumber, IsNotEmpty, IsBoolean, IsOptional } from 'class-validator';
export class CreateSubscriberDto {
@IsNotEmpty()
@IsPhoneNumber(null) // Use null for generic phone number validation (E.164 recommended)
phone: string;
@IsOptional()
@IsString()
name?: string;
@IsNotEmpty()
@IsBoolean()
optedIn: boolean;
}// src/subscribers/dto/update-subscriber.dto.ts
import { PartialType } from '@nestjs/swagger'; // Or @nestjs/mapped-types
import { CreateSubscriberDto } from './create-subscriber.dto';
export class UpdateSubscriberDto extends PartialType(CreateSubscriberDto) {}Service (subscribers.service.ts): Implement CRUD logic using PrismaService. The create method implements upsert logic: if a phone number already exists, it updates the record instead of throwing an error. This prevents duplicate subscriber errors during bulk imports. Alternative approaches include throwing a ConflictException (strict validation) or returning the existing record (idempotent behavior).
// src/subscribers/subscribers.service.ts
import { Injectable, NotFoundException, Inject, CACHE_MANAGER } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateSubscriberDto } from './dto/create-subscriber.dto';
import { UpdateSubscriberDto } from './dto/update-subscriber.dto';
import { Subscriber } from '@prisma/client';
import { Cache } from 'cache-manager';
@Injectable()
export class SubscribersService {
private readonly cacheKeyAllSubscribers = 'all_opted_in_subscribers';
constructor(
private prisma: PrismaService,
@Inject(CACHE_MANAGER) private cacheManager: Cache // Inject CacheManager
) {}
async create(createSubscriberDto: CreateSubscriberDto): Promise<Subscriber> {
const existing = await this.prisma.subscriber.findUnique({
where: { phone: createSubscriberDto.phone },
});
let result: Subscriber;
if (existing) {
// Upsert Behavior: If the phone number exists, update the record. This is a design choice;
// alternatives include throwing a ConflictException or ignoring the duplicate.
result = await this.prisma.subscriber.update({
where: { phone: createSubscriberDto.phone },
data: createSubscriberDto,
});
} else {
result = await this.prisma.subscriber.create({ data: createSubscriberDto });
}
await this.invalidateSubscribersCache(); // Invalidate cache on change
return result;
}
async findAll(): Promise<Subscriber[]> {
const cachedSubscribers = await this.cacheManager.get<Subscriber[]>(this.cacheKeyAllSubscribers);
if (cachedSubscribers) {
console.log('Serving subscribers from cache');
return cachedSubscribers;
}
console.log('Fetching subscribers from database');
const subscribers = await this.prisma.subscriber.findMany({ where: { optedIn: true } }); // Only return opted-in by default
// Cache for 5 minutes (example)
await this.cacheManager.set(this.cacheKeyAllSubscribers, subscribers, 5 * 60 * 1000);
return subscribers;
}
async findOne(id: number): Promise<Subscriber | null> {
const subscriber = await this.prisma.subscriber.findUnique({ where: { id } });
if (!subscriber) {
throw new NotFoundException(`Subscriber with ID ${id} not found`);
}
return subscriber;
}
async findByPhone(phone: string): Promise<Subscriber | null> {
const subscriber = await this.prisma.subscriber.findUnique({ where: { phone } });
if (!subscriber) {
throw new NotFoundException(`Subscriber with phone ${phone} not found`);
}
return subscriber;
}
async update(id: number, updateSubscriberDto: UpdateSubscriberDto): Promise<Subscriber> {
try {
const updatedSubscriber = await this.prisma.subscriber.update({
where: { id },
data: updateSubscriberDto,
});
await this.invalidateSubscribersCache(); // Invalidate cache on change
return updatedSubscriber;
} catch (error) {
// Handle potential Prisma errors, e.g., record not found
if (error.code === 'P2025') {
throw new NotFoundException(`Subscriber with ID ${id} not found`);
}
throw error;
}
}
async remove(id: number): Promise<Subscriber> {
try {
const deletedSubscriber = await this.prisma.subscriber.delete({ where: { id } });
await this.invalidateSubscribersCache(); // Invalidate cache on change
return deletedSubscriber;
} catch (error) {
if (error.code === 'P2025') {
throw new NotFoundException(`Subscriber with ID ${id} not found`);
}
throw error;
}
}
async optOutByPhone(phone: string): Promise<Subscriber> {
const subscriber = await this.findByPhone(phone); // Reuse findByPhone which throws NotFoundException
if (!subscriber.optedIn) {
// Already opted out, maybe just return the record or log
console.log(`Subscriber ${phone} already opted out.`);
return subscriber;
}
const updatedSubscriber = await this.prisma.subscriber.update({
where: { phone },
data: { optedIn: false },
});
await this.invalidateSubscribersCache(); // Invalidate cache on change
return updatedSubscriber;
}
// Helper to invalidate cache
private async invalidateSubscribersCache() {
await this.cacheManager.del(this.cacheKeyAllSubscribers);
console.log('Invalidated subscribers cache');
}
}Controller (subscribers.controller.ts): Define API endpoints for subscriber management.
// src/subscribers/subscribers.controller.ts
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe } from '@nestjs/common';
import { SubscribersService } from './subscribers.service';
import { CreateSubscriberDto } from './dto/create-subscriber.dto';
import { UpdateSubscriberDto } from './dto/update-subscriber.dto';
@Controller('subscribers')
export class SubscribersController {
constructor(private readonly subscribersService: SubscribersService) {}
@Post()
create(@Body() createSubscriberDto: CreateSubscriberDto) {
return this.subscribersService.create(createSubscriberDto);
}
@Get()
findAll() {
return this.subscribersService.findAll();
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.subscribersService.findOne(id);
}
@Patch(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() updateSubscriberDto: UpdateSubscriberDto) {
return this.subscribersService.update(id, updateSubscriberDto);
}
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.subscribersService.remove(id);
}
}Campaigns Module (src/campaigns/):
DTO:
// src/campaigns/dto/create-campaign.dto.ts
import { IsString, IsNotEmpty, IsOptional, IsDateString, MaxLength } from 'class-validator';
export class CreateCampaignDto {
@IsNotEmpty()
@IsString()
name: string;
@IsNotEmpty()
@IsString()
@MaxLength(1600) // SMS messages support up to 1600 characters with concatenation
message: string;
@IsOptional()
@IsString()
targetSegment?: string; // For future segmentation logic
@IsOptional()
@IsDateString()
scheduledAt?: string; // ISO 8601 format
}SMS Message Length Guidelines:
- Single SMS: 160 characters (GSM-7) or 70 characters (Unicode)
- Concatenated SMS: Up to 1,600 characters (10 segments)
- Each segment reduces available characters slightly (153 for GSM-7, 67 for Unicode)
- Sinch automatically handles message concatenation
// src/campaigns/dto/update-campaign.dto.ts
import { PartialType } from '@nestjs/swagger'; // Or @nestjs/mapped-types
import { CreateCampaignDto } from './create-campaign.dto';
export class UpdateCampaignDto extends PartialType(CreateCampaignDto) {}Service (campaigns.service.ts): Implement campaign logic. Sending is delegated to SinchService. The sendCampaign method fetches subscribers in batches of 1,000 to avoid loading all subscribers into memory. This approach scales efficiently for large subscriber lists and respects Sinch's batch size limit.
Performance Considerations:
- Fetching all subscribers at once (
findAll()) fails with 10,000+ subscribers due to memory constraints - Batch processing uses cursor-based pagination with
skipandtake - Each batch is processed sequentially to respect API rate limits
- Memory usage remains constant regardless of total subscriber count
// src/campaigns/campaigns.service.ts
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { UpdateCampaignDto } from './dto/update-campaign.dto';
import { Campaign, CampaignStatus } from '@prisma/client';
import { SubscribersService } from '../subscribers/subscribers.service';
import { SinchService } from '../sinch/sinch.service';
import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable()
export class CampaignsService {
private readonly logger = new Logger(CampaignsService.name);
constructor(
private prisma: PrismaService,
private subscribersService: SubscribersService,
private sinchService: SinchService, // Direct injection (no forwardRef needed)
) {}
async create(createCampaignDto: CreateCampaignDto): Promise<Campaign> {
return this.prisma.campaign.create({
data: {
...createCampaignDto,
status: CampaignStatus.DRAFT, // Default status
},
});
}
async findAll(): Promise<Campaign[]> {
return this.prisma.campaign.findMany();
}
async findOne(id: number): Promise<Campaign | null> {
const campaign = await this.prisma.campaign.findUnique({ where: { id } });
if (!campaign) {
throw new NotFoundException(`Campaign with ID ${id} not found`);
}
return campaign;
}
async update(id: number, updateCampaignDto: UpdateCampaignDto): Promise<Campaign> {
try {
return await this.prisma.campaign.update({
where: { id },
data: updateCampaignDto,
});
} catch (error) {
if (error.code === 'P2025') {
throw new NotFoundException(`Campaign with ID ${id} not found`);
}
throw error;
}
}
async remove(id: number): Promise<Campaign> {
try {
return await this.prisma.campaign.delete({ where: { id } });
} catch (error) {
if (error.code === 'P2025') {
throw new NotFoundException(`Campaign with ID ${id} not found`);
}
throw error;
}
}
async sendCampaign(id: number): Promise<{ message: string; totalSent: number, batchIds: string[] }> {
const campaign = await this.findOne(id); // Throws NotFound if not found
if (campaign.status === CampaignStatus.SENT || campaign.status === CampaignStatus.SENDING) {
return { message: `Campaign ${id} is already ${campaign.status.toLowerCase()} or sent.`, totalSent: 0, batchIds: [] };
}
await this.updateCampaignStatus(id, CampaignStatus.SENDING);
// Sinch SMS API supports up to 1,000 recipients per batch request (increased from 100).
// Reference: https://developers.sinch.com/docs/sms/api-reference/ (Batches endpoint)
const batchSize = 1000; // Send in batches of 1000 per Sinch's documented limit
let skip = 0;
let totalSent = 0;
let hasMoreSubscribers = true;
const batchIds: string[] = [];
try {
while (hasMoreSubscribers) {
this.logger.log(`Fetching subscriber batch for campaign ${id}: skip=${skip}, take=${batchSize}`);
const subscriberBatch = await this.prisma.subscriber.findMany({
where: { optedIn: true /* Add segmentation logic here based on campaign.targetSegment if implemented */ },
take: batchSize,
skip: skip,
select: { phone: true } // Only fetch phone numbers
});
if (subscriberBatch.length === 0) {
hasMoreSubscribers = false;
this.logger.log(`No more subscribers found for campaign ${id} at skip=${skip}.`);
continue; // Exit loop if no subscribers in this batch
}
const phoneNumbers = subscriberBatch.map(sub => sub.phone);
this.logger.log(`Sending campaign ${id} batch to ${phoneNumbers.length} subscribers.`);
const batchResult = await this.sinchService.sendBulkSms(phoneNumbers, campaign.message);
if (batchResult?.id) {
batchIds.push(batchResult.id);
this.logger.log(`Batch sent for campaign ${id}. Batch ID: ${batchResult.id}`);
} else {
this.logger.warn(`Sinch batch send for campaign ${id} did not return an ID. Result: ${JSON.stringify(batchResult)}`);
// Decide how to handle – continue? mark as partial failure?
}
totalSent += phoneNumbers.length;
skip += batchSize;
// Optional: Add a small delay between batches if hitting rate limits
// Note: Sinch queues messages per service plan in FIFO order. Rate limits vary by plan.
// Reference: https://developers.sinch.com/docs/sms/resources/message-info/rate-limits/
// await new Promise(resolve => setTimeout(resolve, 200));
}
if (totalSent === 0) {
await this.updateCampaignStatus(id, CampaignStatus.FAILED, 'No opted-in subscribers found.');
this.logger.warn(`Campaign ${id} sending finished, but no opted-in subscribers were found.`);
return { message: 'No opted-in subscribers to send to.', totalSent: 0, batchIds: [] };
} else {
await this.updateCampaignStatus(id, CampaignStatus.SENT); // Mark as SENT after all batches are processed
this.logger.log(`Campaign ${id} sent successfully to ${totalSent} subscribers. Batch IDs: ${batchIds.join(', ')}`);
return { message: `Campaign ${id} sent successfully to ${totalSent} subscribers.`, totalSent, batchIds };
}
} catch (error) {
this.logger.error(`Failed to send campaign ${id} during batch processing:`, error);
await this.updateCampaignStatus(id, CampaignStatus.FAILED, error.message);
// Re-throw or handle appropriately – the error might have occurred mid-send
throw new Error(`Failed to send campaign ${id}: ${error.message}`);
}
}
private async updateCampaignStatus(id: number, status: CampaignStatus, statusReason?: string): Promise<void> {
const data: any = { status };
if (statusReason) {
data.statusReason = statusReason;
}
if (status === CampaignStatus.SENT || status === CampaignStatus.SENDING) {
data.sentAt = new Date(); // Record timestamp when sending starts/completes
}
await this.prisma.campaign.update({
where: { id },
data,
});
}
// --- Scheduled Campaign Handling ---
@Cron(CronExpression.EVERY_MINUTE) // Check every minute
async handleScheduledCampaigns() {
this.logger.log('Checking for scheduled campaigns to send…');
const now = new Date();
const campaignsToSend = await this.prisma.campaign.findMany({
where: {
status: CampaignStatus.SCHEDULED,
scheduledAt: {
lte: now, // Less than or equal to current time
},
},
});
if (campaignsToSend.length === 0) {
// this.logger.log('No scheduled campaigns due.'); // Reduce log noise
return;
}
this.logger.log(`Found ${campaignsToSend.length} scheduled campaign(s) to send.`);
for (const campaign of campaignsToSend) {
this.logger.log(`Attempting to send scheduled campaign ID: ${campaign.id}`);
try {
// Reuse the existing sendCampaign logic, which handles status updates and batching
await this.sendCampaign(campaign.id);
// sendCampaign updates status internally (SENDING → SENT/FAILED)
} catch (error) {
this.logger.error(`Error sending scheduled campaign ID ${campaign.id}:`, error.message);
// sendCampaign already updates status to FAILED on error, no need to update here
}
}
}
}Campaigns Controller (src/campaigns/campaigns.controller.ts):
// src/campaigns/campaigns.controller.ts
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe } from '@nestjs/common';
import { CampaignsService } from './campaigns.service';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { UpdateCampaignDto } from './dto/update-campaign.dto';
@Controller('campaigns')
export class CampaignsController {
constructor(private readonly campaignsService: CampaignsService) {}
@Post()
create(@Body() createCampaignDto: CreateCampaignDto) {
return this.campaignsService.create(createCampaignDto);
}
@Get()
findAll() {
return this.campaignsService.findAll();
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.campaignsService.findOne(id);
}
@Patch(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() updateCampaignDto: UpdateCampaignDto) {
return this.campaignsService.update(id, updateCampaignDto);
}
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.campaignsService.remove(id);
}
@Post(':id/send')
send(@Param('id', ParseIntPipe) id: number) {
return this.campaignsService.sendCampaign(id);
}
}Sinch Module (src/sinch/):
Service (sinch.service.ts): Handle interaction with the Sinch API. This service implements exponential backoff retry logic for transient errors (429 rate limits, 5xx server errors, network failures). The strategy starts with a 500ms delay and doubles on each retry, up to 3 attempts total. Use exponential backoff for production systems to handle temporary API failures without overwhelming the service.
// src/sinch/sinch.service.ts
import { Injectable, Inject, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom, retry, delay, map, catchError, throwError, timer } from 'rxjs';
import { AxiosRequestConfig, AxiosError } from 'axios';
// Define which HTTP status codes from Sinch might warrant a retry
const RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504];
const MAX_RETRIES = 3;
const INITIAL_DELAY_MS = 500; // Initial delay before first retry
@Injectable()
export class SinchService {
private readonly logger = new Logger(SinchService.name);
// IMPORTANT: Adjust the region prefix ('us', 'eu', etc.) to match your Sinch account's service plan region.
// US region: https://us.sms.api.sinch.com/xms/v1/
// EU region: https://eu.sms.api.sinch.com/xms/v1/
// Reference: https://developers.sinch.com/docs/sms/api-reference/
private readonly sinchApiUrl = 'https://us.sms.api.sinch.com/xms/v1/';
private readonly servicePlanId: string;
private readonly apiToken: string;
private readonly fromNumber: string;
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {
this.servicePlanId = this.configService.get<string>('SINCH_SERVICE_PLAN_ID');
this.apiToken = this.configService.get<string>('SINCH_API_TOKEN');
this.fromNumber = this.configService.get<string>('SINCH_FROM_NUMBER');
if (!this.servicePlanId || !this.apiToken || !this.fromNumber) {
throw new Error('Sinch credentials (Service Plan ID, API Token, From Number) are not configured in .env');
}
this.logger.log(`SinchService initialized for region: ${this.sinchApiUrl}`);
}
private getAuthHeaders(): Record<string, string> {
return {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
}
async sendSingleSms(to: string, body: string): Promise<any> {
// Sinch batch endpoint handles both single and bulk messages
this.logger.log(`Sending single SMS to ${to} via Sinch.`);
return this.sendBulkSms([to], body);
}
async sendBulkSms(to: string[], body: string): Promise<any> {
if (!to || to.length === 0) {
this.logger.warn('sendBulkSms called with empty recipient list.');
return { message: "No recipients provided." };
}
// Sinch SMS API supports up to 1,000 recipients per batch request (increased from 100).
// Reference: https://developers.sinch.com/docs/sms/api-reference/ (Batches endpoint)
if (to.length > 1000) {
this.logger.warn(`Batch size ${to.length} exceeds Sinch's 1,000 recipient limit. Consider splitting into multiple batches.`);
}
const endpoint = `${this.sinchApiUrl}${this.servicePlanId}/batches`;
const payload = {
from: this.fromNumber,
to: to, // Array of E.164 numbers (up to 1,000 recipients)
body: body,
// Add other parameters like delivery_report if needed
// delivery_report: 'full' // Example: Request delivery reports
};
const config: AxiosRequestConfig = {
headers: this.getAuthHeaders(),
};
this.logger.log(`Attempting to send bulk SMS to ${to.length} numbers via Sinch endpoint: ${endpoint}`);
// Avoid logging full PII (phone numbers) in production logs unless debugging
// this.logger.debug(`Sinch Payload: ${JSON.stringify(payload)}`);
const request$ = this.httpService.post(endpoint, payload, config).pipe(
map(response => {
this.logger.log(`Sinch API response status: ${response.status}`);
// Check Sinch documentation for expected success response structure (often 201 Created for batches)
if (response.status >= 200 && response.status < 300 && response.data?.id) {
this.logger.log(`Successfully initiated batch SMS. Batch ID: ${response.data.id}`);
return response.data; // Return the response data (contains batch ID)
} else {
// Treat unexpected success codes or missing data as potential issues
this.logger.warn(`Sinch API returned status ${response.status} but missing expected data (batch ID). Response: ${JSON.stringify(response.data)}`);
// Depending on Sinch API, maybe still return data or throw specific error
return response.data || { warning: `Unexpected success response status ${response.status}` };
}
}),
retry({
count: MAX_RETRIES,
delay: (error: AxiosError, retryCount: number) => {
const statusCode = error.response?.status;
// Only retry specific HTTP errors or network errors
// Rate limits (429) and server errors (5xx) are retryable per Sinch API docs
// Reference: https://developers.sinch.com/docs/sms/resources/message-info/rate-limits/
if ((statusCode && RETRYABLE_STATUS_CODES.includes(statusCode)) || !statusCode) { // Retry network errors too
const delayMs = INITIAL_DELAY_MS * Math.pow(2, retryCount - 1); // Exponential backoff
this.logger.warn(`Retryable error detected sending SMS (Status: ${statusCode || 'Network Error'}). Retrying (${retryCount}/${MAX_RETRIES}) in ${delayMs}ms…`);
return timer(delayMs); // Use timer for delayed emission
} else {
// Don't retry non-retryable errors (e.g., 400 Bad Request, 401 Unauthorized, 403 Forbidden)
this.logger.error(`Non-retryable error sending SMS (Status: ${statusCode}). Aborting retries.`);
return throwError(() => error); // Propagate the error immediately
}
},
}),
catchError((error: AxiosError) => {
this.logger.error('Error sending SMS via Sinch after retries (or non-retryable error):', error.response?.data || error.message);
// Try to extract a meaningful error message from Sinch's response structure
const errorDetail = error.response?.data as any; // Type assertion for easier access
const errorMessage = errorDetail?.requestError?.serviceException?.text ||
errorDetail?.requestError?.policyException?.text ||
errorDetail?.text || // Check structure based on Sinch docs
(error.response?.status ? `Status ${error.response.status}` : error.message);
// Wrap in a standard Error object to ensure a clean message propagates
return throwError(() => new Error(`Failed to send SMS via Sinch: ${errorMessage}`));
})
);
return firstValueFrom(request$);
}
}Webhooks Controller (src/webhooks/webhooks.controller.ts):
Handle inbound SMS messages from Sinch for opt-out processing. Sinch sends webhook payloads when users reply to your messages. Implement signature verification in production to ensure webhooks originate from Sinch.
// src/webhooks/webhooks.controller.ts
import { Controller, Post, Body, Logger, HttpCode } from '@nestjs/common';
import { SubscribersService } from '../subscribers/subscribers.service';
@Controller('webhooks')
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
constructor(private readonly subscribersService: SubscribersService) {}
@Post('sinch-inbound')
@HttpCode(200)
async handleInboundSms(@Body() payload: any) {
this.logger.log(`Received inbound SMS webhook: ${JSON.stringify(payload)}`);
// TODO: Implement webhook signature verification for security
// Reference: https://developers.sinch.com/docs/sms/api-reference/sms/tag/Webhooks/
const from = payload.from; // Sender's phone number
const body = payload.body?.toLowerCase() || ''; // Message text
// Check for opt-out keywords
const optOutKeywords = ['stop', 'unsubscribe', 'optout', 'cancel', 'end', 'quit'];
const isOptOut = optOutKeywords.some(keyword => body.includes(keyword));
if (isOptOut) {
this.logger.log(`Opt-out detected from ${from}. Updating subscriber…`);
try {
await this.subscribersService.optOutByPhone(from);
this.logger.log(`Subscriber ${from} opted out successfully.`);
} catch (error) {
this.logger.error(`Error opting out subscriber ${from}:`, error.message);
}
} else {
this.logger.log(`Inbound message from ${from} does not contain opt-out keywords.`);
}
return { status: 'received' }; // Acknowledge receipt
}
}Health Check Controller (src/health/health.controller.ts):
Monitor application health for production deployments.
// src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, PrismaHealthIndicator } from '@nestjs/terminus';
import { PrismaService } from '../prisma/prisma.service';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: PrismaHealthIndicator,
private prisma: PrismaService,
) {}
@Get()
@HealthCheck()
check() {
return this.health.check([
() => this.db.pingCheck('database', this.prisma),
]);
}
}Frequently Asked Questions About Sinch SMS Marketing with NestJS
How do I integrate Sinch SMS API with NestJS 11?
Integrate Sinch SMS API with NestJS 11 by installing @nestjs/axios and axios, then creating a SinchService that uses HttpService to make authenticated requests to Sinch's regional endpoints (https://us.sms.api.sinch.com or https://eu.sms.api.sinch.com). Store your Service Plan ID and API Token in environment variables using @nestjs/config, and implement the service with retry logic for 429/5xx errors. Sinch supports batch messaging with up to 1,000 recipients per request for efficient bulk sending.
What is the maximum batch size for Sinch SMS API?
Sinch SMS API supports up to 1,000 recipients per batch request (increased from 100 in previous versions). To send to more subscribers, implement batch processing by splitting your recipient list into chunks of 1,000 phone numbers and sending multiple requests. Use Prisma's findMany with take and skip parameters to fetch subscribers in batches from your PostgreSQL database, avoiding memory issues with large lists.
How do I handle SMS opt-outs in NestJS with Sinch webhooks?
Handle SMS opt-outs by configuring Sinch inbound message webhooks to POST to your NestJS endpoint (e.g., /api/webhooks/sinch-inbound). Parse incoming webhook payloads to detect opt-out keywords like "STOP", "UNSUBSCRIBE", or "OPTOUT", then update the subscriber's optedIn status to false in your PostgreSQL database using Prisma. Implement webhook signature verification for security and return 200 OK responses to acknowledge receipt. Filter opted-out subscribers from future campaigns using where: { optedIn: true } in your Prisma queries.
How do I schedule SMS campaigns in NestJS?
Schedule SMS campaigns in NestJS using @nestjs/schedule with cron jobs. Add a @Cron decorator to a method that checks for campaigns with status: SCHEDULED and scheduledAt timestamp less than or equal to the current time. Run this check every minute using CronExpression.EVERY_MINUTE, then trigger your existing sendCampaign method for matching campaigns. Store campaign schedules as ISO 8601 date strings in PostgreSQL, and update campaign status to SENDING → SENT or FAILED based on results.
What are Sinch SMS API rate limits for NestJS applications?
Sinch SMS API rate limits vary by service plan and are measured in messages per second (not requests per second). Each recipient in a batch counts as one message toward your rate limit. Sinch queues messages per service plan in FIFO order, so new batches are accepted immediately but may be delayed if earlier batches are still processing. Implement exponential backoff retry logic for 429 (Too Many Requests) responses, and consider adding delays between batch sends if hitting rate limits frequently.
How do I send bulk SMS to 1,000+ subscribers with Sinch and NestJS?
Send bulk SMS to 1,000+ subscribers by implementing batch processing in your NestJS CampaignsService. Fetch subscribers in batches of 1,000 using Prisma's findMany with take: 1000 and incrementing skip values. For each batch, call sinchService.sendBulkSms(phoneNumbers, message) where phoneNumbers is an array of E.164-formatted numbers. Track batch IDs returned by Sinch for delivery monitoring. This approach scales efficiently without loading all subscribers into memory and respects Sinch's 1,000 recipient limit.
How do I store Sinch SMS credentials securely in NestJS?
Store Sinch SMS credentials securely using @nestjs/config with environment variables. Add SINCH_SERVICE_PLAN_ID, SINCH_API_TOKEN, and SINCH_FROM_NUMBER to a .env file (never commit to Git). Inject ConfigService into your SinchService and retrieve credentials using configService.get<string>('SINCH_SERVICE_PLAN_ID'). Validate that all required credentials are present on service initialization and throw an error if any are missing. For production, use your deployment platform's secret management (Vercel, AWS Secrets Manager, HashiCorp Vault).
What database schema do I need for SMS marketing campaigns with Prisma?
Design your Prisma schema with three main models: Subscriber (id, phone, name, optedIn, createdAt, updatedAt), Campaign (id, name, message, status, targetSegment, scheduledAt, sentAt, statusReason, createdAt, updatedAt), and optionally Message for tracking individual message delivery (id, subscriberId, campaignId, status, sinchBatchId, sentAt, deliveredAt). Use unique constraints on Subscriber.phone to prevent duplicates. Define a CampaignStatus enum with values: DRAFT, SCHEDULED, SENDING, SENT, FAILED. Index frequently queried fields like Subscriber.optedIn and Campaign.status for query performance.
How do I track SMS delivery status with Sinch webhooks?
Track SMS delivery status by configuring Sinch delivery report webhooks to POST to your NestJS endpoint. Sinch sends delivery reports with statuses: queued, dispatched, aborted, rejected, delivered, failed, expired, and unknown. Store batch IDs returned from sendBulkSms calls in your Campaign model, then correlate incoming delivery webhooks using the batch ID. Create a Message model to track individual message status per subscriber. Implement webhook signature verification to ensure authenticity.
How do I handle international SMS sending with Sinch?
Handle international SMS by using E.164 phone number format (e.g., +442071234567 for UK numbers) and understanding Sinch's pricing varies by destination country. Enable international sending in your Sinch account settings, and validate phone numbers with class-validator's @IsPhoneNumber() decorator. Consider country-specific regulations: some countries require sender ID registration, while others restrict marketing messages. Check Sinch's country-specific requirements and pricing at https://www.sinch.com/products/messaging/sms/pricing/.
Production Deployment Checklist
Before deploying your SMS marketing system, complete these critical tasks:
Security:
- Enable webhook signature verification for Sinch webhooks
- Implement API authentication (JWT, API keys) for your endpoints
- Add rate limiting with
@nestjs/throttlerto prevent abuse - Use environment-specific configurations (development, staging, production)
- Enable HTTPS for all endpoints (especially webhooks)
- Implement input sanitization and validation on all endpoints
- Configure CORS appropriately for your frontend
Database:
- Run Prisma migrations in production with
npx prisma migrate deploy - Set up database connection pooling
- Enable database backups and point-in-time recovery
- Index frequently queried fields (see Prisma schema)
- Monitor database performance and slow queries
Monitoring:
- Set up application logging (Winston, Pino)
- Implement error tracking (Sentry, Rollbar)
- Monitor API response times and error rates
- Track SMS delivery rates and failures
- Set up alerts for critical failures (campaign send failures, database down)
- Monitor Sinch account balance and rate limit usage
Testing:
- Write unit tests for services (SubscribersService, CampaignsService, SinchService)
- Write integration tests for API endpoints
- Test webhook handling with Sinch webhook examples
- Test batch processing with 10,000+ subscribers
- Test retry logic with simulated API failures
- Test scheduled campaigns with various timezones
Deployment:
- Containerize with Docker (see example Dockerfile below)
- Set up CI/CD pipeline (GitHub Actions, GitLab CI)
- Use managed PostgreSQL (AWS RDS, Google Cloud SQL, Supabase)
- Configure auto-scaling for high-traffic periods
- Set up staging environment for testing
Example Dockerfile:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma/
RUN npm ci --only=production
RUN npx prisma generate
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "start:prod"]Example GitHub Actions Workflow:
name: Deploy NestJS SMS Service
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 22
- run: npm ci
- run: npx prisma generate
- run: npm run build
- run: npm test
# Add deployment steps (Docker push, Kubernetes deploy, etc.)Troubleshooting Common Issues
Problem: "Sinch API returns 401 Unauthorized"
Check your SINCH_API_TOKEN and SINCH_SERVICE_PLAN_ID in .env. Verify the token hasn't expired and the service plan ID matches your Sinch account. Test credentials with curl:
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
https://us.sms.api.sinch.com/xms/v1/YOUR_SERVICE_PLAN_ID/batchesProblem: "Campaign sends but no messages delivered"
- Verify phone numbers are in E.164 format (
+1234567890) - Check Sinch dashboard for batch status and delivery reports
- Confirm
SINCH_FROM_NUMBERis registered and verified - Check subscriber
optedInstatus in database - Review Sinch account balance – insufficient funds prevent delivery
Problem: "Database connection timeout errors"
Increase connection pool size in your DATABASE_URL:
DATABASE_URL="postgresql://user:password@localhost:5432/sms_campaigns?schema=public&connection_limit=20"
Enable connection pooling with PgBouncer for production deployments.
Problem: "Memory errors with large subscriber lists"
Verify you're using batch processing with take and skip parameters, not fetching all subscribers at once with findAll(). Each batch should fetch exactly 1,000 subscribers or fewer.
Problem: "Webhook endpoint returns 404 Not Found"
Ensure your webhook route is registered in AppModule and the full URL matches your Sinch webhook configuration. Test locally with ngrok:
ngrok http 3000
# Configure Sinch webhook URL: https://YOUR_NGROK_URL/webhooks/sinch-inboundProblem: "Scheduled campaigns not sending"
- Verify
@nestjs/scheduleis imported inAppModule - Check campaign
statusis set toSCHEDULED, notDRAFT - Confirm
scheduledAtis in the past (scheduled campaigns only run ifscheduledAt <= now) - Review application logs for cron job execution
- Ensure server timezone matches your expected scheduling timezone
Final Outcome:
You now have a production-ready NestJS API capable of managing SMS subscribers, sending bulk campaigns via Sinch to 1,000+ recipients per batch, handling opt-outs through webhooks, scheduling campaigns with cron jobs, and integrating with PostgreSQL using Prisma ORM. The system implements retry logic, exponential backoff, batch processing, caching, and compliance features for TCPA, GDPR, and CAN-SPAM regulations.
Next Steps:
- Deploy to your production environment using Docker and CI/CD
- Configure Sinch webhooks for delivery reports and inbound messages
- Implement user authentication for your API endpoints
- Build a frontend dashboard for campaign management
- Add analytics and reporting for campaign performance
- Implement advanced segmentation for targeted campaigns
- Set up monitoring and alerting for production issues
Frequently Asked Questions
How to send SMS marketing campaigns with NestJS?
Build a backend system with NestJS and integrate the Sinch SMS API. This allows you to manage subscribers, create targeted campaigns, send messages reliably, handle opt-outs, and monitor performance, all within a scalable NestJS application.
What is Sinch used for in SMS marketing?
Sinch is a reliable and scalable SMS API used to send and receive SMS messages globally. Its features include delivery reports, inbound message handling (webhooks), and support for various number types like shortcodes and toll-free numbers.
Why use NestJS for building SMS campaigns?
NestJS is a progressive Node.js framework offering a modular architecture, TypeScript support, and built-in features like dependency injection and validation pipes. These features make it ideal for building efficient, reliable, and scalable server-side applications for SMS marketing.
How to manage SMS subscribers in a NestJS app?
You can manage subscribers by implementing CRUD (Create, Read, Update, Delete) operations within a dedicated Subscribers module. This involves creating data transfer objects (DTOs) for validation and using a database service like Prisma to interact with the database.
What database is recommended for NestJS SMS campaigns?
PostgreSQL is a robust open-source relational database recommended for this project, although Prisma supports other databases like MySQL and SQLite. Docker can be used for containerizing the database for consistent development and deployment.
How to integrate Sinch API with NestJS?
Create a dedicated Sinch module and service in your NestJS application. Use the `@nestjs/axios` package to make HTTP requests to the Sinch API, managing authentication and handling responses within the service.
What is Prisma used for in the NestJS SMS project?
Prisma is a modern database toolkit (ORM) for Node.js and TypeScript. It simplifies database access, schema migrations, and ensures type safety when interacting with the database from your NestJS application.
When should I use Docker with NestJS?
Using Docker is recommended for local database setup and for containerizing the NestJS application itself. This ensures consistent development and deployment environments, simplifying the process and reducing potential issues.
How to handle SMS opt-outs with Sinch and NestJS?
Sinch webhooks can be used to handle opt-outs. When a user replies with a keyword like "STOP", the webhook triggers a function in your NestJS app to update the subscriber's opt-in status in the database.
How to send bulk SMS messages efficiently with Sinch?
The Sinch API supports sending bulk messages. The provided example uses a batching mechanism and an exponential retry with backoff to handle Sinch rate limiting and network errors. This increases efficiency and reduces the risk of exceeding API limits.
What are the prerequisites for this NestJS SMS project?
You'll need Node.js (LTS recommended), npm or yarn, Docker and Docker Compose, a Sinch account with SMS API access, a Sinch phone number, and a basic understanding of TypeScript, NestJS, REST APIs, and databases.
How to structure a NestJS project for SMS marketing?
Organize your project into modules for different functionalities (Subscribers, Campaigns, Sinch, Prisma, Config, etc.). This modular structure promotes maintainability, scalability, and code organization.
How to handle errors when sending SMS messages with Sinch?
Implement robust error handling in the Sinch service to catch potential issues like network problems or API errors. Consider retry mechanisms and appropriate logging for debugging and monitoring.
Can I schedule SMS campaigns with this NestJS setup?
Yes, the provided code demonstrates basic scheduling. You can implement simple campaign scheduling using the `@nestjs/schedule` package along with Prisma to store scheduled times and update campaign statuses after execution.