This guide provides a complete walkthrough for building a robust backend system using NestJS to manage and send SMS marketing campaigns via the Sinch API. We'll cover everything from project setup and core logic to security, error handling, deployment, and verification.
You'll learn how to create a scalable application capable of managing subscribers, crafting campaigns, sending SMS messages reliably through Sinch, handling opt-outs, and monitoring performance. By the end, you'll have a production-ready NestJS application equipped for effective SMS marketing.
Project Overview and Goals
What We're Building:
A NestJS-based API service that enables:
- Subscriber Management: Adding, updating, deleting, and listing subscribers who have opted into receiving SMS marketing messages.
- Campaign Creation: Defining SMS marketing campaigns with specific messages targeted at subscriber segments or the entire list.
- SMS Sending via Sinch: Integrating with the Sinch SMS API to send individual and bulk messages reliably.
- Opt-Out Handling: Managing subscriber opt-outs based on keywords (e.g., STOP) received via Sinch webhooks.
- Basic Scheduling: Implementing simple campaign scheduling.
Problem Solved:
This system provides developers with a foundational backend to power SMS marketing efforts, automating subscriber management and message delivery through a trusted provider like Sinch, while ensuring compliance and scalability.
Technologies Used:
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Its modular architecture, TypeScript support, and built-in features (dependency injection, validation pipes, etc.) make it ideal for this type of project.
- Sinch SMS API: A powerful API for sending and receiving SMS messages globally. We use it for its reliability, scalability, and features like delivery reports and inbound message handling (webhooks).
- Prisma: A modern database toolkit (ORM) for Node.js and TypeScript. It simplifies database access, migrations, and type safety.
- PostgreSQL: A robust open-source relational database (though Prisma supports others like MySQL, SQLite, etc.).
- Docker: For containerizing the application for consistent development and deployment environments.
System Architecture:
graph LR
subgraph ""External Systems""
S(Sinch API)
DB[(PostgreSQL DB)]
end
subgraph ""NestJS Application""
C(API Client / Frontend) --> API{REST API Layer / Controllers};
API --> SVC{Services / Business Logic};
SVC --> SINCH[Sinch Service];
SVC --> PRISMA[Prisma Service];
API --> Pipes[Validation Pipes];
API --> Auth[Auth Guard - Optional];
SINCH --> S;
PRISMA --> DB;
subgraph ""Modules""
M_Sub(Subscribers Module)
M_Camp(Campaigns Module)
M_Sinch(Sinch Module)
M_DB(Database Module / Prisma)
M_Cfg(Config Module)
end
API -- Belongs to --> M_Sub & M_Camp;
SVC -- Belongs to --> M_Sub & M_Camp & M_Sinch;
PRISMA -- Belongs to --> M_DB;
SINCH -- Belongs to --> M_Sinch;
end
S -- Webhooks --> API; // For opt-outs, delivery reports
(Note: Ensure your Markdown rendering platform supports Mermaid diagrams for this to display correctly.)
Prerequisites:
- Node.js (LTS version recommended)
- npm or yarn package manager
- Docker and Docker Compose (for local database setup and containerization)
- A Sinch account with access to the SMS API (Service Plan ID and API Token)
- A provisioned Sinch phone number (Short Code, Toll-Free, or 10DLC depending on region and use case)
- Basic understanding of TypeScript, NestJS, REST APIs, and databases.
Final Outcome:
A functional NestJS API capable of managing SMS subscribers and sending campaigns via Sinch, ready for integration with a frontend or other services.
1. Setting up the Project
Let's initialize our NestJS project and set up the basic structure and dependencies.
1. Install NestJS CLI: If you don't have it, install the NestJS command-line interface globally.
npm install -g @nestjs/cli
2. Create New NestJS Project:
Generate a new project named sms-campaign-service
.
nest new sms-campaign-service
cd sms-campaign-service
3. Install Dependencies: We need several packages for configuration, HTTP requests, validation, database interaction, scheduling (optional), 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 script
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 postgresql
- Configure
.env
: Update theDATABASE_URL
in the newly created.env
file with your PostgreSQL connection string. Example:# .env DATABASE_URL=""postgresql://user:password@localhost:5432/sms_campaigns?schema=public"" # Sinch Credentials (Add these) SINCH_SERVICE_PLAN_ID=""YOUR_SERVICE_PLAN_ID"" SINCH_API_TOKEN=""YOUR_API_TOKEN"" SINCH_FROM_NUMBER=""YOUR_SINCH_NUMBER"" # e.g., +12025550147
- Add
.env
to.gitignore
: Ensure your.env
file (containing secrets) is listed in your.gitignore
file to prevent committing it to version control.
5. Configure Docker for Local Database (Optional but Recommended):
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:15
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 -d
6. Project Structure & Configuration: NestJS promotes a modular structure. We'll create modules for distinct features (Subscribers, Campaigns, Sinch).
-
Configuration 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. Implementing Core Functionality
We'll now build the core modules: Subscribers, Campaigns, and the Sinch interaction service.
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 checks
We use the --no-spec
flag here to keep the guide concise; however, generating and maintaining test spec files (.spec.ts
) is a standard 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. Thecreate
method implements an ""upsert"" logic: if a phone number already exists, it updates the record instead of throwing an error. This design choice should be considered based on specific requirements.// 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 (covered in Section 3 - Note: Section 3 is not provided in the input, this comment remains from the original text but points to missing content).
Campaigns Module (src/campaigns/
):
-
DTO:
// src/campaigns/dto/create-campaign.dto.ts import { IsString, IsNotEmpty, IsOptional, IsDateString } from 'class-validator'; export class CreateCampaignDto { @IsNotEmpty() @IsString() name: string; @IsNotEmpty() @IsString() message: string; // Add length validation later if needed @IsOptional() @IsString() targetSegment?: string; // For future segmentation logic @IsOptional() @IsDateString() scheduledAt?: string; // ISO 8601 format }
// 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 toSinchService
. A critical point for scalability: thesendCampaign
method below now fetches subscribers in batches, which is essential for large lists. Fetching all subscribers at once (findAll()
) does not scale.// 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); const batchSize = 1000; // Send in batches of 1000 (adjust as needed based on Sinch limits/performance) 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 // 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 now 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 } } } }
Sinch Module (src/sinch/
):
-
Service (
sinch.service.ts
): Handles interaction with the Sinch API.// 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', 'ca', etc.) to match your Sinch account's service plan region. 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 often uses batch endpoint even for single 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."" }; } const endpoint = `${this.sinchApiUrl}${this.servicePlanId}/batches`; const payload = { from: this.fromNumber, to: to, // Array of E.164 numbers 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 (likely 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 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$); } }