Build two-way SMS messaging with Sinch API and NestJS. Learn webhook handling, automated replies, error handling, and production deployment with TypeScript.
Build a production-ready two-way SMS messaging application using Node.js, the NestJS framework, and the Sinch SMS API. Learn how to receive incoming SMS messages via webhooks and automatically reply using the Sinch Node.js SDK.
This implementation solves the common need for applications to interact with users via SMS, enabling scenarios like automated support responses, status updates, appointment confirmations, or simple interactive services.
Project Overview and Goals
What You'll Build:
A NestJS application that:
Listens for incoming SMS messages sent to a designated Sinch virtual number via HTTP webhooks.
Parses the incoming message details (sender, content, etc.).
Logs the received message.
Sends an automated reply back to the original sender using the Sinch SMS API.
Problem Solved:
Build a foundational structure for any application needing to programmatically receive and respond to SMS messages, automating communication workflows.
Technologies Used:
Node.js: As the runtime environment (LTS version recommended: v18.x or v20.x as of 2024/2025).
NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Its modular architecture and use of TypeScript make it ideal for production systems.
Sinch SMS API & Node.js SDK: For sending and receiving SMS messages. We use their webhook system for inbound messages and their SDK for outbound replies.
@sinch/sdk-core: The official Sinch Node.js SDK. SDK Version Note: This guide uses @sinch/sdk-core v1.x (verified compatible as of Q4 2024). Sinch periodically updates their SDK with new features and breaking changes. Verify compatibility with npm list @sinch/sdk-core. Consult Sinch's SDK documentation for migration guides if using newer versions.
@nestjs/config: For managing environment variables following NestJS best practices.
class-validator & class-transformer: For validating incoming request payloads (DTOs).
ngrok: (For local development) To expose the local NestJS server to the internet so Sinch webhooks can reach it. Production Note: ngrok free tier has limitations (random URLs on restart, 40 connections/minute, 1GB bandwidth/month). For production, use stable hosting with permanent HTTPS URLs (AWS, DigitalOcean, Heroku, Railway) or ngrok's paid tier with reserved domains.
Basic familiarity with TypeScript, Node.js, and REST APIs.
1. Setting Up the Project
Initialize your NestJS project, install dependencies, and configure the basic structure and environment variables.
Step 1: Create a New NestJS Project
Open your terminal and run the NestJS CLI command to scaffold a new project. Name it sinch-two-way-sms.
Bash
nest new sinch-two-way-sms
Choose your preferred package manager (npm or yarn) when prompted. Navigate into the project directory:
Bash
cd sinch-two-way-sms
Step 2: Install Necessary Dependencies
Install the Sinch SDK, NestJS config module, and validation libraries.
Bash
# Using npm
npm install @sinch/sdk-core @nestjs/config class-validator class-transformer
# Using yarn
yarn add @sinch/sdk-core @nestjs/config class-validator class-transformer
@sinch/sdk-core: The official Sinch Node.js SDK for interacting with their APIs (including sending SMS).
@nestjs/config: The standard NestJS module for managing environment variables.
class-validator & class-transformer: Used for validating the structure and types of incoming webhook payloads against a Data Transfer Object (DTO) (Section 2, Step 4).
Step 3: Configure Environment Variables
Create a .env file in the root of your project directory to store your sensitive Sinch API credentials. Never commit this file to version control.
Text
# .env
# Sinch API Credentials (Find these in your Sinch Dashboard -> Access Keys)
# https://dashboard.sinch.com/settings/project-management
SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID
SINCH_KEY_ID=YOUR_SINCH_ACCESS_KEY_ID
SINCH_KEY_SECRET=YOUR_SINCH_ACCESS_KEY_SECRET
# Your Sinch Virtual Number (The number users text *to*)
# Find this in your Sinch Dashboard under Numbers -> Your Numbers
SINCH_NUMBER=+1xxxxxxxxxx # Use E.164 format
# Application Port (Optional, defaults typically work)
PORT=3000
SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET: These are crucial for authenticating with the Sinch SDK to send messages. Obtain them from the "Access Keys" section of your Sinch Dashboard. Generate a new key if needed. Remember to store the secret safely immediately as it won't be shown again.
SINCH_NUMBER: This is the virtual phone number you rented from Sinch that users will send messages to. Ensure it's SMS-enabled. Remember to replace the +1xxxxxxxxxx placeholder with your actual Sinch number.
PORT: The port your NestJS application will listen on.
Important: Remember to replace all placeholder values starting with YOUR_ and the specific phone number placeholder +1xxxxxxxxxx with your actual credentials and number.
Step 4: Configure Global Environment Variable Loading (Using @nestjs/config)
Modify src/app.module.ts to load environment variables globally using the @nestjs/config module.
By setting isGlobal: true, the ConfigService provides access to environment variables throughout the application without importing ConfigModule into every feature module.
Step 5: Add .env to .gitignore
Ensure your .gitignore file (in the project root) includes .env to prevent accidental commits of your credentials.
Text
# .gitignore
# Add this line if it's not already present
.env
Create a NestJS module, controller, and service to handle incoming SMS messages from Sinch via webhooks.
Webhook Security Note: Sinch webhooks are sent over HTTPS but do not include signature validation by default (unlike some providers like Twilio or Plivo). Security best practices:
HTTPS Only: Always use HTTPS endpoints in production to encrypt webhook data in transit
IP Whitelisting: Restrict webhook endpoint access to Sinch's IP ranges. Sinch publishes IP ranges in their documentation
Request Validation: Validate request structure matches expected Sinch webhook format using DTOs
Idempotency Keys: Use message IDs to detect and handle duplicate webhook deliveries
Rate Limiting: Implement rate limiting on webhook endpoints to prevent abuse
For enhanced security, consider implementing additional authentication tokens in webhook URLs (e.g., https://yourapp.com/webhooks/sms?token=SECRET_TOKEN) and validating the token in your controller.
Step 1: Generate the SMS Module, Controller, and Service
Use the NestJS CLI to generate the necessary components.
Step 4: Define the Incoming SMS Data Transfer Object (DTO)
Create a DTO to define the expected structure of the JSON payload Sinch sends for inbound SMS messages. This provides type safety and validation. Referencing Sinch's official webhook documentation, a typical inbound SMS webhook payload looks like this:
JSON
{""event"":""incoming_sms"",""id"":""01FC66417CDA379C9EF1111EC0AFA2F5"",""timestamp"":""2025-04-20T10:00:00.123Z"",""version"":1,""type"":""mo_text"",""from"":""+15551234567"",""to"":""+15559876543"",""body"":""Hello from user!"",""operator_id"":""31120"",""sent_at"":""2025-04-20T09:59:59.987Z"",""received_at"":""2025-04-20T10:00:00.050Z"",""client_reference"":null}
Create a file src/sms/dto/inbound-sms.dto.ts:
TypeScript
// src/sms/dto/inbound-sms.dto.tsimport { IsString, IsNotEmpty, IsOptional, IsPhoneNumber, IsDateString, IsNumber } from'class-validator';
exportclassInboundSmsDto {
@IsString()
@IsNotEmpty()
id: string; // Sinch message ID@IsPhoneNumber(undefined, { message: 'Invalid phone number format for ""from"" field.' }) // Use class-validator for basic format check@IsNotEmpty()
from: string; // Sender's phone number (E.164 format)@IsString() // Could also use IsPhoneNumber if strict validation is needed@IsNotEmpty()
to: string; // Your Sinch virtual number@IsString()
@IsNotEmpty()
body: string; // The content of the SMS message@IsString()
@IsOptional()
type?: string; // e.g., 'mo_text'@IsDateString()
@IsOptional()
received_at?: string; // ISO 8601 timestamp// Add other fields from the Sinch payload as needed@IsOptional()
@IsString()
event?: string;
@IsOptional()
@IsString()
operator_id?: string;
@IsOptional()
@IsDateString()
sent_at?: string;
@IsOptional()
@IsString()
client_reference?: string | null; // Can be null@IsOptional()
@IsDateString() // Changed from IsString based on example formattimestamp?: string;
@IsOptional()
@IsNumber() // Changed from IsString based on example valueversion?: number;
}
Note: We use class-validator decorators for validation. We installed the required packages in Section 1, Step 2. We also need to enable the ValidationPipe globally in src/main.ts.
Update src/main.ts:
TypeScript
// src/main.tsimport { NestFactory } from'@nestjs/core';
import { AppModule } from'./app.module';
import { ValidationPipe } from'@nestjs/common'; // Import ValidationPipeimport { ConfigService } from'@nestjs/config'; // Import ConfigServiceasyncfunctionbootstrap() {
const app = awaitNestFactory.create(AppModule);
// Enable ValidationPipe globally
app.useGlobalPipes(newValidationPipe({
whitelist: true, // Strip properties not defined in DTOtransform: true, // Automatically transform payloads to DTO instancesforbidNonWhitelisted: true, // Throw error if extra properties are senttransformOptions: {
enableImplicitConversion: true, // Allow basic type conversions
},
}));
// Get port from ConfigService (which reads from .env)const configService = app.get(ConfigService);
const port = configService.get<number>('PORT') || 3000;
await app.listen(port);
console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();
We removed the direct dotenv.config() call and now use ConfigService to retrieve the port, aligning with the use of @nestjs/config.
Step 5: Implement the Webhook Endpoint in the Controller
Modify SmsController to define a POST endpoint that listens for incoming webhooks from Sinch.
TypeScript
// src/sms/sms.controller.tsimport { Controller, Post, Body, HttpCode, HttpStatus, Logger } from'@nestjs/common';
import { SmsService } from'./sms.service';
import { InboundSmsDto } from'./dto/inbound-sms.dto';
@Controller('sms') // Base path for routes in this controllerexportclassSmsController {
privatereadonly logger = newLogger(SmsController.name);
constructor(privatereadonlysmsService: SmsService) {}
@Post('inbound') // Route: POST /sms/inbound@HttpCode(HttpStatus.OK) // Respond with 200 OK to Sinch immediatelyasynchandleInboundSms(@Body() inboundSmsDto: InboundSmsDto): Promise<void> {
// Log metadata, avoiding logging the potentially sensitive message body here.this.logger.log(`Received inbound SMS ID: ${inboundSmsDto.id} from: ${inboundSmsDto.from}`);
// Asynchronously process the message and send reply// We don't await this promise here to ensure a quick response to Sinch.// Error handling within processIncomingMessage is crucial.this.smsService.processIncomingMessage(inboundSmsDto).catch(error => {
// Log errors occurring during the async processingthis.logger.error(`Error processing inbound SMS ID ${inboundSmsDto.id}: ${error.message}`, error.stack);
// Implement additional error reporting if needed (e.g., Sentry)
});
// Return immediately - Sinch expects a quick 2xx response// Actual processing happens in the background via smsService.processIncomingMessage
}
}
@Controller('sms'): Defines the base route /sms.
@Post('inbound'): Defines a handler for POST requests to /sms/inbound. This will be our webhook URL.
@HttpCode(HttpStatus.OK): Ensures that even if the handler returns undefined (or a Promise resolving to undefined), NestJS sends a 200 OK response. Sinch requires a 2xx response to acknowledge webhook receipt.
@Body() inboundSmsDto: InboundSmsDto: Uses the ValidationPipe (enabled globally) and our DTO to parse and validate the incoming JSON request body.
Logging: We now log only the message ID and sender in the controller for security (reduced PII exposure). Detailed logging, including the body if necessary, should happen within the service layer.
Important: We log metadata and then call this.smsService.processIncomingMessagewithoutawait. This allows us to immediately send the 200 OK response back to Sinch, preventing timeouts on their end. The actual processing and reply sending happen asynchronously in the background. Robust error handling within the service method is essential.
Step 6: Implement the Processing Logic in the Service
Add the logic to SmsService to initialize the Sinch client and define the processIncomingMessage method.
TypeScript
// src/sms/sms.service.tsimport { Injectable, Logger, OnModuleInit } from'@nestjs/common';
import { ConfigService } from'@nestjs/config'; // Import ConfigServiceimport { SinchClient } from'@sinch/sdk-core';
import { InboundSmsDto } from'./dto/inbound-sms.dto';
@Injectable()
exportclassSmsServiceimplementsOnModuleInit {
privatereadonly logger = newLogger(SmsService.name);
privatesinchClient: SinchClient;
privatesinchNumber: string;
constructor(privateconfigService: ConfigService) {} // Inject ConfigServiceonModuleInit() {
// Initialize Sinch Client when the module loads using ConfigServiceconst 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');
if (!projectId || !keyId || !keySecret || !this.sinchNumber) {
this.logger.error('Sinch API credentials or number missing in environment variables! Aborting initialization.');
// Throw an error to prevent the application from starting in a misconfigured state.thrownewError('Sinch configuration incomplete in environment variables.');
}
try {
this.sinchClient = newSinchClient({ projectId, keyId, keySecret });
this.logger.log('Sinch Client Initialized Successfully.');
} catch (error) {
this.logger.error('Failed to initialize Sinch Client:', error);
throw error; // Rethrow to prevent the application from starting incorrectly
}
}
asyncprocessIncomingMessage(inboundSms: InboundSmsDto): Promise<void> {
// Log details within the service layerthis.logger.log(`Processing message ID: ${inboundSms.id} from ${inboundSms.from}. Body: ""${inboundSms.body}""`);
// Simple Echo Logic: Reply with the received messageconst replyText = `You sent: ""${inboundSms.body}""`; // Corrected quotestry {
awaitthis.sendSms(inboundSms.from, replyText);
this.logger.log(`Reply sent successfully to ${inboundSms.from} for inbound ID ${inboundSms.id}`);
} catch (error) {
this.logger.error(`Failed to send reply to ${inboundSms.from} for message ${inboundSms.id}: ${error.message}`, error.stack);
// Depending on the error, you might implement retries (Section 5)// or notify an admin. Error is already logged in sendSms.
}
}
privateasyncsendSms(recipient: string, message: string): Promise<any> {
if (!this.sinchClient) {
this.logger.error('Attempted to send SMS, but Sinch Client is not initialized.');
thrownewError('Sinch Client not initialized.');
}
if (!this.sinchNumber) {
this.logger.error('Attempted to send SMS, but Sinch sender number is not configured.');
thrownewError('Sinch sender number not configured.');
}
this.logger.debug(`Attempting to send SMS to ${recipient} from ${this.sinchNumber}`);
try {
// Note: The Sinch Node SDK uses `batches.send` even for sending a single message.// This is the standard method according to the SDK design.const response = awaitthis.sinchClient.sms.batches.send({
sendSMSRequestBody: {
to: [recipient], // Recipient number from the inbound messagefrom: this.sinchNumber, // Your Sinch virtual numberbody: message,
// You can add other parameters like 'delivery_report', 'client_reference' here if needed
},
});
this.logger.debug(`Sinch API send response: ${JSON.stringify(response)}`);
return response; // Return the response which might contain the batch ID
} catch (error) {
// Log detailed error information from Sinch SDK if availableconst errorMessage = error.response?.data ? JSON.stringify(error.response.data) : error.message;
this.logger.error(`Sinch SDK error sending SMS: ${errorMessage}`, error.stack);
throw error; // Re-throw the error to be handled by the caller (processIncomingMessage)
}
}
}
ConfigService: We inject ConfigService to access environment variables in a type-safe way, following NestJS conventions.
OnModuleInit: Initializes the SinchClient using credentials from ConfigService. Added clearer error logging and throwing if configuration is missing.
processIncomingMessage: This async method takes the parsed DTO. It logs more details (including the body, as this is the service layer) constructs a reply message, and calls sendSms. Crucially, it includes try...catch block for robust error handling during the reply process.
sendSms: This private helper method encapsulates the actual call to the Sinch SDK's sinchClient.sms.batches.send method. Includes error handling specific to the SDK call and logs detailed errors. Added a comment clarifying the use of batches.send. Corrected quotes in the reply text.
3. API Layer Documentation
The primary API endpoint exposed by this application is the webhook receiver.
Endpoint:POST /sms/inbound
Description: Receives inbound SMS messages forwarded by the Sinch platform via HTTP POST requests.
Request Body:
Content-Type:application/json
Payload: An object matching the structure defined in src/sms/dto/inbound-sms.dto.ts. See the example payload in Section 2, Step 4. Key fields include:
from (string, E.164 format): The sender's phone number.
to (string, E.164 format): Your Sinch virtual number that received the message.
body (string): The text content of the SMS.
id (string): Sinch's unique identifier for the message.
Other optional fields like timestamp, received_at, type, etc.
Successful Response:
Status Code:200 OK
Body: Empty.
Error Responses:
400 Bad Request: If the request body fails validation (e.g., missing required fields, invalid phone number format, extra fields when forbidNonWhitelisted: true). The response body will typically contain details about the validation errors from class-validator.
500 Internal Server Error: If an unexpected error occurs during processing before the initial response is sent (less likely with the async processing approach, but possible during initial DTO handling or controller instantiation).
Testing with cURL (Requires ngrok - see Section 4):
Remember to replace <your-ngrok-subdomain> with your actual ngrok subdomain and +1xxxxxxxxxx with your actual Sinch number.
Assuming ngrok forwards https://<your-ngrok-subdomain>.ngrok.io to your local http://localhost:3000:
You should receive a 200 OK response immediately, and your NestJS application logs should show the message ID and sender being received, followed by processing logs. If the reply logic works and the from number is valid, it would receive the echo reply.
4. Integrating with Sinch (Configuration & Local Testing)
This section details how to configure your Sinch account to send webhooks to your application and how to test it locally using ngrok.
Step 1: Obtain Sinch API Credentials & Number
As done in Section 1, Step 3, ensure you have your SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET, and SINCH_NUMBER in your .env file.
Find/Rent Sinch Number: Sinch Dashboard -> Numbers -> Your Numbers (or Buy Numbers). Ensure the number is SMS-enabled for the correct region(s).
Step 2: Start Your NestJS Application
Run your application locally:
Bash
# Using npm
npm run start:dev
# Using yarn
yarn start:dev
Your app should be running, typically on http://localhost:3000. Check the console logs for the ""Application is running on..."" message and confirmation that the Sinch Client initialized successfully.
Step 3: Expose Your Local Server with ngrok
Open a new terminal window (keep the NestJS app running in the first one). Start ngrok to tunnel traffic to your local port (e.g., 3000).
Bash
ngrok http 3000
ngrok will display output similar to this:
Code
Session Status online
Account Your Name (Plan: Free)
Version x.x.x
Region United States (us)
Forwarding http://<random-subdomain>.ngrok.io -> http://localhost:3000
Forwarding https://<random-subdomain>.ngrok.io -> http://localhost:3000 # <-- Use this HTTPS URL
Web Interface http://127.0.0.1:4040
Copy the https:// forwarding URL. This is the public URL that Sinch can use to reach your local application.
ngrok Limitations (Free Tier):
URLs change every time you restart ngrok (must update Sinch webhook URL each time)
Limited to 40 connections/minute
1GB bandwidth/month cap
Session expires after 2 hours (paid plans have longer sessions)
Production Alternatives:
Cloud Hosting: Deploy to AWS EC2, DigitalOcean Droplets, Heroku, Railway, or Google Cloud Run with permanent HTTPS URLs
ngrok Paid Plans: Reserved domains ($8-25/month) that don't change on restart
Alternative Tunneling: LocalTunnel, Cloudflare Tunnel (free, but requires setup)
VPS with Reverse Proxy: NGINX or Caddy on a VPS with Let's Encrypt SSL certificates
Step 4: Configure the Sinch Callback URL
Now, tell Sinch where to send the inbound SMS webhooks.
Go to your Sinch Customer Dashboard.
Navigate to SMS -> APIs.
Find the API configuration associated with your SINCH_PROJECT_ID (often named after your project or a default like ""Customer_SMS_API""). Click on its name/ID.
Look for a section like Callback URLs or Webhook Configuration.
Find the setting for Inbound Messages or a general Default Callback URL.
Click Add Callback URL or Edit.
Paste the https:// ngrok URL you copied, followed by your webhook path: /sms/inbound.
(Note: Sinch dashboard UI may change. Consult the official Sinch documentation for the most up-to-date instructions on configuring SMS API callback URLs if you cannot find these options.)
Step 5: Test the Integration
Using your actual mobile phone, send an SMS message to your SINCH_NUMBER.
Observe:
NestJS Logs: You should see log entries in your NestJS terminal showing the ""Received inbound SMS ID..."" message, followed by ""Processing message ID..."" (including the body), and ideally the ""Reply sent successfully..."" log.
ngrok Terminal: You should see a POST /sms/inbound request with a 200 OK response status listed in the ngrok interface.
Your Mobile Phone: You should receive an SMS reply back from your SINCH_NUMBER saying something like You sent: ""[Your Original Message]"".
If you encounter issues, check the Troubleshooting section (Section 11 - Note: Section 11 was not provided in the original text).
5. Implementing Error Handling, Logging, and Retries
Production systems need robust error handling and logging.
Error Handling Strategy:
Validation Errors: Handled automatically by the global ValidationPipe. NestJS returns a 400 Bad Request with details. Logs are generated by NestJS for these.
Sinch SDK Errors (Sending Reply): Caught within the sendSms method in SmsService. The error is logged with details (including Sinch's response if available) and re-thrown. Common Sinch API errors:
401 Unauthorized: Invalid API credentials (check SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET)
400 Bad Request: Invalid request format (check phone number E.164 format, message length)
403 Forbidden: Insufficient permissions or account restrictions
429 Too Many Requests: Rate limit exceeded (default: 30 messages/second per project). Implement exponential backoff
500/503 Server Errors: Temporary Sinch platform issues (retry with backoff)
Processing Errors (General): The outer try...catch in processIncomingMessage catches errors from sendSms (or other logic added later). This prevents an unhandled promise rejection from crashing the background task and logs the failure context.
Webhook Endpoint Errors: The @Post('inbound') handler uses .catch() on the async processIncomingMessage call. This ensures errors during background processing are logged via the controller's logger context but do not prevent the initial 200 OK response from being sent to Sinch.
Initialization Errors: Errors during SinchClient setup in onModuleInit are logged and thrown, preventing the application from starting in a broken state.
Logging:
Use NestJS's built-in Logger service.
Logs include timestamps, context (class name), and log level.
Key events logged:
Sinch client initialization success/failure.
Receiving an inbound SMS (ID and sender in controller, full details in service).
Starting processing of a message (including body in service).
Successfully sending a reply.
Errors during processing or sending replies (including error messages and stack traces).
Debug logs for sending attempts and Sinch API responses.
Production Logging: Consider integrating more advanced logging solutions (like Winston, Pino) with transports for file logging, log aggregation services (e.g., Datadog, Logz.io, ELK stack). Configure log levels appropriately for different environments (debug in dev, info or warn in prod). Ensure sensitive data is masked or omitted from production logs where appropriate.
Retry Mechanisms (for Sending Replies):
Sinch API calls (like sending SMS) might fail transiently (network issues, temporary service unavailability). Implement retries to improve reliability.
Example: Simple Exponential Backoff Retry in sendSms
TypeScript
// src/sms/sms.service.ts (Updated sendSms method)// Helper function for async delayconstdelay = (ms: number) => newPromise(resolve =>setTimeout(resolve, ms));
// ... inside SmsService classprivateasyncsendSms(recipient: string, message: string, maxRetries = 3): Promise<any> {
if (!this.sinchClient) {
this.logger.error('Attempted to send SMS, but Sinch Client is not initialized.');
thrownewError('Sinch Client not initialized.');
}
if (!this.sinchNumber) {
this.logger.error('Attempted to send SMS, but Sinch sender number is not configured.');
thrownewError('Sinch sender number not configured.');
}
let attempts = 0;
let currentDelay = 1000; // Initial delay 1 secondwhile (attempts < maxRetries) {
attempts++;
this.logger.debug(`Attempting to send SMS to ${recipient} (Attempt ${attempts}/${maxRetries})`);
try {
// Note: The Sinch Node SDK uses `batches.send` even for sending a single message.const response = awaitthis.sinchClient.sms.batches.send({
sendSMSRequestBody: {
to: [recipient],
from: this.sinchNumber,
body: message,
},
});
this.logger.debug(`Sinch API send response (Attempt ${attempts}): ${JSON.stringify(response)}`);
return response; // Success, exit the loop
} catch (error) {
const errorMessage = error.response?.data ? JSON.stringify(error.response.data) : error.message;
const statusCode = error.response?.status;
this.logger.warn(`Sinch SDK error sending SMS (Attempt ${attempts}, Status: ${statusCode || 'N/A'}): ${errorMessage}`, error.stack);
if (attempts >= maxRetries) {
this.logger.error(`Max retries (${maxRetries}) reached for sending SMS to ${recipient}. Giving up.`);
throw error; // Re-throw the final error after all retries failed
}
// Basic retry logic: retry on network errors or 5xx server errors.// Refine this based on specific Sinch API error codes documented for transient failures.const isRetryable = !statusCode || (statusCode >= 500 && statusCode < 600);
if (!isRetryable) {
this.logger.error(`Non-retryable error encountered (Status: ${statusCode}). Giving up sending SMS to ${recipient}.`);
throw error; // Do not retry non-retryable errors (like 4xx client errors)
}
this.logger.log(`Waiting ${currentDelay}ms before retry ${attempts + 1}...`);
awaitdelay(currentDelay);
currentDelay *= 2; // Exponential backoff: 1s, 2s, 4s...
}
}
// This line should theoretically be unreachable if maxRetries > 0thrownewError('Exited retry loop unexpectedly.');
}
// ... rest of the class
This sendSms method attempts the API call up to maxRetries times.
It waits (delay) between retries, doubling the wait time (currentDelay *= 2) after each failure (exponential backoff).
It includes basic logic to check if an error isRetryable (no status code usually means network/DNS error, or 5xx server errors). Client errors (4xx) are typically not retried. Note: This basic logic might need refinement based on specific Sinch API error codes or official documentation for more robust retry decisions.
If all retries fail, or a non-retryable error occurs, the error is thrown.
Testing Error Scenarios:
Temporarily disconnect your internet to simulate network errors during reply sending.
Intentionally use invalid Sinch credentials in .env to test authentication failures (401 errors, likely non-retryable).
Send invalid JSON in the cURL test (Section 3) to test validation errors (400 Bad Request from the framework).
Modify the sendSms method to temporarily throw specific error types (e.g., simulate a 503 error) to test the retry logic.
Webhook Response Time Requirements:
Sinch expects a 2xx response within 20 seconds (industry standard: 5-10 seconds)
If your endpoint times out, Sinch may retry the webhook (typically 3 times with exponential backoff)
This is why we respond immediately with 200 OK and process asynchronously
Rate Limiting Best Practices:
Sinch API Limits: 30 messages/second per project (verified 2024)
Implement Token Bucket: Use libraries like bottleneck or p-queue to throttle outbound SMS
6. Creating a Database Schema and Data Layer (Optional Enhancement)
While not strictly required for the basic echo bot, storing message history is often valuable.
Use Case: Log all incoming and outgoing messages for auditing, analysis, compliance, or debugging.
Technology Choice: A relational database (like PostgreSQL) with an ORM (like TypeORM or Prisma) is a common choice with NestJS. For message storage, consider:
Database Options:
PostgreSQL: Best for transactional consistency, complex queries, full-text search
MongoDB: Good for high-write scenarios, flexible schema
Redis: Fast, ephemeral storage for recent messages or session data
Time-Series DB (InfluxDB, TimescaleDB): Optimized for message analytics over time
Data Retention: Implement TTL policies. SMS data typically retained for 90 days to 2 years depending on compliance requirements (GDPR, TCPA, HIPAA).
Frequently Asked Questions About Sinch Two-Way SMS with NestJS
How do I handle Sinch webhook retries?
Sinch retries webhook deliveries up to 3 times if your endpoint returns non-2xx responses or times out (>20 seconds). Handle retries by: (1) implementing idempotency using inboundSms.id to detect duplicates, (2) storing processed message IDs in Redis or database with TTL, (3) checking if message was already processed before sending reply, and (4) always returning 200 OK quickly (<5 seconds). Use @HttpCode(HttpStatus.OK) decorator and async processing pattern shown in this guide.
What is the Sinch API rate limit for sending SMS?
Sinch allows 30 messages per second per project (verified Q4 2024). For higher volumes, contact Sinch sales for increased limits. Implement rate limiting using libraries like bottleneck (Node.js) or queue systems (BullMQ with Redis). Monitor 429 Too Many Requests responses and implement exponential backoff (1s, 2s, 4s delays). For enterprise applications processing >100,000 messages/day, use distributed queue workers with Redis for horizontal scaling.
How much does Sinch SMS cost?
Sinch SMS pricing varies by destination country. As of 2024, typical rates: US/Canada $0.0075-0.01/message, UK £0.04/message, India ₹0.20/message. Virtual number rental: $1-5/month depending on country. Inbound messages typically free or minimal cost. Check Sinch's official pricing page for current rates. Volume discounts available for >100k messages/month.
Can I use Sinch for two-way SMS in production?
Yes, Sinch is enterprise-grade and suitable for production. Requirements: (1) stable HTTPS webhook URL (not ngrok free tier), (2) proper error handling and retry logic, (3) rate limiting implementation (30 msg/s limit), (4) monitoring and alerting (Sentry, Datadog), (5) database for message history and deduplication, (6) compliance with regulations (TCPA for US, GDPR for EU), and (7) security measures (HTTPS, IP whitelisting, request validation). This guide provides production-ready patterns for all requirements.
How do I test Sinch webhooks locally?
Test locally using: (1) ngrok to expose localhost ( ngrok http 3000), (2) configure ngrok HTTPS URL in Sinch Dashboard > SMS > APIs > Callback URLs, (3) send test SMS to your Sinch number, (4) monitor logs in NestJS terminal and ngrok web interface (http://127.0.0.1:4040), and (5) verify 200 OK responses and reply delivery. Alternative: Use cURL to simulate webhooks (see Section 3) without sending actual SMS. For CI/CD testing, mock SinchClient using Jest mocks or test containers.
What phone number format does Sinch require?
Sinch requires E.164 format for all phone numbers: +[country code][number] without spaces or special characters. Examples: +14155551234 (US), +442071234567 (UK), +919876543210 (India). Validate using class-validator's @IsPhoneNumber() decorator or libphonenumber-js library. Invalid formats cause 400 Bad Request errors. Always store numbers in E.164 in database for consistency.
How do I handle message delivery failures?
Handle delivery failures by: (1) implementing retry logic with exponential backoff (shown in Section 5), (2) logging Sinch SDK error responses with status codes and messages, (3) storing failed messages in database with failure reason, (4) implementing dead-letter queue for messages that fail after max retries (3-5 attempts), (5) setting up alerts for failure rate thresholds (>5%), and (6) distinguishing permanent failures (invalid number, blocked) from temporary (network, rate limit) using HTTP status codes (4xx vs 5xx).
Can I send MMS or media messages with Sinch?
Yes, Sinch supports MMS in supported countries (US, Canada, UK). Use media_urls parameter in sendSMSRequestBody:
TypeScript
awaitthis.sinchClient.sms.batches.send({
sendSMSRequestBody: {
to: [recipient],
from: this.sinchNumber,
body: message,
media_urls: ['https://example.com/image.jpg'], // Array of URLs
},
});
Media requirements: HTTPS URLs, max 5 media files per message, supported formats (JPEG, PNG, GIF, MP4), max file size 500KB-5MB depending on carrier. MMS costs 3-5x standard SMS rates.
How do I secure Sinch webhooks in production?
Secure webhooks by: (1) using HTTPS only (enforced by Sinch), (2) implementing IP whitelisting for Sinch's IP ranges (check Sinch docs for current IPs), (3) adding secret tokens in webhook URLs (?token=SECRET), (4) validating request structure with DTOs (shown in guide), (5) implementing rate limiting on webhook endpoint (100 requests/minute per IP), (6) logging all webhook requests for audit trails, (7) using environment variables for secrets, and (8) monitoring for unusual patterns (sudden spikes, malformed requests).
What NestJS version is compatible with Sinch SDK?
This guide uses NestJS 10.x (verified Q4 2024) with @sinch/sdk-core 1.x. Compatible with: NestJS 9.x, 10.x, Node.js 18.x, 20.x. TypeScript 4.9+ required. For NestJS 8.x or older, minor adjustments may be needed for ConfigModule and ValidationPipe configuration. Always check NestJS migration guides when upgrading major versions.
How do I deploy Sinch NestJS app to production?
Deploy to production: (1) build application (npm run build), (2) set environment variables on hosting platform (not .env files), (3) use process manager (PM2: pm2 start dist/main.js -i max), (4) configure reverse proxy (NGINX, Caddy) with SSL certificates (Let's Encrypt), (5) set up monitoring (health checks, error tracking with Sentry), (6) implement logging to external service (CloudWatch, Datadog), (7) configure auto-scaling based on CPU/memory (AWS ECS, Kubernetes), and (8) use managed PostgreSQL and Redis for data layer. Recommended platforms: AWS (EC2/ECS/Lambda), DigitalOcean App Platform, Heroku, Railway, Google Cloud Run.
How do I implement conversation state in two-way SMS?
Implement conversation state using: (1) Redis with user phone number as key (redis.set(phoneNumber, JSON.stringify(state), 'EX', 3600)), (2) in-memory caching with @nestjs/cache-manager for simple bots, (3) database sessions table with phone_number, state, last_activity columns, (4) state machine pattern with states like AWAITING_NAME, AWAITING_CHOICE, (5) timeout handling (expire state after 5-15 minutes inactivity), and (6) context preservation across messages. Example: User sends "ORDER" → bot replies "What's your name?" → store state AWAITING_NAME → next message becomes the name.
Conclusion
Build production-ready two-way SMS messaging applications using Sinch, Node.js, and NestJS. This implementation includes:
Add Intelligence: Integrate NLP (Dialogflow, OpenAI) for conversational AI responses
Implement Keywords: Route messages based on keywords (STOP, HELP, commands)
Database Layer: Store message history using PostgreSQL/TypeORM for analytics
Monitoring: Add Sentry error tracking, Datadog metrics, health check endpoints
Queue System: Implement BullMQ for high-volume message processing (>1000/hour)
Multi-Channel: Extend to WhatsApp, RCS using Sinch's other APIs
Compliance: Add opt-out handling (STOP keyword), consent tracking for TCPA/GDPR
Frequently asked questions
Start by creating a new NestJS project using the Nest CLI, then install necessary dependencies like the Sinch SDK, config module, and validation libraries. Configure environment variables in a .env file for your Sinch credentials, ensuring it's added to .gitignore. Finally, enable global environment variable loading in the app.module.ts file using @nestjs/config.
The Sinch SMS API is used for both sending and receiving text messages. It facilitates receiving inbound SMS messages through webhooks and enables the application to send automated replies using the Sinch Node.js SDK.
NestJS provides a structured and efficient way to build server-side applications with Node.js. Its modular architecture, TypeScript support, and dependency injection features make it suitable for building robust, production-ready systems.
ngrok is essential during local development and testing. It creates a public, secure tunnel to your locally running NestJS application, allowing Sinch webhooks to reach your server even though it's not publicly accessible on the internet.
Sinch delivers inbound SMS messages to your application via webhooks. You need to configure a webhook URL in your Sinch dashboard that points to a specific endpoint in your NestJS application. This endpoint will receive an HTTP POST request containing the message data whenever a new SMS arrives.
A DTO is an object that defines the structure of data being transferred between layers of your application, such as between the controller and the service. It's commonly used with class-validator to validate and sanitize incoming request data, ensuring type safety and security.
Sinch requires a 200 OK response to acknowledge receipt of the webhook. Without it, Sinch may retry sending the webhook, potentially leading to duplicate message processing. The quick 200 OK response also prevents timeouts on Sinch's end.
You can test locally by using ngrok to create a public URL for your application, configuring that URL as your webhook endpoint in the Sinch dashboard, then sending a text message to your Sinch virtual number. Observe the logs in your NestJS application and the ngrok terminal to confirm proper functionality.
Environment variables store sensitive configuration data like API keys and secrets. In NestJS, they are managed using the @nestjs/config module and stored in a .env file, which should never be committed to version control.
After parsing the received message from the webhook request, you can use the Sinch Node.js SDK to send an automated SMS reply back to the sender. This involves initializing the Sinch client with your credentials and then calling the appropriate SDK methods to send the message.
Class-validator helps ensure data integrity by validating incoming webhook payloads against a DTO. It verifies that the received data conforms to the expected format and type, preventing unexpected errors and security vulnerabilities.
Error handling is implemented using try-catch blocks to gracefully handle potential errors during API calls, webhook processing, and other operations. The application uses a global validation pipe, error handling within the service layer, and logging of error details for monitoring and debugging.
The .gitignore file specifies files and directories that should be excluded from version control. Sensitive information, like API keys in the .env file, should always be added to .gitignore to prevent accidental exposure.
While not shown in the basic implementation, you can enhance the application to store message history using a database. This is beneficial for auditing, analysis, and debugging purposes. TypeORM or Prisma are good options for database interaction.
Build a production-ready two-way SMS messaging application using Node.js, the NestJS framework, and the Sinch SMS API. Learn how to receive incoming SMS messages via webhooks and automatically reply using the Sinch Node.js SDK.
This implementation solves the common need for applications to interact with users via SMS, enabling scenarios like automated support responses, status updates, appointment confirmations, or simple interactive services.
Project Overview and Goals
What You'll Build:
A NestJS application that:
Problem Solved:
Build a foundational structure for any application needing to programmatically receive and respond to SMS messages, automating communication workflows.
Technologies Used:
@sinch/sdk-core: The official Sinch Node.js SDK. SDK Version Note: This guide uses@sinch/sdk-corev1.x (verified compatible as of Q4 2024). Sinch periodically updates their SDK with new features and breaking changes. Verify compatibility withnpm list @sinch/sdk-core. Consult Sinch's SDK documentation for migration guides if using newer versions.@nestjs/config: For managing environment variables following NestJS best practices.class-validator&class-transformer: For validating incoming request payloads (DTOs).ngrok: (For local development) To expose the local NestJS server to the internet so Sinch webhooks can reach it. Production Note: ngrok free tier has limitations (random URLs on restart, 40 connections/minute, 1GB bandwidth/month). For production, use stable hosting with permanent HTTPS URLs (AWS, DigitalOcean, Heroku, Railway) or ngrok's paid tier with reserved domains.System Architecture:
Prerequisites:
npm install -g @nestjs/cli).ngrokinstalled (https://ngrok.com/download) for local development testing.1. Setting Up the Project
Initialize your NestJS project, install dependencies, and configure the basic structure and environment variables.
Step 1: Create a New NestJS Project
Open your terminal and run the NestJS CLI command to scaffold a new project. Name it
sinch-two-way-sms.Choose your preferred package manager (npm or yarn) when prompted. Navigate into the project directory:
cd sinch-two-way-smsStep 2: Install Necessary Dependencies
Install the Sinch SDK, NestJS config module, and validation libraries.
# Using npm npm install @sinch/sdk-core @nestjs/config class-validator class-transformer # Using yarn yarn add @sinch/sdk-core @nestjs/config class-validator class-transformer@sinch/sdk-core: The official Sinch Node.js SDK for interacting with their APIs (including sending SMS).@nestjs/config: The standard NestJS module for managing environment variables.class-validator&class-transformer: Used for validating the structure and types of incoming webhook payloads against a Data Transfer Object (DTO) (Section 2, Step 4).Step 3: Configure Environment Variables
Create a
.envfile in the root of your project directory to store your sensitive Sinch API credentials. Never commit this file to version control.SINCH_PROJECT_ID,SINCH_KEY_ID,SINCH_KEY_SECRET: These are crucial for authenticating with the Sinch SDK to send messages. Obtain them from the "Access Keys" section of your Sinch Dashboard. Generate a new key if needed. Remember to store the secret safely immediately as it won't be shown again.SINCH_NUMBER: This is the virtual phone number you rented from Sinch that users will send messages to. Ensure it's SMS-enabled. Remember to replace the+1xxxxxxxxxxplaceholder with your actual Sinch number.PORT: The port your NestJS application will listen on.Important: Remember to replace all placeholder values starting with
YOUR_and the specific phone number placeholder+1xxxxxxxxxxwith your actual credentials and number.Step 4: Configure Global Environment Variable Loading (Using
@nestjs/config)Modify
src/app.module.tsto load environment variables globally using the@nestjs/configmodule.// 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 @Module({ imports: [ ConfigModule.forRoot({ // Load .env file variables globally isGlobal: true, }), // Other modules will be imported here later ], controllers: [AppController], providers: [AppService], }) export class AppModule {}By setting
isGlobal: true, theConfigServiceprovides access to environment variables throughout the application without importingConfigModuleinto every feature module.Step 5: Add
.envto.gitignoreEnsure your
.gitignorefile (in the project root) includes.envto prevent accidental commits of your credentials.Your basic project structure is now ready.
2. Implementing Core Functionality: Receiving Inbound SMS
Create a NestJS module, controller, and service to handle incoming SMS messages from Sinch via webhooks.
Webhook Security Note: Sinch webhooks are sent over HTTPS but do not include signature validation by default (unlike some providers like Twilio or Plivo). Security best practices:
For enhanced security, consider implementing additional authentication tokens in webhook URLs (e.g.,
https://yourapp.com/webhooks/sms?token=SECRET_TOKEN) and validating the token in your controller.Step 1: Generate the SMS Module, Controller, and Service
Use the NestJS CLI to generate the necessary components.
This creates:
src/sms/sms.module.ts: Organizes the SMS-related components.src/sms/sms.controller.ts: Handles incoming HTTP requests (webhooks).src/sms/sms.service.ts: Contains the business logic (processing messages, sending replies).Step 2: Update the SMS Module
Ensure the
SmsControllerandSmsServiceare registered in theSmsModule.// src/sms/sms.module.ts import { Module } from '@nestjs/common'; import { SmsController } from './sms.controller'; import { SmsService } from './sms.service'; @Module({ controllers: [SmsController], providers: [SmsService], }) export class SmsModule {}Step 3: Import the SMS Module into the Root AppModule
Make the
SmsModuleavailable to the application by importing it intoAppModule.// src/app.module.ts import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { SmsModule } from './sms/sms.module'; // Import SmsModule import { ConfigModule } from '@nestjs/config'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, }), SmsModule, // Add SmsModule here ], controllers: [AppController], providers: [AppService], }) export class AppModule {}Step 4: Define the Incoming SMS Data Transfer Object (DTO)
Create a DTO to define the expected structure of the JSON payload Sinch sends for inbound SMS messages. This provides type safety and validation. Referencing Sinch's official webhook documentation, a typical inbound SMS webhook payload looks like this:
{ ""event"": ""incoming_sms"", ""id"": ""01FC66417CDA379C9EF1111EC0AFA2F5"", ""timestamp"": ""2025-04-20T10:00:00.123Z"", ""version"": 1, ""type"": ""mo_text"", ""from"": ""+15551234567"", ""to"": ""+15559876543"", ""body"": ""Hello from user!"", ""operator_id"": ""31120"", ""sent_at"": ""2025-04-20T09:59:59.987Z"", ""received_at"": ""2025-04-20T10:00:00.050Z"", ""client_reference"": null }Create a file
src/sms/dto/inbound-sms.dto.ts:// src/sms/dto/inbound-sms.dto.ts import { IsString, IsNotEmpty, IsOptional, IsPhoneNumber, IsDateString, IsNumber } from 'class-validator'; export class InboundSmsDto { @IsString() @IsNotEmpty() id: string; // Sinch message ID @IsPhoneNumber(undefined, { message: 'Invalid phone number format for ""from"" field.' }) // Use class-validator for basic format check @IsNotEmpty() from: string; // Sender's phone number (E.164 format) @IsString() // Could also use IsPhoneNumber if strict validation is needed @IsNotEmpty() to: string; // Your Sinch virtual number @IsString() @IsNotEmpty() body: string; // The content of the SMS message @IsString() @IsOptional() type?: string; // e.g., 'mo_text' @IsDateString() @IsOptional() received_at?: string; // ISO 8601 timestamp // Add other fields from the Sinch payload as needed @IsOptional() @IsString() event?: string; @IsOptional() @IsString() operator_id?: string; @IsOptional() @IsDateString() sent_at?: string; @IsOptional() @IsString() client_reference?: string | null; // Can be null @IsOptional() @IsDateString() // Changed from IsString based on example format timestamp?: string; @IsOptional() @IsNumber() // Changed from IsString based on example value version?: number; }Note: We use
class-validatordecorators for validation. We installed the required packages in Section 1, Step 2. We also need to enable theValidationPipeglobally insrc/main.ts.Update
src/main.ts:// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; // Import ValidationPipe import { ConfigService } from '@nestjs/config'; // Import ConfigService async function bootstrap() { const app = await NestFactory.create(AppModule); // Enable ValidationPipe globally app.useGlobalPipes(new ValidationPipe({ whitelist: true, // Strip properties not defined in DTO transform: true, // Automatically transform payloads to DTO instances forbidNonWhitelisted: true, // Throw error if extra properties are sent transformOptions: { enableImplicitConversion: true, // Allow basic type conversions }, })); // Get port from ConfigService (which reads from .env) const configService = app.get(ConfigService); const port = configService.get<number>('PORT') || 3000; await app.listen(port); console.log(`Application is running on: ${await app.getUrl()}`); } bootstrap();We removed the direct
dotenv.config()call and now useConfigServiceto retrieve the port, aligning with the use of@nestjs/config.Step 5: Implement the Webhook Endpoint in the Controller
Modify
SmsControllerto define a POST endpoint that listens for incoming webhooks from Sinch.// src/sms/sms.controller.ts import { Controller, Post, Body, HttpCode, HttpStatus, Logger } from '@nestjs/common'; import { SmsService } from './sms.service'; import { InboundSmsDto } from './dto/inbound-sms.dto'; @Controller('sms') // Base path for routes in this controller export class SmsController { private readonly logger = new Logger(SmsController.name); constructor(private readonly smsService: SmsService) {} @Post('inbound') // Route: POST /sms/inbound @HttpCode(HttpStatus.OK) // Respond with 200 OK to Sinch immediately async handleInboundSms(@Body() inboundSmsDto: InboundSmsDto): Promise<void> { // Log metadata, avoiding logging the potentially sensitive message body here. this.logger.log(`Received inbound SMS ID: ${inboundSmsDto.id} from: ${inboundSmsDto.from}`); // Asynchronously process the message and send reply // We don't await this promise here to ensure a quick response to Sinch. // Error handling within processIncomingMessage is crucial. this.smsService.processIncomingMessage(inboundSmsDto).catch(error => { // Log errors occurring during the async processing this.logger.error(`Error processing inbound SMS ID ${inboundSmsDto.id}: ${error.message}`, error.stack); // Implement additional error reporting if needed (e.g., Sentry) }); // Return immediately - Sinch expects a quick 2xx response // Actual processing happens in the background via smsService.processIncomingMessage } }@Controller('sms'): Defines the base route/sms.@Post('inbound'): Defines a handler for POST requests to/sms/inbound. This will be our webhook URL.@HttpCode(HttpStatus.OK): Ensures that even if the handler returnsundefined(or a Promise resolving toundefined), NestJS sends a200 OKresponse. Sinch requires a2xxresponse to acknowledge webhook receipt.@Body() inboundSmsDto: InboundSmsDto: Uses theValidationPipe(enabled globally) and our DTO to parse and validate the incoming JSON request body.this.smsService.processIncomingMessagewithoutawait. This allows us to immediately send the200 OKresponse back to Sinch, preventing timeouts on their end. The actual processing and reply sending happen asynchronously in the background. Robust error handling within the service method is essential.Step 6: Implement the Processing Logic in the Service
Add the logic to
SmsServiceto initialize the Sinch client and define theprocessIncomingMessagemethod.// src/sms/sms.service.ts import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; // Import ConfigService import { SinchClient } from '@sinch/sdk-core'; import { InboundSmsDto } from './dto/inbound-sms.dto'; @Injectable() export class SmsService implements OnModuleInit { private readonly logger = new Logger(SmsService.name); private sinchClient: SinchClient; private sinchNumber: string; constructor(private configService: ConfigService) {} // Inject ConfigService onModuleInit() { // Initialize Sinch Client when the module loads using ConfigService 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'); if (!projectId || !keyId || !keySecret || !this.sinchNumber) { this.logger.error('Sinch API credentials or number missing in environment variables! Aborting initialization.'); // Throw an error to prevent the application from starting in a misconfigured state. throw new Error('Sinch configuration incomplete in environment variables.'); } try { this.sinchClient = new SinchClient({ projectId, keyId, keySecret }); this.logger.log('Sinch Client Initialized Successfully.'); } catch (error) { this.logger.error('Failed to initialize Sinch Client:', error); throw error; // Rethrow to prevent the application from starting incorrectly } } async processIncomingMessage(inboundSms: InboundSmsDto): Promise<void> { // Log details within the service layer this.logger.log(`Processing message ID: ${inboundSms.id} from ${inboundSms.from}. Body: ""${inboundSms.body}""`); // Simple Echo Logic: Reply with the received message const replyText = `You sent: ""${inboundSms.body}""`; // Corrected quotes try { await this.sendSms(inboundSms.from, replyText); this.logger.log(`Reply sent successfully to ${inboundSms.from} for inbound ID ${inboundSms.id}`); } catch (error) { this.logger.error(`Failed to send reply to ${inboundSms.from} for message ${inboundSms.id}: ${error.message}`, error.stack); // Depending on the error, you might implement retries (Section 5) // or notify an admin. Error is already logged in sendSms. } } private async sendSms(recipient: string, message: string): Promise<any> { if (!this.sinchClient) { this.logger.error('Attempted to send SMS, but Sinch Client is not initialized.'); throw new Error('Sinch Client not initialized.'); } if (!this.sinchNumber) { this.logger.error('Attempted to send SMS, but Sinch sender number is not configured.'); throw new Error('Sinch sender number not configured.'); } this.logger.debug(`Attempting to send SMS to ${recipient} from ${this.sinchNumber}`); try { // Note: The Sinch Node SDK uses `batches.send` even for sending a single message. // This is the standard method according to the SDK design. const response = await this.sinchClient.sms.batches.send({ sendSMSRequestBody: { to: [recipient], // Recipient number from the inbound message from: this.sinchNumber, // Your Sinch virtual number body: message, // You can add other parameters like 'delivery_report', 'client_reference' here if needed }, }); this.logger.debug(`Sinch API send response: ${JSON.stringify(response)}`); return response; // Return the response which might contain the batch ID } catch (error) { // Log detailed error information from Sinch SDK if available const errorMessage = error.response?.data ? JSON.stringify(error.response.data) : error.message; this.logger.error(`Sinch SDK error sending SMS: ${errorMessage}`, error.stack); throw error; // Re-throw the error to be handled by the caller (processIncomingMessage) } } }ConfigService: We injectConfigServiceto access environment variables in a type-safe way, following NestJS conventions.OnModuleInit: Initializes theSinchClientusing credentials fromConfigService. Added clearer error logging and throwing if configuration is missing.processIncomingMessage: This async method takes the parsed DTO. It logs more details (including the body, as this is the service layer) constructs a reply message, and callssendSms. Crucially, it includestry...catchblock for robust error handling during the reply process.sendSms: This private helper method encapsulates the actual call to the Sinch SDK'ssinchClient.sms.batches.sendmethod. Includes error handling specific to the SDK call and logs detailed errors. Added a comment clarifying the use ofbatches.send. Corrected quotes in the reply text.3. API Layer Documentation
The primary API endpoint exposed by this application is the webhook receiver.
Endpoint:
POST /sms/inboundDescription: Receives inbound SMS messages forwarded by the Sinch platform via HTTP POST requests.
Request Body:
application/jsonsrc/sms/dto/inbound-sms.dto.ts. See the example payload in Section 2, Step 4. Key fields include:from(string, E.164 format): The sender's phone number.to(string, E.164 format): Your Sinch virtual number that received the message.body(string): The text content of the SMS.id(string): Sinch's unique identifier for the message.timestamp,received_at,type, etc.Successful Response:
200 OKError Responses:
400 Bad Request: If the request body fails validation (e.g., missing required fields, invalid phone number format, extra fields whenforbidNonWhitelisted: true). The response body will typically contain details about the validation errors fromclass-validator.500 Internal Server Error: If an unexpected error occurs during processing before the initial response is sent (less likely with the async processing approach, but possible during initial DTO handling or controller instantiation).Testing with cURL (Requires
ngrok- see Section 4):Remember to replace
<your-ngrok-subdomain>with your actual ngrok subdomain and+1xxxxxxxxxxwith your actual Sinch number.Assuming
ngrokforwardshttps://<your-ngrok-subdomain>.ngrok.ioto your localhttp://localhost:3000:curl -X POST \ https://<your-ngrok-subdomain>.ngrok.io/sms/inbound \ -H 'Content-Type: application/json' \ -d '{ "event": "incoming_sms", "id": "TEST01_CURL", "timestamp": "2024-08-15T11:00:00.000Z", "version": 1, "type": "mo_text", "from": "+15550001111", "to": "+1xxxxxxxxxx", "body": "Manual test message via cURL", "received_at": "2024-08-15T11:00:01.000Z" }'You should receive a
200 OKresponse immediately, and your NestJS application logs should show the message ID and sender being received, followed by processing logs. If the reply logic works and thefromnumber is valid, it would receive the echo reply.4. Integrating with Sinch (Configuration & Local Testing)
This section details how to configure your Sinch account to send webhooks to your application and how to test it locally using
ngrok.Step 1: Obtain Sinch API Credentials & Number
SINCH_PROJECT_ID,SINCH_KEY_ID,SINCH_KEY_SECRET, andSINCH_NUMBERin your.envfile.Step 2: Start Your NestJS Application
Run your application locally:
# Using npm npm run start:dev # Using yarn yarn start:devYour app should be running, typically on
http://localhost:3000. Check the console logs for the ""Application is running on..."" message and confirmation that the Sinch Client initialized successfully.Step 3: Expose Your Local Server with
ngrokOpen a new terminal window (keep the NestJS app running in the first one). Start
ngrokto tunnel traffic to your local port (e.g., 3000).ngrokwill display output similar to this:Copy the
https://forwarding URL. This is the public URL that Sinch can use to reach your local application.ngrok Limitations (Free Tier):
Production Alternatives:
Step 4: Configure the Sinch Callback URL
Now, tell Sinch where to send the inbound SMS webhooks.
SINCH_PROJECT_ID(often named after your project or a default like ""Customer_SMS_API""). Click on its name/ID.https://ngrok URL you copied, followed by your webhook path:/sms/inbound.https://<random-subdomain>.ngrok.io/sms/inbound(Note: Sinch dashboard UI may change. Consult the official Sinch documentation for the most up-to-date instructions on configuring SMS API callback URLs if you cannot find these options.)
Step 5: Test the Integration
SINCH_NUMBER.ngrokTerminal: You should see aPOST /sms/inboundrequest with a200 OKresponse status listed in thengrokinterface.SINCH_NUMBERsaying something likeYou sent: ""[Your Original Message]"".If you encounter issues, check the Troubleshooting section (Section 11 - Note: Section 11 was not provided in the original text).
5. Implementing Error Handling, Logging, and Retries
Production systems need robust error handling and logging.
Error Handling Strategy:
ValidationPipe. NestJS returns a400 Bad Requestwith details. Logs are generated by NestJS for these.sendSmsmethod inSmsService. The error is logged with details (including Sinch's response if available) and re-thrown. Common Sinch API errors:SINCH_PROJECT_ID,SINCH_KEY_ID,SINCH_KEY_SECRET)try...catchinprocessIncomingMessagecatches errors fromsendSms(or other logic added later). This prevents an unhandled promise rejection from crashing the background task and logs the failure context.@Post('inbound')handler uses.catch()on the asyncprocessIncomingMessagecall. This ensures errors during background processing are logged via the controller's logger context but do not prevent the initial200 OKresponse from being sent to Sinch.SinchClientsetup inonModuleInitare logged and thrown, preventing the application from starting in a broken state.Logging:
Loggerservice.debugin dev,infoorwarnin prod). Ensure sensitive data is masked or omitted from production logs where appropriate.Retry Mechanisms (for Sending Replies):
Sinch API calls (like sending SMS) might fail transiently (network issues, temporary service unavailability). Implement retries to improve reliability.
Example: Simple Exponential Backoff Retry in
sendSms// src/sms/sms.service.ts (Updated sendSms method) // Helper function for async delay const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); // ... inside SmsService class private async sendSms(recipient: string, message: string, maxRetries = 3): Promise<any> { if (!this.sinchClient) { this.logger.error('Attempted to send SMS, but Sinch Client is not initialized.'); throw new Error('Sinch Client not initialized.'); } if (!this.sinchNumber) { this.logger.error('Attempted to send SMS, but Sinch sender number is not configured.'); throw new Error('Sinch sender number not configured.'); } let attempts = 0; let currentDelay = 1000; // Initial delay 1 second while (attempts < maxRetries) { attempts++; this.logger.debug(`Attempting to send SMS to ${recipient} (Attempt ${attempts}/${maxRetries})`); try { // Note: The Sinch Node SDK uses `batches.send` even for sending a single message. const response = await this.sinchClient.sms.batches.send({ sendSMSRequestBody: { to: [recipient], from: this.sinchNumber, body: message, }, }); this.logger.debug(`Sinch API send response (Attempt ${attempts}): ${JSON.stringify(response)}`); return response; // Success, exit the loop } catch (error) { const errorMessage = error.response?.data ? JSON.stringify(error.response.data) : error.message; const statusCode = error.response?.status; this.logger.warn(`Sinch SDK error sending SMS (Attempt ${attempts}, Status: ${statusCode || 'N/A'}): ${errorMessage}`, error.stack); if (attempts >= maxRetries) { this.logger.error(`Max retries (${maxRetries}) reached for sending SMS to ${recipient}. Giving up.`); throw error; // Re-throw the final error after all retries failed } // Basic retry logic: retry on network errors or 5xx server errors. // Refine this based on specific Sinch API error codes documented for transient failures. const isRetryable = !statusCode || (statusCode >= 500 && statusCode < 600); if (!isRetryable) { this.logger.error(`Non-retryable error encountered (Status: ${statusCode}). Giving up sending SMS to ${recipient}.`); throw error; // Do not retry non-retryable errors (like 4xx client errors) } this.logger.log(`Waiting ${currentDelay}ms before retry ${attempts + 1}...`); await delay(currentDelay); currentDelay *= 2; // Exponential backoff: 1s, 2s, 4s... } } // This line should theoretically be unreachable if maxRetries > 0 throw new Error('Exited retry loop unexpectedly.'); } // ... rest of the classsendSmsmethod attempts the API call up tomaxRetriestimes.delay) between retries, doubling the wait time (currentDelay *= 2) after each failure (exponential backoff).isRetryable(no status code usually means network/DNS error, or 5xx server errors). Client errors (4xx) are typically not retried. Note: This basic logic might need refinement based on specific Sinch API error codes or official documentation for more robust retry decisions.Testing Error Scenarios:
.envto test authentication failures (401 errors, likely non-retryable).sendSmsmethod to temporarily throw specific error types (e.g., simulate a 503 error) to test the retry logic.Webhook Response Time Requirements:
2xxresponse within 20 seconds (industry standard: 5-10 seconds)200 OKand process asynchronouslyRate Limiting Best Practices:
bottleneckorp-queueto throttle outbound SMS6. Creating a Database Schema and Data Layer (Optional Enhancement)
While not strictly required for the basic echo bot, storing message history is often valuable.
Use Case: Log all incoming and outgoing messages for auditing, analysis, compliance, or debugging.
Technology Choice: A relational database (like PostgreSQL) with an ORM (like TypeORM or Prisma) is a common choice with NestJS. For message storage, consider:
Database Options:
Recommended Schema (PostgreSQL/TypeORM example):
// message.entity.ts @Entity('messages') export class Message { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'varchar', length: 50 }) sinchMessageId: string; // Sinch's message ID for deduplication @Column({ type: 'varchar', length: 20 }) @Index() from: string; // Sender phone number (E.164) @Column({ type: 'varchar', length: 20 }) @Index() to: string; // Recipient phone number (E.164) @Column({ type: 'text' }) body: string; // Message content @Column({ type: 'enum', enum: ['inbound', 'outbound'] }) direction: 'inbound' | 'outbound'; @Column({ type: 'enum', enum: ['received', 'sent', 'failed'], default: 'received' }) status: string; @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) @Index() timestamp: Date; @Column({ type: 'jsonb', nullable: true }) metadata: any; // Store full Sinch webhook payload }Data Retention: Implement TTL policies. SMS data typically retained for 90 days to 2 years depending on compliance requirements (GDPR, TCPA, HIPAA).
Frequently Asked Questions About Sinch Two-Way SMS with NestJS
How do I handle Sinch webhook retries?
Sinch retries webhook deliveries up to 3 times if your endpoint returns non-2xx responses or times out (>20 seconds). Handle retries by: (1) implementing idempotency using
inboundSms.idto detect duplicates, (2) storing processed message IDs in Redis or database with TTL, (3) checking if message was already processed before sending reply, and (4) always returning200 OKquickly (<5 seconds). Use@HttpCode(HttpStatus.OK)decorator and async processing pattern shown in this guide.What is the Sinch API rate limit for sending SMS?
Sinch allows 30 messages per second per project (verified Q4 2024). For higher volumes, contact Sinch sales for increased limits. Implement rate limiting using libraries like
bottleneck(Node.js) or queue systems (BullMQ with Redis). Monitor429 Too Many Requestsresponses and implement exponential backoff (1s, 2s, 4s delays). For enterprise applications processing >100,000 messages/day, use distributed queue workers with Redis for horizontal scaling.How much does Sinch SMS cost?
Sinch SMS pricing varies by destination country. As of 2024, typical rates: US/Canada $0.0075-0.01/message, UK £0.04/message, India ₹0.20/message. Virtual number rental: $1-5/month depending on country. Inbound messages typically free or minimal cost. Check Sinch's official pricing page for current rates. Volume discounts available for >100k messages/month.
Can I use Sinch for two-way SMS in production?
Yes, Sinch is enterprise-grade and suitable for production. Requirements: (1) stable HTTPS webhook URL (not ngrok free tier), (2) proper error handling and retry logic, (3) rate limiting implementation (30 msg/s limit), (4) monitoring and alerting (Sentry, Datadog), (5) database for message history and deduplication, (6) compliance with regulations (TCPA for US, GDPR for EU), and (7) security measures (HTTPS, IP whitelisting, request validation). This guide provides production-ready patterns for all requirements.
How do I test Sinch webhooks locally?
Test locally using: (1) ngrok to expose localhost (
ngrok http 3000), (2) configure ngrok HTTPS URL in Sinch Dashboard > SMS > APIs > Callback URLs, (3) send test SMS to your Sinch number, (4) monitor logs in NestJS terminal and ngrok web interface (http://127.0.0.1:4040), and (5) verify200 OKresponses and reply delivery. Alternative: Use cURL to simulate webhooks (see Section 3) without sending actual SMS. For CI/CD testing, mockSinchClientusing Jest mocks or test containers.What phone number format does Sinch require?
Sinch requires E.164 format for all phone numbers:
+[country code][number]without spaces or special characters. Examples: +14155551234 (US), +442071234567 (UK), +919876543210 (India). Validate usingclass-validator's@IsPhoneNumber()decorator orlibphonenumber-jslibrary. Invalid formats cause400 Bad Requesterrors. Always store numbers in E.164 in database for consistency.How do I handle message delivery failures?
Handle delivery failures by: (1) implementing retry logic with exponential backoff (shown in Section 5), (2) logging Sinch SDK error responses with status codes and messages, (3) storing failed messages in database with failure reason, (4) implementing dead-letter queue for messages that fail after max retries (3-5 attempts), (5) setting up alerts for failure rate thresholds (>5%), and (6) distinguishing permanent failures (invalid number, blocked) from temporary (network, rate limit) using HTTP status codes (4xx vs 5xx).
Can I send MMS or media messages with Sinch?
Yes, Sinch supports MMS in supported countries (US, Canada, UK). Use
media_urlsparameter insendSMSRequestBody:await this.sinchClient.sms.batches.send({ sendSMSRequestBody: { to: [recipient], from: this.sinchNumber, body: message, media_urls: ['https://example.com/image.jpg'], // Array of URLs }, });Media requirements: HTTPS URLs, max 5 media files per message, supported formats (JPEG, PNG, GIF, MP4), max file size 500KB-5MB depending on carrier. MMS costs 3-5x standard SMS rates.
How do I secure Sinch webhooks in production?
Secure webhooks by: (1) using HTTPS only (enforced by Sinch), (2) implementing IP whitelisting for Sinch's IP ranges (check Sinch docs for current IPs), (3) adding secret tokens in webhook URLs (
?token=SECRET), (4) validating request structure with DTOs (shown in guide), (5) implementing rate limiting on webhook endpoint (100 requests/minute per IP), (6) logging all webhook requests for audit trails, (7) using environment variables for secrets, and (8) monitoring for unusual patterns (sudden spikes, malformed requests).What NestJS version is compatible with Sinch SDK?
This guide uses NestJS 10.x (verified Q4 2024) with
@sinch/sdk-core1.x. Compatible with: NestJS 9.x, 10.x, Node.js 18.x, 20.x. TypeScript 4.9+ required. For NestJS 8.x or older, minor adjustments may be needed forConfigModuleandValidationPipeconfiguration. Always check NestJS migration guides when upgrading major versions.How do I deploy Sinch NestJS app to production?
Deploy to production: (1) build application (
npm run build), (2) set environment variables on hosting platform (not .env files), (3) use process manager (PM2:pm2 start dist/main.js -i max), (4) configure reverse proxy (NGINX, Caddy) with SSL certificates (Let's Encrypt), (5) set up monitoring (health checks, error tracking with Sentry), (6) implement logging to external service (CloudWatch, Datadog), (7) configure auto-scaling based on CPU/memory (AWS ECS, Kubernetes), and (8) use managed PostgreSQL and Redis for data layer. Recommended platforms: AWS (EC2/ECS/Lambda), DigitalOcean App Platform, Heroku, Railway, Google Cloud Run.How do I implement conversation state in two-way SMS?
Implement conversation state using: (1) Redis with user phone number as key (
redis.set(phoneNumber, JSON.stringify(state), 'EX', 3600)), (2) in-memory caching with@nestjs/cache-managerfor simple bots, (3) database sessions table withphone_number,state,last_activitycolumns, (4) state machine pattern with states likeAWAITING_NAME,AWAITING_CHOICE, (5) timeout handling (expire state after 5-15 minutes inactivity), and (6) context preservation across messages. Example: User sends "ORDER" → bot replies "What's your name?" → store stateAWAITING_NAME→ next message becomes the name.Conclusion
Build production-ready two-way SMS messaging applications using Sinch, Node.js, and NestJS. This implementation includes:
class-validatorNext Steps: