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-core v1.2.1, @sinch/sms v1.2.1 (latest as of January 2025)
TypeScript: Superset of JavaScript used by NestJS for static typing.
dotenv: For managing environment variables.
class-validator / class-transformer: For request payload validation.
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.
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.
Bash
npm install -g @nestjs/cli
Create New NestJS Project: Generate a new project. Replace nestjs-sinch-sms with your desired project name.
Bash
nest new nestjs-sinch-sms
Select npm when prompted for the package manager.
Navigate to Project Directory:
Bash
cd nestjs-sinch-sms
Install Dependencies: Install the official Sinch SDK packages, NestJS config module for environment variables, and validation packages.
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 .env file in the project root for storing sensitive credentials.
Bash
touch .env
Add the following lines to .env (leave values blank for now):
.env
# .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 .env keeps sensitive keys out of source control and allows for different configurations per environment. @nestjs/config will load these variables into your application's environment.
Configure ConfigModule: Import and configure ConfigModule in your main application module (src/app.module.ts) to load the .env file globally.
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.
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.
Bash
nest generate module sinch
nest generate service sinch
This creates src/sinch/sinch.module.ts and src/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.tsimport { 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()
exportclassSinchServiceimplementsOnModuleInit {
privatereadonly logger = newLogger(SinchService.name);
privatesinchClient: SinchClient;
privatesinchNumber: string;
constructor(privateconfigService: ConfigService) {}
// OnModuleInit ensures client is initialized after config is readyonModuleInit() {
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 regionif (!projectId || !keyId || !keySecret || !this.sinchNumber) {
this.logger.error('Missing Sinch credentials or phone number in .env file');
// Throw error during initialization if config is missingthrownewError('Sinch configuration is incomplete. Check .env file.');
}
// Initialize the Sinch Clientthis.sinchClient = newSinchClient({
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.
*/asyncsendSms(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.');
thrownewHttpException('Sinch Client not initialized.', HttpStatus.INTERNAL_SERVER_ERROR);
}
// Basic validation: Check for '+' prefix for E.164if (!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.`);
}
constsendBatchRequest: SendBatchRequest = {
sendSMSRequestBody: {
to: [to], // Expects an array of recipientsfrom: this.sinchNumber,
body: body,
},
};
this.logger.log(`Attempting to send SMS to ${to}`);
try {
const response = awaitthis.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 structurelet 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 availableif (httpStatusCode === 401) {
statusCode = HttpStatus.UNAUTHORIZED;
message = 'Sinch authentication failed. Check API credentials.';
} elseif (httpStatusCode === 400 || (sinchErrorCode && sinchErrorCode.toString().startsWith('40'))) { // Example mapping for Bad Request
statusCode = HttpStatus.BAD_REQUEST;
message = `Sinch Bad Request: ${sinchErrorMessage || error.message}`;
} elseif (httpStatusCode === 403) {
statusCode = HttpStatus.FORBIDDEN;
message = 'Sinch request forbidden. Check account permissions or sender ID registration.';
} elseif (httpStatusCode === 429) {
statusCode = HttpStatus.TOO_MANY_REQUESTS;
message = 'Sinch rate limit exceeded. Reduce request frequency or upgrade plan.';
} elseif (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 layerthrownewHttpException(message, statusCode);
}
}
}
Why OnModuleInit? Ensures ConfigService is ready before initializing SinchClient.
Why ConfigService? Standard NestJS way to access configuration, keeping credentials separate.
Implement SmsController: Inject SinchService and create the POST endpoint.
TypeScript
// src/sms/sms.controller.tsimport { Controller, Post, Body, Logger, HttpException, HttpStatus, HttpCode } from'@nestjs/common';
import { SinchService } from'../sinch/sinch.service'; // Adjust path if neededimport { SendSmsDto } from'./dto/send-sms.dto';
@Controller('sms') // Route prefix: /smsexportclassSmsController {
privatereadonly logger = newLogger(SmsController.name);
constructor(privatereadonlysinchService: SinchService) {}
@Post('send') // Endpoint: POST /sms/send@HttpCode(HttpStatus.CREATED) // Explicitly set success status to 201 CreatedasyncsendSms(@Body() sendSmsDto: SendSmsDto) { // ValidationPipe is applied globally (see main.ts)this.logger.log(`Received request to send SMS to: ${sendSmsDto.to}`);
try {
const result = awaitthis.sinchService.sendSms(sendSmsDto.to, sendSmsDto.body);
// Return a success response, including the batch ID from Sinchreturn {
message: 'SMS sending initiated successfully.',
batchId: result?.id, // Access the batch ID from the Sinch responsestatus: 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 itif (error instanceofHttpException) {
throw error;
}
// Otherwise, wrap it in a generic server errorthrownewHttpException(
`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 global ValidationPipe configured in main.ts.
Update SmsModule: Import SinchModule to make SinchService injectable.
TypeScript
// src/sms/sms.module.tsimport { 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 SmsControllercontrollers: [SmsController],
// No providers needed here unless the module had its own services
})
exportclassSmsModule {}
Enable Global ValidationPipe: Configure the ValidationPipe globally in src/main.ts for application-wide automatic validation.
TypeScript
// src/main.tsimport { NestFactory } from'@nestjs/core';
import { AppModule } from'./app.module';
import { Logger, ValidationPipe } from'@nestjs/common'; // Import ValidationPipe and Loggerasyncfunctionbootstrap() {
const app = awaitNestFactory.create(AppModule);
const logger = newLogger('Bootstrap'); // Create a logger instance// Enable global validation pipe
app.useGlobalPipes(newValidationPipe({
whitelist: true, // Strip properties not defined in DTOstransform: true, // Automatically transform payloads to DTO instancesforbidNonWhitelisted: true, // Throw error if non-whitelisted properties are presenttransformOptions: {
enableImplicitConversion: true, // Allow basic type conversions if needed
},
}));
const port = process.env.PORT || 3000; // Use environment variable for portawait 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.
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 Secret immediately – 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_REGION to match (e.g., us-1, eu-1)
Update .env File: Populate the file with your credentials.
.env
# .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.
Text
# .gitignore
# ... other entries
# Environment Variables
.env
*.env
!.env.example # Optional: commit an example file without secrets
# ... other entries
5. Error Handling and Logging
This implementation incorporates key error handling and logging mechanisms:
Logging: Uses NestJS's Logger in 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 ValidationPipe automatically handles invalid request payloads, throwing BadRequestException (HTTP 400).
Sinch API Errors:SinchService catches 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 appropriate HttpException. This allows the SmsController to receive structured errors.
The implementation in SinchService (Section 2) already includes refined error handling that maps Sinch errors to HttpException. Customize this mapping based on specific Sinch error codes documented in their API reference.
Retry Mechanisms: For transient network issues or temporary Sinch unavailability (e.g., HTTP 503), consider implementing a retry strategy (e.g., using async-retry or RxJS operators) within SinchService.sendSms.
Input Validation: Handled by class-validator and ValidationPipe.
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).
Authentication/Authorization: Protect the /sms/send endpoint using standard methods like JWT (@nestjs/jwt, @nestjs/passport) or API Keys if the API is exposed externally.
E.164 Phone Number Format: Sinch requires E.164 (+countrycode...). The IsPhoneNumber validator helps, but robust parsing might be needed for diverse inputs. The warning log in SinchService provides 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/await for non-blocking I/O.
Sinch Client Initialization: Initialize once in SinchService (onModuleInit) for reuse.
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/terminus for a /health endpoint.
Logging: Implement centralized, structured logging (JSON) for production (ELK, Datadog, etc.).
Error Tracking: Use services like Sentry (@sentry/node, @sentry/nestjs).
Invalid number / Parameter validation failed (HTTP 400):to or from number 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 .env is populated and ConfigModule is correctly set up.
SDK Version Issues: Ensure @sinch/sdk-core and @sinch/sms versions are compatible. Check Sinch docs.
Region Mismatch: Verify SINCH_REGION in .env matches 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')
asynchandleDeliveryReport(@Body() report: any) {
// Process delivery report from Sinchthis.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 (creates dist folder).
Deploy:
Copy dist, node_modules (or run npm ci --omit=dev on server after copying dist, package.json, package-lock.json).
Provide production environment variables securely (hosting provider's secrets management, not.env file).
Start: node dist/main.js. Use PM2 for process management (pm2 start dist/main.js --name my-app).
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:
YAML
# Deploy to AWS Elastic Beanstalk# .ebextensions/nodecommand.configoption_settings:aws:elasticbeanstalk:container:nodejs:NodeCommand:"npm start"aws:elasticbeanstalk:application:environment:NODE_ENV:productionPORT:8080
Store Sinch credentials in AWS Systems Manager Parameter Store:
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 neededimport { INestApplication, ValidationPipe, HttpStatus, HttpException } from'@nestjs/common'; // Import HttpExceptionimport { SinchService } from'./../src/sinch/sinch.service'; // Adjust pathdescribe('SmsController (e2e)', () => {
letapp: INestApplication;
// Mock the actual Sinch Service to avoid sending real SMS during testsconst mockSinchService = {
// Mock implementation returns a resolved promise with expected structuresendSms: jest.fn().mockResolvedValue({ id: 'e2e-batch-id', status: 'mock-sent'}),
};
beforeAll(async () => {
constmoduleFixture: TestingModule = awaitTest.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(newValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
transformOptions: { // Ensure transformOptions are included if used in main.tsenableImplicitConversion: true,
},
}));
await app.init();
});
afterAll(async () => {
await app.close();
});
// Reset mocks before each test case to ensure test isolationbeforeEach(() => {
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 numberbody: 'E2E Test Message',
};
returnrequest(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 formatbody: 'E2E Test Message',
};
returnrequest(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
};
returnrequest(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 caseconst errorMessage = 'Mock Sinch API Error';
const errorStatus = HttpStatus.BAD_GATEWAY; // Example error status
mockSinchService.sendSms.mockRejectedValue(newHttpException(errorMessage, errorStatus)); // Simulate a specific errorreturnrequest(app.getHttpServer())
.post('/sms/send')
.send(payload)
.expect(errorStatus) // Expect the status code thrown by the mocked service
.expect((res) => {
// NestJS default error response structureexpect(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.
Frequently asked questions
Create a NestJS service that interacts with the Sinch SMS API using their Node.js SDK. This involves setting up a project with necessary dependencies like @sinch/sdk-core, @sinch/sms, and configuring environment variables for your Sinch credentials. A dedicated controller will handle incoming requests and utilize the service to send messages.
The Sinch SMS API allows your NestJS application to send transactional or notification-based SMS messages. This guide provides a structured and secure way to integrate this functionality into your Node.js application, utilizing the Sinch Node.js SDK for streamlined interaction with the Sinch cloud service.
NestJS provides a robust and modular architecture for building scalable server-side applications. Its dependency injection, built-in validation, and configuration management simplify the integration process with external APIs like Sinch.
The Sinch client should be initialized early in the application lifecycle, ideally when the module is initialized, using the OnModuleInit lifecycle hook. This ensures the client is ready when the service is first accessed and uses the ConfigService, which is ready when the module has finished loading. This is essential for efficient SMS sending.
Yes, NestJS uses TypeScript by default, which adds static typing and improved code maintainability to your project. The Sinch SDK also supports TypeScript, providing type safety for interacting with the API.
The SinchService implements error handling by catching errors from the Sinch SDK, logging them, and throwing HttpExceptions with appropriate status codes based on the error. This allows your NestJS application to gracefully handle potential issues like invalid credentials (401 Unauthorized) or bad requests (400 Bad Request).
You'll need Node.js and npm installed, a Sinch account with API credentials (Project ID, Key ID, Key Secret), a Sinch phone number capable of sending SMS, and a basic understanding of TypeScript, Node.js, and REST APIs. Access to a terminal is also required.
Store sensitive Sinch API credentials (Project ID, Key ID, Key Secret) in a .env file in your project's root directory. Use the @nestjs/config package to load these variables into your application's environment, keeping them separate from your codebase and out of source control.
A Data Transfer Object (DTO) defines the structure and validation rules for incoming requests. Using DTOs with class-validator and NestJS's ValidationPipe ensures data integrity and prevents invalid requests from reaching your service logic.
The IsPhoneNumber decorator from the class-validator package can be used in your DTO to enforce E.164 phone number formatting, which is crucial for interacting correctly with the Sinch API. Be sure to allow all E.164 format numbers by providing null as region code.
Use the @nestjs/throttler package. Configure it in your app.module.ts to define limits on the number of requests per IP within a specific timeframe, protecting your application and the Sinch API from abuse.
Set the SINCH_REGION environment variable in your .env file. The SinchService will use this variable to configure the Sinch SDK client accordingly, improving performance and reliability.
Build your application (npm run build), deploy the dist folder and node_modules, manage environment variables securely in your production environment, and utilize a process manager like PM2. Implementing a CI/CD pipeline automates these steps for efficient deployments.
Use a combination of manual testing (e.g., with curl or Postman), unit tests (mocking dependencies), and end-to-end (E2E) tests with tools like Supertest to thoroughly verify the functionality of your application. Mock the SinchService in tests to avoid sending actual SMS messages during testing.
Use the @nestjs/terminus package to create a /health endpoint. This endpoint can perform basic liveness checks and be extended to include checks for database connections or other external dependencies, providing valuable monitoring capabilities.
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:
Time to Complete: 45–60 minutes for experienced developers; 90–120 minutes for newcomers to NestJS.
Technologies Used:
@sinch/sdk-corev1.2.1,@sinch/smsv1.2.1 (latest as of January 2025)System Architecture:
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:
Source: NestJS documentation (v10 requires Node.js v16+); Node.js LTS release schedule
Final Outcome: A NestJS application running locally with a
/sms/sendendpoint 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.
Create New NestJS Project: Generate a new project. Replace
nestjs-sinch-smswith your desired project name.Select
npmwhen prompted for the package manager.Navigate to Project Directory:
cd nestjs-sinch-smsInstall Dependencies: Install the official Sinch SDK packages, NestJS config module for environment variables, and validation packages.
Troubleshooting Installation Issues:
EACCESpermission errorsudoor fix npm permissions following npm docsnetwork timeoutnpm config set registry https://registry.npmjs.org/node_modulesandpackage-lock.json, then runnpm installagainEnvironment Setup (
.env): Create a.envfile in the project root for storing sensitive credentials.touch .envAdd the following lines to
.env(leave values blank for now):.envkeeps sensitive keys out of source control and allows for different configurations per environment.@nestjs/configwill load these variables into your application's environment.Configure ConfigModule: Import and configure
ConfigModulein your main application module (src/app.module.ts) to load the.envfile globally.// 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.tsapplies 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.
src/sinch/sinch.module.tsandsrc/sinch/sinch.service.ts.Dependency Injection Benefits: NestJS's dependency injection allows you to inject
SinchServiceinto 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.// 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); } } }OnModuleInit? EnsuresConfigServiceis ready before initializingSinchClient.ConfigService? Standard NestJS way to access configuration, keeping credentials separate.HttpException? Provides standard HTTP error responses that NestJS understands, allowing controllers to handle them gracefully.Common Sinch Error Codes:
unauthorized.envforbiddensyntax_invalid_parameter_formatrate_limit_exceededservice_unavailableExport SinchService: Ensure
SinchServiceis exported fromSinchModule.// 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:
Create SendSmsDto: Define the request body structure and validation rules.
mkdir -p src/sms/dto # Create directory if it doesn't exist touch src/sms/dto/send-sms.dto.tsAdd the following content:
// 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 }class-validatorandValidationPipe.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.// 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, ); } } }@HttpCode(HttpStatus.CREATED)? Explicitly sets the HTTP status code for successful POST requests to 201, aligning with REST conventions and the E2E test expectation.@UsePipes: We rely on the globalValidationPipeconfigured inmain.ts.Update SmsModule: Import
SinchModuleto makeSinchServiceinjectable.// 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.// 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
.envfile.Step-by-Step Credential Setup:
Log in to Sinch: Navigate to the Sinch Customer Dashboard.
Locate Project ID:
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)Generate Access Keys:
Key ID(appears as a short alphanumeric string)Key Secretimmediately – it's shown only once and cannot be retrieved laterFind Your Sinch Number:
+and country code, e.g.,+12065551212)Determine Region:
dashboard.sinch.com/sms/api/{region}/overviewus(United States),eu(Europe)SINCH_REGIONto match (e.g.,us-1,eu-1)Update
.envFile: Populate the file with your credentials.Security: Add
.envto your.gitignorefile to prevent committing secrets.5. Error Handling and Logging
This implementation incorporates key error handling and logging mechanisms:
Loggerin services, controllers, and bootstrap for key events and errors. Configure more robust logging (e.g., file transport, external services) for production.ValidationPipeautomatically handles invalid request payloads, throwingBadRequestException(HTTP 400).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.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.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:
7. Security Features
class-validatorandValidationPipe..env, exclude from Git. Use secure environment variable management in production.@nestjs/throttler.npm install @nestjs/throttlerapp.module.ts(already shown in Section 1, Step 6)./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
+countrycode...). TheIsPhoneNumbervalidator helps, but robust parsing might be needed for diverse inputs. The warning log inSinchServiceprovides a basic check.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:
Note: Actual pricing varies by destination country. Check Sinch pricing page for specific rates.
9. Performance Optimizations
async/awaitfor non-blocking I/O.SinchService(onModuleInit) for reuse.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:
10. Monitoring, Observability, and Analytics
@nestjs/terminusfor a/healthendpoint.@sentry/node,@sentry/nestjs).Example Health Check:
npm install @nestjs/terminus// 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 } }// 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 {}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.SINCH_...variables missing/empty in.env. Ensure.envis populated andConfigModuleis correctly set up.@sinch/sdk-coreand@sinch/smsversions are compatible. Check Sinch docs.SINCH_REGIONin.envmatches your account/number region if issues persist.Webhook Setup for Delivery Reports:
Configure delivery callbacks to track message status:
@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 }https://your-domain.com/sms/webhook/delivery12. Deployment and CI/CD
npm run build(createsdistfolder).dist,node_modules(or runnpm ci --omit=devon server after copyingdist,package.json,package-lock.json)..envfile).node dist/main.js. Use PM2 for process management (pm2 start dist/main.js --name my-app).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.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:
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:devSend POST request using
curlor Postman:Curl Example:
curl --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):
{ "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.// 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-corev1.2.1 and@sinch/smsv1.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, andSINCH_KEY_SECRETin the.envfile 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
SinchServicein 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 likeasync-retryor 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...). TheIsPhoneNumbervalidator 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.