messaging channels
messaging channels
Send SMS with Sinch, NestJS & Node.js: Complete Integration Guide
Build a production-ready SMS service with Sinch API, NestJS, and Node.js. Step-by-step tutorial covering API setup, authentication, error handling, rate limits, and deployment best practices.
Integrate the Sinch SMS API into a NestJS application using Node.js with this step-by-step walkthrough. You'll build a simple API endpoint capable of sending SMS messages via Sinch – covering project setup, core implementation, security considerations, error handling, testing, and deployment best practices.
By the end of this guide, you'll have a functional NestJS service that securely interacts with the Sinch API to send SMS messages, complete with validation, logging, and configuration management. This serves as a robust foundation for incorporating SMS functionality into larger applications.
Project Overview and Goals
Goal: Create a NestJS application with an API endpoint that accepts a phone number and message body, then uses the Sinch API to send an SMS message to that number.
Problem Solved: Integrate transactional or notification-based SMS messaging into your Node.js applications with a structured, reusable, and secure approach using the NestJS framework.
Common Use Cases:
- Two-factor authentication (2FA) codes
- Order confirmations and shipping updates
- Appointment reminders and notifications
- Password reset verification
Time to Complete: 45–60 minutes for experienced developers; 90–120 minutes for newcomers to NestJS.
Technologies Used:
- Node.js: The JavaScript runtime environment.
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Chosen for its modular architecture, dependency injection, and built-in support for features like validation and configuration.
- Sinch SMS API: The third-party service used for sending SMS messages. We'll use their official Node.js SDK.
- Sinch SDK:
@sinch/sdk-corev1.2.1,@sinch/smsv1.2.1 (latest as of January 2025)
- Sinch SDK:
- TypeScript: Superset of JavaScript used by NestJS for static typing.
- dotenv: For managing environment variables.
- class-validator / class-transformer: For request payload validation.
System Architecture:
+-----------+ +-----------------+ +-------------+
| | HTTP | | API | |
| Client +-----> | NestJS API +-------> Sinch Cloud |
| (e.g. UI, | Request | (This Guide) | Call | (SMS API) |
| curl) | | | | |
+-----------+ +-------+---------+ +-------------+
|
| Uses Sinch SDK
| Injected Service
v
+---------------+
| Sinch Service |
+---------------+When an error occurs, the Sinch Service catches SDK exceptions, logs the issue, and throws an appropriate HttpException that the controller returns to the client with a clear error message.
Prerequisites:
- Node.js v18 or v20 (LTS versions recommended for 2024-2025; minimum v16 required for NestJS v10+) and npm installed.
- A Sinch account with API credentials (Project ID, Key ID, Key Secret).
- Setup Process: Create account at sinch.com → verify email → access Dashboard → generate API keys under Settings → Access Keys
- Cost Expectations: Pay-as-you-go pricing starts at $0.0075–$0.02 per SMS depending on destination country; no monthly fee for basic plans
- A provisioned Sinch phone number capable of sending SMS.
- Basic understanding of TypeScript, Node.js, and REST APIs.
- Access to a terminal or command prompt.
Source: NestJS documentation (v10 requires Node.js v16+); Node.js LTS release schedule
Final Outcome: A NestJS application running locally with a /sms/send endpoint that successfully sends an SMS via Sinch when provided valid credentials and input.
1. Setting up the Project
Initialize your NestJS project and install necessary dependencies.
-
Install NestJS CLI: If you don't have it, install the NestJS command-line interface globally.
bashnpm install -g @nestjs/cli -
Create New NestJS Project: Generate a new project. Replace
nestjs-sinch-smswith your desired project name.bashnest new nestjs-sinch-smsSelect
npmwhen prompted for the package manager. -
Navigate to Project Directory:
bashcd nestjs-sinch-sms -
Install Dependencies: Install the official Sinch SDK packages, NestJS config module for environment variables, and validation packages.
bashnpm install @sinch/sdk-core @sinch/sms @nestjs/config class-validator class-transformer dotenv
Troubleshooting Installation Issues:
| Issue | Solution |
|---|---|
EACCES permission error | Run with sudo or fix npm permissions following npm docs |
network timeout | Check firewall settings, try different npm registry with npm config set registry https://registry.npmjs.org/ |
| Conflicting dependencies | Delete node_modules and package-lock.json, then run npm install again |
-
Environment Setup (
.env): Create a.envfile in the project root for storing sensitive credentials.bashtouch .envAdd the following lines to
.env(leave values blank for now):dotenv# .env # Sinch API Credentials (Get from Sinch Dashboard → Settings → Access Keys) SINCH_PROJECT_ID= SINCH_KEY_ID= SINCH_KEY_SECRET= # Sinch Phone Number (Get from Sinch Dashboard → Numbers → Your Numbers) SINCH_NUMBER= # Sinch API Region (Optional – defaults typically work, check Sinch docs if needed) # e.g., us-1, eu-1 SINCH_REGION=- Purpose: Using
.envkeeps sensitive keys out of source control and allows for different configurations per environment.@nestjs/configwill load these variables into your application's environment.
- Purpose: Using
-
Configure ConfigModule: Import and configure
ConfigModulein your main application module (src/app.module.ts) to load the.envfile globally.typescript// src/app.module.ts import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ConfigModule } from '@nestjs/config'; // Import ConfigModule import { SinchModule } from './sinch/sinch.module'; // We will create this next import { SmsModule } from './sms/sms.module'; // We will create this next import { HealthModule } from './health/health.module'; // Import HealthModule import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; // Import Throttler import { APP_GUARD } from '@nestjs/core'; // Import APP_GUARD @Module({ imports: [ ConfigModule.forRoot({ // Configure ConfigModule isGlobal: true, // Make config available globally envFilePath: '.env', // Specify the env file path }), ThrottlerModule.forRoot([{ // Configure Throttler ttl: 60000, // 60 seconds limit: 10, // 10 requests per IP per ttl }]), SinchModule, // Add SinchModule SmsModule, // Add SmsModule HealthModule, // Add HealthModule ], controllers: [AppController], providers: [ AppService, { // Apply ThrottlerGuard globally provide: APP_GUARD, useClass: ThrottlerGuard, }, ], }) export class AppModule {}
Why configure ThrottlerModule here? Rate limiting protects your endpoint from abuse from the start. Configuring it in app.module.ts applies throttling globally to all endpoints, preventing attackers from overwhelming your SMS service with excessive requests before you implement the actual SMS logic.
2. Implementing Core Functionality (Sinch Service)
Create a dedicated module and service for interacting with the Sinch SDK. This promotes modularity and separation of concerns.
- Generate Sinch Module and Service: Use the NestJS CLI to scaffold the module and service.
This createsbash
nest generate module sinch nest generate service sinchsrc/sinch/sinch.module.tsandsrc/sinch/sinch.service.ts.
Dependency Injection Benefits: NestJS's dependency injection allows you to inject SinchService into any controller or service that needs SMS functionality. This makes your code testable (easily swap real service for mocks), maintainable (single source of truth for Sinch logic), and scalable (reuse the service across multiple modules without duplication).
-
Implement SinchService: Edit
src/sinch/sinch.service.ts. This service will initialize the Sinch client using credentials from the environment and provide a method to send SMS.typescript// src/sinch/sinch.service.ts import { Injectable, Logger, OnModuleInit, HttpException, HttpStatus } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SinchClient } from '@sinch/sdk-core'; import { SendBatchRequest } from '@sinch/sms'; @Injectable() export class SinchService implements OnModuleInit { private readonly logger = new Logger(SinchService.name); private sinchClient: SinchClient; private sinchNumber: string; constructor(private configService: ConfigService) {} // OnModuleInit ensures client is initialized after config is ready onModuleInit() { const projectId = this.configService.get<string>('SINCH_PROJECT_ID'); const keyId = this.configService.get<string>('SINCH_KEY_ID'); const keySecret = this.configService.get<string>('SINCH_KEY_SECRET'); this.sinchNumber = this.configService.get<string>('SINCH_NUMBER'); const region = this.configService.get<string>('SINCH_REGION'); // Optional region if (!projectId || !keyId || !keySecret || !this.sinchNumber) { this.logger.error('Missing Sinch credentials or phone number in .env file'); // Throw error during initialization if config is missing throw new Error('Sinch configuration is incomplete. Check .env file.'); } // Initialize the Sinch Client this.sinchClient = new SinchClient({ projectId, keyId, keySecret, ...(region && { smsRegion: region }), }); this.logger.log('Sinch Client Initialized'); } /** * Sends an SMS message using the Sinch API. * @param to The recipient's phone number (E.164 format recommended: e.g., +15551234567). * @param body The text message content. * @returns The result from the Sinch API batches.send operation. * @throws HttpException if sending fails or configuration is missing. */ async sendSms(to: string, body: string): Promise<any> { if (!this.sinchClient) { // This case should ideally be prevented by the OnModuleInit check, but added for safety. this.logger.error('Sinch Client accessed before initialization.'); throw new HttpException('Sinch Client not initialized.', HttpStatus.INTERNAL_SERVER_ERROR); } // Basic validation: Check for '+' prefix for E.164 if (!to.startsWith('+')) { this.logger.warn(`Recipient number '${to}' might not be in E.164 format. Prepending '+' might be needed.`); // Note: This is a rudimentary check; robust E.164 validation/parsing (e.g., using google-libphonenumber) might be needed depending on input sources. } if (!this.sinchNumber.startsWith('+')) { this.logger.warn(`Sinch sender number '${this.sinchNumber}' might not be in E.164 format.`); } const sendBatchRequest: SendBatchRequest = { sendSMSRequestBody: { to: [to], // Expects an array of recipients from: this.sinchNumber, body: body, }, }; this.logger.log(`Attempting to send SMS to ${to}`); try { const response = await this.sinchClient.sms.batches.send(sendBatchRequest); this.logger.log(`SMS sent successfully. Batch ID: ${response.id}`); return response; } catch (error) { this.logger.error(`Failed to send SMS to ${to}: ${error.message}`, error.stack); // Example: Inspecting potential Sinch SDK error structure let statusCode = HttpStatus.INTERNAL_SERVER_ERROR; let message = `Sinch API error: ${error.message}`; // NOTE: The exact structure of the error object from the SDK might vary. // Inspect the actual error object during testing to confirm paths like 'error.response'. const sinchErrorCode = error?.response?.data?.error?.code; const sinchErrorMessage = error?.response?.data?.error?.message; const httpStatusCode = error?.response?.status; // Underlying HTTP status if available if (httpStatusCode === 401) { statusCode = HttpStatus.UNAUTHORIZED; message = 'Sinch authentication failed. Check API credentials.'; } else if (httpStatusCode === 400 || (sinchErrorCode && sinchErrorCode.toString().startsWith('40'))) { // Example mapping for Bad Request statusCode = HttpStatus.BAD_REQUEST; message = `Sinch Bad Request: ${sinchErrorMessage || error.message}`; } else if (httpStatusCode === 403) { statusCode = HttpStatus.FORBIDDEN; message = 'Sinch request forbidden. Check account permissions or sender ID registration.'; } else if (httpStatusCode === 429) { statusCode = HttpStatus.TOO_MANY_REQUESTS; message = 'Sinch rate limit exceeded. Reduce request frequency or upgrade plan.'; } else if (httpStatusCode === 503) { statusCode = HttpStatus.SERVICE_UNAVAILABLE; message = 'Sinch service temporarily unavailable. Implement retry logic.'; } // Re-throw as HttpException for NestJS to handle correctly in the controller layer throw new HttpException(message, statusCode); } } }- Why
OnModuleInit? EnsuresConfigServiceis ready before initializingSinchClient. - Why
ConfigService? Standard NestJS way to access configuration, keeping credentials separate. - Why SDK Client? Abstracts HTTP calls, handles auth, provides type safety.
- Why
HttpException? Provides standard HTTP error responses that NestJS understands, allowing controllers to handle them gracefully.
- Why
Common Sinch Error Codes:
| HTTP Status | Sinch Error Code | Meaning | Action |
|---|---|---|---|
| 401 | unauthorized | Invalid credentials | Verify API keys in .env |
| 403 | forbidden | Insufficient permissions | Check account status or sender ID registration |
| 400 | syntax_invalid_parameter_format | Invalid phone number | Ensure E.164 format with country code |
| 429 | rate_limit_exceeded | Too many requests | Implement exponential backoff |
| 503 | service_unavailable | Temporary outage | Retry with backoff strategy |
-
Export SinchService: Ensure
SinchServiceis exported fromSinchModule.typescript// src/sinch/sinch.module.ts import { Module } from '@nestjs/common'; import { SinchService } from './sinch.service'; import { ConfigModule } from '@nestjs/config'; // Ensure ConfigModule is available if not global @Module({ imports: [ConfigModule], // Make ConfigService available providers: [SinchService], exports: [SinchService], // Export the service for injection elsewhere }) export class SinchModule {}
3. Building the API Layer
Create the controller and DTO for the SMS sending endpoint.
-
Generate SMS Module and Controller:
bashnest generate module sms nest generate controller sms -
Create SendSmsDto: Define the request body structure and validation rules.
bashmkdir -p src/sms/dto # Create directory if it doesn't exist touch src/sms/dto/send-sms.dto.tsAdd the following content:
typescript// src/sms/dto/send-sms.dto.ts import { IsNotEmpty, IsPhoneNumber, IsString, MaxLength } from 'class-validator'; export class SendSmsDto { @IsNotEmpty() @IsPhoneNumber(null) // Use null for region code to allow any valid E.164 number @IsString() readonly to: string; // Recipient phone number (e.g., +15551234567) @IsNotEmpty() @IsString() @MaxLength(1600) // Sinch allows longer messages, potentially split into multiple parts readonly body: string; // Message content }- Why DTO? Provides automatic request payload validation via
class-validatorandValidationPipe.
- Why DTO? Provides automatic request payload validation via
Custom Validation Example: For business-specific constraints like preventing SMS to premium numbers, create a custom validator:
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';
export function IsNotPremiumNumber(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'isNotPremiumNumber',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const premiumPrefixes = ['+1900', '+1976']; // Example US premium prefixes
return typeof value === 'string' && !premiumPrefixes.some(prefix => value.startsWith(prefix));
},
defaultMessage(args: ValidationArguments) {
return 'Cannot send SMS to premium numbers';
}
}
});
};
}-
Implement SmsController: Inject
SinchServiceand create the POST endpoint.typescript// src/sms/sms.controller.ts import { Controller, Post, Body, Logger, HttpException, HttpStatus, HttpCode } from '@nestjs/common'; import { SinchService } from '../sinch/sinch.service'; // Adjust path if needed import { SendSmsDto } from './dto/send-sms.dto'; @Controller('sms') // Route prefix: /sms export class SmsController { private readonly logger = new Logger(SmsController.name); constructor(private readonly sinchService: SinchService) {} @Post('send') // Endpoint: POST /sms/send @HttpCode(HttpStatus.CREATED) // Explicitly set success status to 201 Created async sendSms(@Body() sendSmsDto: SendSmsDto) { // ValidationPipe is applied globally (see main.ts) this.logger.log(`Received request to send SMS to: ${sendSmsDto.to}`); try { const result = await this.sinchService.sendSms(sendSmsDto.to, sendSmsDto.body); // Return a success response, including the batch ID from Sinch return { message: 'SMS sending initiated successfully.', batchId: result?.id, // Access the batch ID from the Sinch response status: result?.status // Or other relevant info like initial status }; } catch (error) { this.logger.error(`Error in sendSms endpoint: ${error.message}`, error.stack); // If error is already an HttpException from SinchService, re-throw it if (error instanceof HttpException) { throw error; } // Otherwise, wrap it in a generic server error throw new HttpException( `Failed to send SMS due to an internal error.`, HttpStatus.INTERNAL_SERVER_ERROR, ); } } }- Why
@HttpCode(HttpStatus.CREATED)? Explicitly sets the HTTP status code for successful POST requests to 201, aligning with REST conventions and the E2E test expectation. - No Local
@UsePipes: We rely on the globalValidationPipeconfigured inmain.ts.
- Why
-
Update SmsModule: Import
SinchModuleto makeSinchServiceinjectable.typescript// src/sms/sms.module.ts import { Module } from '@nestjs/common'; import { SmsController } from './sms.controller'; import { SinchModule } from '../sinch/sinch.module'; // Import SinchModule @Module({ imports: [SinchModule], // Make SinchService available for injection into SmsController controllers: [SmsController], // No providers needed here unless the module had its own services }) export class SmsModule {} -
Enable Global ValidationPipe: Configure the
ValidationPipeglobally insrc/main.tsfor application-wide automatic validation.typescript// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { Logger, ValidationPipe } from '@nestjs/common'; // Import ValidationPipe and Logger async function bootstrap() { const app = await NestFactory.create(AppModule); const logger = new Logger('Bootstrap'); // Create a logger instance // Enable global validation pipe app.useGlobalPipes(new ValidationPipe({ whitelist: true, // Strip properties not defined in DTOs transform: true, // Automatically transform payloads to DTO instances forbidNonWhitelisted: true, // Throw error if non-whitelisted properties are present transformOptions: { enableImplicitConversion: true, // Allow basic type conversions if needed }, })); const port = process.env.PORT || 3000; // Use environment variable for port await app.listen(port); logger.log(`Application listening on port ${port}`); // Log the port } bootstrap();
4. Integrating with Sinch (Credentials)
Retrieve API credentials from Sinch and configure them in the .env file.
Step-by-Step Credential Setup:
-
Log in to Sinch: Navigate to the Sinch Customer Dashboard.
-
Locate Project ID:
- Click on your account name in the top-right corner
- Select "Account Settings"
- Find "Project ID" under "Project Details" (format:
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
-
Generate Access Keys:
- From the left sidebar, click "Settings" → "Access Keys"
- Click the blue "Generate new key" button
- Enter a descriptive name (e.g., "NestJS SMS Service")
- Copy the
Key ID(appears as a short alphanumeric string) - IMPORTANT: Copy the
Key Secretimmediately – it's shown only once and cannot be retrieved later - Store both values securely
-
Find Your Sinch Number:
- From the left sidebar, click "Numbers" → "Your numbers"
- Locate your active number in the list
- Copy the number in E.164 format (includes
+and country code, e.g.,+12065551212) - If no number exists, click "Buy a number" to provision one
-
Determine Region:
- Check your dashboard URL:
dashboard.sinch.com/sms/api/{region}/overview - Common regions:
us(United States),eu(Europe) - Set
SINCH_REGIONto match (e.g.,us-1,eu-1)
- Check your dashboard URL:
-
Update
.envFile: Populate the file with your credentials.dotenv# .env SINCH_PROJECT_ID=YOUR_ACTUAL_PROJECT_ID_HERE SINCH_KEY_ID=YOUR_ACTUAL_KEY_ID_HERE SINCH_KEY_SECRET=YOUR_ACTUAL_KEY_SECRET_HERE SINCH_NUMBER=+1xxxxxxxxxx # Your Sinch Number in E.164 format SINCH_REGION=us-1 # Or your specific region, e.g., eu-1
Security: Add .env to your .gitignore file to prevent committing secrets.
# .gitignore
# ... other entries
# Environment Variables
.env
*.env
!.env.example # Optional: commit an example file without secrets
# ... other entries5. Error Handling and Logging
This implementation incorporates key error handling and logging mechanisms:
- Logging: Uses NestJS's
Loggerin services, controllers, and bootstrap for key events and errors. Configure more robust logging (e.g., file transport, external services) for production. - Validation Errors: The global
ValidationPipeautomatically handles invalid request payloads, throwingBadRequestException(HTTP 400). - Sinch API Errors:
SinchServicecatches errors from the SDK, logs them, attempts to interpret common HTTP status codes (like 401 Unauthorized, 400 Bad Request, 403 Forbidden, 429 Too Many Requests, 503 Service Unavailable), and throws an appropriateHttpException. This allows theSmsControllerto receive structured errors.- The implementation in
SinchService(Section 2) already includes refined error handling that maps Sinch errors toHttpException. Customize this mapping based on specific Sinch error codes documented in their API reference.
- The implementation in
- Retry Mechanisms: For transient network issues or temporary Sinch unavailability (e.g., HTTP 503), consider implementing a retry strategy (e.g., using
async-retryor RxJS operators) withinSinchService.sendSms.
Exponential Backoff Retry Example:
import { retry } from 'async-retry';
async sendSmsWithRetry(to: string, body: string): Promise<any> {
return retry(
async (bail) => {
try {
return await this.sendSms(to, body);
} catch (error) {
// Don't retry on client errors (4xx except 429)
if (error.status >= 400 && error.status < 500 && error.status !== 429) {
bail(error);
return;
}
throw error; // Retry on 5xx or 429
}
},
{
retries: 3,
minTimeout: 1000, // Start with 1s
maxTimeout: 8000, // Max 8s between retries
factor: 2, // Exponential backoff factor
}
);
}6. Database Schema and Data Layer (Optional)
For production systems, store records of sent SMS messages (recipient, timestamp, content, batchId, delivery status).
Recommended Schema Example (TypeORM):
// src/sms/entities/sms-log.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';
@Entity('sms_logs')
export class SmsLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
recipient: string; // E.164 format phone number
@Column({ type: 'text' })
body: string;
@Column({ nullable: true })
batchId: string; // Sinch batch ID
@Column({ default: 'pending' })
status: string; // pending, sent, delivered, failed
@CreateDateColumn()
sentAt: Date;
@Column({ nullable: true })
deliveredAt: Date;
@Column({ type: 'jsonb', nullable: true })
errorDetails: any; // Store error information
}This approach enables:
- Audit trails for compliance requirements
- Tracking delivery status via Sinch webhooks
- Cost analysis and usage reporting
- Debugging failed message deliveries
7. Security Features
- Input Validation: Handled by
class-validatorandValidationPipe. - API Key Security: Store keys in
.env, exclude from Git. Use secure environment variable management in production. - Rate Limiting: Protect against abuse using
@nestjs/throttler.- Install:
npm install @nestjs/throttler - Configure in
app.module.ts(already shown in Section 1, Step 6).
- Install:
- Authentication/Authorization: Protect the
/sms/sendendpoint using standard methods like JWT (@nestjs/jwt,@nestjs/passport) or API Keys if the API is exposed externally.
JWT Authentication Implementation:
// src/auth/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// Apply to SMS controller
// src/sms/sms.controller.ts
import { UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('sms')
@UseGuards(JwtAuthGuard) // Require JWT authentication
export class SmsController {
// ... controller methods
}Configure JWT strategy and module following NestJS authentication documentation.
8. Handling Special Cases
- E.164 Phone Number Format: Sinch requires E.164 (
+countrycode...). TheIsPhoneNumbervalidator helps, but robust parsing might be needed for diverse inputs. The warning log inSinchServiceprovides a basic check. - Message Encoding & Length: Standard SMS using GSM-7 encoding supports 160 characters per single message segment. When messages exceed this limit, they're split into 153-character segments (7 characters reserved for concatenation headers). Non-GSM characters (emojis, Chinese script, special symbols) require UCS-2 encoding, which supports 70 characters per single segment or 67 characters per segment for concatenated messages. Sinch handles concatenation automatically, but longer messages and special characters increase costs (multiple message parts). The
MaxLength(1600)validation in the DTO allows for approximately 10 concatenated segments.
Source: GSM-7 and UCS-2 encoding standards; Twilio SMS Character Encoding documentation; Sinch SMS API documentation
Cost Calculation by Message Length:
| Message Length | Encoding | Segments | Cost Example (US) | Notes |
|---|---|---|---|---|
| 1–160 chars | GSM-7 | 1 | $0.0075 | Standard Latin characters only |
| 161–306 chars | GSM-7 | 2 | $0.015 | 153 chars per segment |
| 1–70 chars | UCS-2 | 1 | $0.0075 | Contains emojis or special chars |
| 71–134 chars | UCS-2 | 2 | $0.015 | 67 chars per segment |
| 800 chars | GSM-7 | 6 | $0.045 | Long notification message |
Note: Actual pricing varies by destination country. Check Sinch pricing page for specific rates.
- Regional Regulations: Comply with SMS rules (opt-in, timing, sender ID registration like A2P 10DLC in the US) for target regions. Consult Sinch resources.
9. Performance Optimizations
- Async Operations: Leverage Node.js/NestJS async nature with
async/awaitfor non-blocking I/O. - Sinch Client Initialization: Initialize once in
SinchService(onModuleInit) for reuse. - Payload Size: Keep request/response payloads concise.
- Caching: Generally not applicable for unique SMS sends.
- Rate Limits: Each Sinch service plan includes a rate limit that sets the maximum number of messages that can be sent per second (calculated from all API messages). Batch messages count each recipient separately toward rate limits. Status queries are limited to 1 request per second per IP address; exceeding this returns HTTP 429. Check your Sinch account settings for your specific throughput limits.
Source: Sinch SMS API Rate Limits documentation (2024)
High-volume bottlenecks are more likely Sinch rate limits or carrier issues than the NestJS app itself.
Load Testing Recommendations:
- Use tools like Apache JMeter, k6, or Artillery to simulate production load
- Test with 100–1,000 requests per minute to identify bottlenecks
- Monitor response times; target < 500ms for SMS API calls
- Track error rates; maintain < 0.1% error rate under normal load
- Test rate limit behavior by exceeding configured throttle limits
10. Monitoring, Observability, and Analytics
- Health Checks: Use
@nestjs/terminusfor a/healthendpoint. - Logging: Implement centralized, structured logging (JSON) for production (ELK, Datadog, etc.).
- Error Tracking: Use services like Sentry (
@sentry/node,@sentry/nestjs). - Metrics: Track request rate, latency, error rate (Prometheus/Grafana, APM).
- Sinch Dashboard: Monitor delivery rates, costs, errors via Sinch's platform.
Example Health Check:
- Install:
npm install @nestjs/terminus - Generate module and controller:
bash
nest generate module health nest generate controller health - Implement Controller:
typescript
// src/health/health.controller.ts import { Controller, Get } from '@nestjs/common'; import { HealthCheckService, HealthCheck } from '@nestjs/terminus'; @Controller('health') export class HealthController { constructor(private health: HealthCheckService) {} @Get() @HealthCheck() check() { // Add specific checks (e.g., database ping, external service reachability) if needed // Example: return this.health.check([() => this.db.pingCheck('database')]); return this.health.check([]); // Basic liveness check } } - Implement Module:
typescript
// src/health/health.module.ts import { Module } from '@nestjs/common'; import { TerminusModule } from '@nestjs/terminus'; import { HealthController } from './health.controller'; @Module({ imports: [TerminusModule], // Import TerminusModule controllers: [HealthController], }) export class HealthModule {} - Import
HealthModuleintoAppModule(already shown in Section 1, Step 6).
Prometheus Metrics Example:
// Install: npm install @willsoto/nestjs-prometheus prom-client
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
// Add to app.module.ts imports
PrometheusModule.register({
defaultMetrics: {
enabled: true,
},
path: '/metrics', // Expose metrics at /metrics endpoint
}),Create custom metrics in SinchService:
import { Counter, Histogram } from 'prom-client';
const smsCounter = new Counter({
name: 'sms_sent_total',
help: 'Total number of SMS messages sent',
labelNames: ['status'],
});
const smsDuration = new Histogram({
name: 'sms_duration_seconds',
help: 'SMS API call duration',
});11. Troubleshooting and Caveats
HTTPError: 401 Unauthorized: Incorrect Sinch credentials (SINCH_PROJECT_ID,SINCH_KEY_ID,SINCH_KEY_SECRET). Verify.envagainst Sinch Dashboard (Settings → Access Keys). Regenerate keys if needed. Check.envloading.Invalid number/Parameter validation failed(HTTP 400):toorfromnumber format incorrect (needs E.164:+...). Sender number (SINCH_NUMBER) might not be provisioned correctly on Sinch. Verify numbers and formats.- Missing Credentials Error on Startup: Required
SINCH_...variables missing/empty in.env. Ensure.envis populated andConfigModuleis correctly set up. - SDK Version Issues: Ensure
@sinch/sdk-coreand@sinch/smsversions are compatible. Check Sinch docs. - Region Mismatch: Verify
SINCH_REGIONin.envmatches your account/number region if issues persist. - Sinch Service Outages: Check Sinch Status Page. Implement retries for transient errors.
- Cost: Monitor usage via Sinch Dashboard. Implement rate limiting.
- A2P 10DLC (US): Register brand/campaign via Sinch for US A2P SMS to avoid filtering.
Webhook Setup for Delivery Reports:
Configure delivery callbacks to track message status:
- Create webhook endpoint in your controller:
typescript
@Post('webhook/delivery') async handleDeliveryReport(@Body() report: any) { // Process delivery report from Sinch this.logger.log(`Delivery status: ${report.status} for batch ${report.batch_id}`); // Update database with delivery status } - Configure callback URL in Sinch Dashboard:
- Navigate to SMS API settings
- Set "Delivery Report URL" to
https://your-domain.com/sms/webhook/delivery - Enable "Delivery receipts" for your service plan
- Secure webhook with signature verification (see Sinch webhook documentation)
12. Deployment and CI/CD
- Build:
npm run build(createsdistfolder). - Deploy:
- Copy
dist,node_modules(or runnpm ci --omit=devon server after copyingdist,package.json,package-lock.json). - Provide production environment variables securely (hosting provider's secrets management, not
.envfile). - Start:
node dist/main.js. Use PM2 for process management (pm2 start dist/main.js --name my-app).
- Copy
- CI/CD Pipeline:
- Trigger (e.g., git push).
- Steps: Checkout → Setup Node →
npm ci→ Lint → Test (npm test,npm run test:e2e) → Build (npm run build) → Package (Docker image, zip) → Deploy → Inject Env Vars securely → Restart app.
- Rollback: Strategy to deploy a previous known-good version.
AWS Deployment Example:
# Deploy to AWS Elastic Beanstalk
# .ebextensions/nodecommand.config
option_settings:
aws:elasticbeanstalk:container:nodejs:
NodeCommand: "npm start"
aws:elasticbeanstalk:application:environment:
NODE_ENV: production
PORT: 8080Store Sinch credentials in AWS Systems Manager Parameter Store:
aws ssm put-parameter --name /myapp/SINCH_PROJECT_ID --value "your-value" --type SecureStringGCP Cloud Run Deployment:
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY dist ./dist
CMD ["node", "dist/main.js"]Deploy:
gcloud run deploy nestjs-sms --source . --region us-central1 \
--set-env-vars SINCH_PROJECT_ID=your-valueAzure App Service Deployment:
Use Azure Key Vault for secrets:
az keyvault secret set --vault-name mykeyvault --name SINCH-PROJECT-ID --value "your-value"13. Verification and Testing
-
Manual Verification:
-
Populate
.envwith valid credentials and a test recipient number. -
Start:
npm run start:dev -
Send POST request using
curlor Postman:Curl Example:
bashcurl --location --request POST 'http://localhost:3000/sms/send' \ --header 'Content-Type: application/json' \ --data-raw '{ "to": "+15551234567", "body": "Hello from NestJS and Sinch! Test message." }'(Replace
+15551234567with your actual test recipient number)Expected Success Response (Example):
json{ "message": "SMS sending initiated successfully.", "batchId": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "status": "queued" }(Actual
batchIdandstatusmay vary) -
Check recipient phone, application logs, and Sinch Dashboard logs.
-
-
Unit Tests: Test components in isolation (mocks for dependencies).
SinchService Unit Test:
// src/sinch/sinch.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { SinchService } from './sinch.service';
describe('SinchService', () => {
let service: SinchService;
let mockConfigService: Partial<ConfigService>;
beforeEach(async () => {
mockConfigService = {
get: jest.fn((key: string) => {
const config = {
SINCH_PROJECT_ID: 'test-project-id',
SINCH_KEY_ID: 'test-key-id',
SINCH_KEY_SECRET: 'test-key-secret',
SINCH_NUMBER: '+15551234567',
SINCH_REGION: 'us-1',
};
return config[key];
}),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
SinchService,
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<SinchService>(SinchService);
});
it('should initialize Sinch client on module init', () => {
expect(service).toBeDefined();
// Additional assertions on client initialization
});
it('should send SMS successfully', async () => {
const mockResponse = { id: 'batch-123', status: 'queued' };
jest.spyOn(service['sinchClient'].sms.batches, 'send').mockResolvedValue(mockResponse);
const result = await service.sendSms('+15559876543', 'Test message');
expect(result).toEqual(mockResponse);
});
});SmsController Unit Test:
// src/sms/sms.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { SmsController } from './sms.controller';
import { SinchService } from '../sinch/sinch.service';
describe('SmsController', () => {
let controller: SmsController;
let mockSinchService: Partial<SinchService>;
beforeEach(async () => {
mockSinchService = {
sendSms: jest.fn().mockResolvedValue({ id: 'batch-456', status: 'sent' }),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [SmsController],
providers: [
{ provide: SinchService, useValue: mockSinchService },
],
}).compile();
controller = module.get<SmsController>(SmsController);
});
it('should send SMS and return batch info', async () => {
const dto = { to: '+15559876543', body: 'Test' };
const result = await controller.sendSms(dto);
expect(result.message).toBe('SMS sending initiated successfully.');
expect(result.batchId).toBe('batch-456');
expect(mockSinchService.sendSms).toHaveBeenCalledWith(dto.to, dto.body);
});
});-
End-to-End (E2E) Tests: Test the full request flow. NestJS CLI sets up
supertest.typescript// test/app.e2e-spec.ts (Example test cases for the SMS endpoint) import * as request from 'supertest'; import { Test, TestingModule } from '@nestjs/testing'; import { AppModule } from './../src/app.module'; // Adjust path if needed import { INestApplication, ValidationPipe, HttpStatus, HttpException } from '@nestjs/common'; // Import HttpException import { SinchService } from './../src/sinch/sinch.service'; // Adjust path describe('SmsController (e2e)', () => { let app: INestApplication; // Mock the actual Sinch Service to avoid sending real SMS during tests const mockSinchService = { // Mock implementation returns a resolved promise with expected structure sendSms: jest.fn().mockResolvedValue({ id: 'e2e-batch-id', status: 'mock-sent'}), }; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], // Import main application module }) // Override the real SinchService (provided via the imported SinchModule) with our mock .overrideProvider(SinchService) .useValue(mockSinchService) .compile(); app = moduleFixture.createNestApplication(); // Apply the same global validation pipe used in main.ts app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true, transformOptions: { // Ensure transformOptions are included if used in main.ts enableImplicitConversion: true, }, })); await app.init(); }); afterAll(async () => { await app.close(); }); // Reset mocks before each test case to ensure test isolation beforeEach(() => { mockSinchService.sendSms.mockClear(); // Reset mock implementation to default success case if needed mockSinchService.sendSms.mockResolvedValue({ id: 'e2e-batch-id', status: 'mock-sent'}); }); it('/sms/send (POST) - should send SMS successfully', () => { const payload = { to: '+19998887777', // Use a valid E.164 format number body: 'E2E Test Message', }; return request(app.getHttpServer()) .post('/sms/send') .send(payload) .expect(HttpStatus.CREATED) // Expect 201 Created (set explicitly in controller) .expect((res) => { expect(res.body.message).toEqual('SMS sending initiated successfully.'); expect(res.body.batchId).toEqual('e2e-batch-id'); expect(mockSinchService.sendSms).toHaveBeenCalledWith(payload.to, payload.body); }); }); it('/sms/send (POST) - should return 400 for invalid phone number format', () => { const payload = { to: 'invalid-number-format', // Invalid E.164 format body: 'E2E Test Message', }; return request(app.getHttpServer()) .post('/sms/send') .send(payload) .expect(HttpStatus.BAD_REQUEST); // Expect 400 Bad Request due to ValidationPipe }); it('/sms/send (POST) - should return 400 for missing message body', () => { const payload = { to: '+19998887777', // 'body' field is missing }; return request(app.getHttpServer()) .post('/sms/send') .send(payload) .expect(HttpStatus.BAD_REQUEST); // Expect 400 Bad Request due to ValidationPipe }); it('/sms/send (POST) - should handle errors from SinchService (e.g., API failure)', () => { const payload = { to: '+19998887777', body: 'E2E Test Message Error Case', }; // Configure the mock service to reject the promise for this test case const errorMessage = 'Mock Sinch API Error'; const errorStatus = HttpStatus.BAD_GATEWAY; // Example error status mockSinchService.sendSms.mockRejectedValue(new HttpException(errorMessage, errorStatus)); // Simulate a specific error return request(app.getHttpServer()) .post('/sms/send') .send(payload) .expect(errorStatus) // Expect the status code thrown by the mocked service .expect((res) => { // NestJS default error response structure expect(res.body.statusCode).toEqual(errorStatus); expect(res.body.message).toEqual(errorMessage); }); }); });
Frequently Asked Questions
What version of the Sinch Node.js SDK should I use with NestJS?
Use @sinch/sdk-core v1.2.1 and @sinch/sms v1.2.1 (latest as of January 2025). These versions are fully compatible with NestJS v10+ and Node.js v16+. Install both packages together for the complete SDK functionality.
What Node.js version is required for NestJS with Sinch SMS?
NestJS v10+ requires Node.js v16 minimum. For production applications in 2024-2025, use Node.js v18 or v20 LTS versions for optimal stability and long-term support. These versions provide the best performance and security updates.
How many SMS messages can I send per second with Sinch?
Rate limits depend on your Sinch service plan. Each plan sets a maximum messages-per-second limit calculated from all API messages. Batch messages count each recipient separately. Status queries are limited to 1 request per second per IP address. Check your Sinch account settings for your specific throughput limits.
What happens when my SMS exceeds 160 characters?
Messages using GSM-7 encoding are split into 153-character segments (7 characters reserved for concatenation headers). Messages with emojis or special characters use UCS-2 encoding and split into 67-character segments. Sinch handles concatenation automatically, but multiple segments increase costs proportionally.
How do I track delivery status for sent messages?
Configure webhook callbacks in your Sinch Dashboard to receive delivery reports. Create a POST endpoint in your controller (e.g., /sms/webhook/delivery) to handle incoming status updates. Sinch sends delivery receipts containing batch ID, recipient number, delivery status, and timestamp. Store this data in your database to track message lifecycle from sent to delivered or failed.
How do I handle Sinch authentication errors (HTTP 401)?
Verify your SINCH_PROJECT_ID, SINCH_KEY_ID, and SINCH_KEY_SECRET in the .env file match your Sinch Dashboard credentials (Settings → Access Keys). If using OAuth2 authentication (US/EU regions), ensure your service plan supports it. Regenerate keys if needed and restart your NestJS application after updating credentials.
Can I use Sinch SMS with NestJS in production?
Yes. This guide provides a production-ready foundation including environment variable management, error handling, validation, rate limiting, health checks, and comprehensive testing. Add database logging, monitoring (Sentry, Datadog), and use a persistent session store for production deployments.
How do I test SMS sending without incurring costs?
Mock the SinchService in your unit and E2E tests (as shown in Section 13). This prevents actual API calls during testing. For integration testing with real API calls, use a test phone number and monitor your Sinch Dashboard for usage. Sinch may offer test credentials or sandbox environments – check their documentation.
What's the difference between GSM-7 and UCS-2 encoding?
GSM-7 encoding supports standard Latin characters and allows 160 characters per single SMS segment. UCS-2 encoding is required for emojis, Chinese script, and special symbols, allowing only 70 characters per segment. A single emoji forces the entire message to use UCS-2, reducing the character limit. Choose encoding based on your message content to optimize costs.
How do I handle rate limit errors (HTTP 429) from Sinch?
Implement exponential backoff retry logic in your SinchService. When receiving HTTP 429, wait progressively longer between retry attempts (e.g., 1s, 2s, 4s, 8s). Use libraries like async-retry or built-in retry mechanisms. Monitor your message throughput and upgrade your Sinch service plan if you consistently hit rate limits.
Can I send SMS to international numbers with Sinch and NestJS?
Yes, Sinch supports international SMS. Use E.164 format for all phone numbers (+countrycode...). The IsPhoneNumber validator in the DTO accepts international numbers. Be aware that international SMS rates vary by destination country and may be significantly higher than domestic messaging. Check Sinch pricing for specific countries.