NestJS WhatsApp Integration with Sinch API: Step-by-Step Guide
Complete guide to integrating Sinch WhatsApp API with NestJS. Learn to send messages, handle webhooks, and implement secure signature verification with production-ready TypeScript code examples.
Learn how to integrate WhatsApp messaging into your NestJS application using the Sinch Conversation API. This comprehensive guide covers sending WhatsApp messages, receiving webhook notifications, and implementing production-ready security patterns for Node.js backends.
Leverage NestJS's robust framework to build scalable applications that communicate with customers on the world's most popular messaging platform.
Technologies Used:
NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications.
Sinch Conversation API: A unified API for managing conversations across multiple channels, including WhatsApp.
TypeScript: Provides static typing for better code quality and maintainability.
Node.js: The runtime environment.
(Optional) MongoDB & Mongoose: For persisting message history.
(Optional) Docker: For containerization and deployment consistency.
(Note: This ASCII diagram illustrates the flow. For final documentation, consider generating a visual diagram using tools like MermaidJS or other diagramming software.)
Prerequisites:
Node.js (LTS version recommended, v18+ for best compatibility) and npm/yarn installed.
A registered and approved WhatsApp Business Sender configured within your Sinch account.
A publicly accessible URL for receiving webhooks (ngrok can be used for local development, but a stable HTTPS domain/IP is needed for production).
Basic understanding of NestJS concepts (Modules, Controllers, Services). See official documentation at https://docs.nestjs.com/.
(Optional) MongoDB instance (local or cloud like MongoDB Atlas).
(Optional) Docker installed.
1. Project Setup and Configuration
Initialize your NestJS project and set up the basic structure and environment configuration.
1.1. Create NestJS Project:
Open your terminal and run:
Bash
nest new sinch-whatsapp-integration
cd sinch-whatsapp-integration
Choose your preferred package manager (npm or yarn).
1.2. Install Dependencies:
Install modules for HTTP requests, configuration management, data validation, and handling raw request bodies for webhooks. Optionally, add Mongoose for database interaction.
axios: A promise-based HTTP client for making requests to the Sinch API. (Alternatively, use NestJS's built-in HttpModule).
class-validator & class-transformer: For validating incoming webhook payloads and API request bodies.
body-parser & @types/body-parser: Required for accessing the raw request body, necessary for webhook signature verification.
@nestjs/mongoose & mongoose: For MongoDB integration (if storing messages).
1.3. Environment Variables:
Store sensitive credentials outside your codebase using a .env file – a security best practice.
Create a .env file in the project root:
.env
# .env
# === IMPORTANT ===
# The values starting with 'YOUR_' below are placeholders.
# You MUST replace them with your actual credentials from the Sinch portal.
# Do NOT commit this file with real secrets to version control.
# Sinch API Credentials
SINCH_SERVICE_PLAN_ID=YOUR_SINCH_SERVICE_PLAN_ID # Replace with your Service Plan ID from Sinch
SINCH_API_TOKEN=YOUR_SINCH_API_TOKEN # Replace with your API Token from Sinch
SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID # Replace with your Project ID (often same as Service Plan ID, verify in Sinch portal)
# Sinch WhatsApp Sender ID (Phone Number associated with your WABA)
# Replace with your actual approved WhatsApp sender number in E.164 format (e.g., +12345550100)
SINCH_WHATSAPP_SENDER_ID=+12345678900 # EXAMPLE ONLY – REPLACE THIS
# Webhook Secret (Generate a strong random string for security)
# This is used to verify incoming webhooks from Sinch. You MUST provide the same secret in the Sinch portal webhook config.
SINCH_WEBHOOK_SECRET=YOUR_STRONG_RANDOM_SECRET # Replace with a strong, unique secret
# Application Port
PORT=3000
# Optional: Database Connection String (if using Mongoose)
# DATABASE_URL=mongodb://user:password@host:port/database
Navigate to APIs > API Credentials. You should find your Service Plan ID and API Token. If you don't have one, create an API token. Copy these accurately.
Your Project ID is typically associated with your Service Plan. Verify this in the portal.
Navigate to Apps under the Conversation API section. Find or create an App configured for WhatsApp. The associated phone number is your SINCH_WHATSAPP_SENDER_ID. This must be the exact, approved number in E.164 format (including + and country code, e.g., +12345678900).
Generate a strong, unique secret for SINCH_WEBHOOK_SECRET (use a password manager or online generator). Provide this exact same secret to Sinch when configuring the webhook later. Treat this like a password.
Note on E.164 Format: All phone numbers used with Sinch Conversation API must be in E.164 format: +[country code][number] with no spaces, dashes, or parentheses. For example, a US number: +14155551234. This international phone number standard ensures proper routing across global telecommunications networks.
1.4. Configure Environment Module:
Load the .env file into the application using @nestjs/config.
Modify src/app.module.ts:
TypeScript
// src/app.module.tsimport { Module } from'@nestjs/common';
import { ConfigModule } from'@nestjs/config';
import { AppController } from'./app.controller';
import { AppService } from'./app.service';
// Import other modules (Sinch, Webhook, Database) here later@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Makes ConfigModule available globallyenvFilePath: '.env', // Specifies the env file path
}),
// Add other modules here as you create them
],
controllers: [AppController],
providers: [AppService],
})
exportclassAppModule {}
1.5. Project Structure:
Organize your code into modules for better separation of concerns.
Text
src/
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├── main.ts
├── config/ # Configuration related files (optional)
│ └── sinch.config.ts
├── sinch/ # Module for Sinch API interaction
│ ├── sinch.module.ts
│ ├── sinch.service.ts
│ └── dto/ # Data Transfer Objects for Sinch API
│ ├── send-message.dto.ts
│ └── sinch-send-response.dto.ts # DTO for Sinch send response
├── webhook/ # Module for handling incoming Sinch webhooks
│ ├── webhook.module.ts
│ ├── webhook.controller.ts
│ ├── webhook.service.ts
│ ├── guards/ # Custom guards (e.g., for webhook verification)
│ │ └── sinch-webhook.guard.ts
│ └── dto/ # DTOs for incoming webhook payloads
│ └── sinch-webhook.dto.ts # Renamed for clarity
└── database/ # Optional: Module for database interaction
├── database.module.ts
├── schemas/
│ └── message.schema.ts
└── services/
└── message-persistence.service.ts
Create these folders (sinch, webhook, config, etc.) within the src directory.
Create a dedicated NestJS service to interact with the Sinch Conversation API for sending WhatsApp messages programmatically.
2.1. Create Sinch Module and Service:
Generate the module and service using the NestJS CLI:
Bash
nest generate module sinch
nest generate service sinch
2.2. Define Sinch Configuration (Optional but Recommended):
Create a typed configuration file for Sinch settings.
TypeScript
// src/config/sinch.config.tsimport { registerAs } from'@nestjs/config';
exportdefaultregisterAs('sinch', () => ({
servicePlanId: process.env.SINCH_SERVICE_PLAN_ID,
apiToken: process.env.SINCH_API_TOKEN,
projectId: process.env.SINCH_PROJECT_ID || process.env.SINCH_SERVICE_PLAN_ID, // Fallback if separate Project ID isn't usedwhatsappSenderId: process.env.SINCH_WHATSAPP_SENDER_ID,
webhookSecret: process.env.SINCH_WEBHOOK_SECRET,
// Ensure the region (us, eu, etc.) in the URL matches your account's region// Sinch operates in multiple regions. Check your account settings for the correct region.// Available regions: us, eu. Default shown below is 'us'.apiUrl: `https://us.conversation.api.sinch.com/v1/projects/${process.env.SINCH_PROJECT_ID || process.env.SINCH_SERVICE_PLAN_ID}`,
}));
Inject this configuration into your SinchService.
2.3. Implement SinchService:
This service contains the logic to call the Sinch API.
TypeScript
// src/sinch/sinch.service.tsimport { Injectable, Logger, Inject } from'@nestjs/common';
import { ConfigType } from'@nestjs/config';
import axios, { AxiosInstance, AxiosError } from'axios'; // Import AxiosErrorimport * as crypto from'crypto'; // Use ES6 import for cryptoimport sinchConfig from'../config/sinch.config'; // Import the typed configimport { SinchSendMessageResponseDto } from'./dto/sinch-send-response.dto'; // Import response DTO@Injectable()
exportclassSinchService {
privatereadonly logger = newLogger(SinchService.name);
privatereadonlyhttpClient: AxiosInstance;
constructor(@Inject(sinchConfig.KEY) // Inject the typed configprivateconfig: ConfigType<typeof sinchConfig>,
) {
// Crucial check for essential configurationif (!config.projectId || !config.apiToken || !config.whatsappSenderId || !config.webhookSecret) {
this.logger.error('Sinch configuration incomplete. Check .env file for SINCH_PROJECT_ID, SINCH_API_TOKEN, SINCH_WHATSAPP_SENDER_ID, and SINCH_WEBHOOK_SECRET.');
thrownewError('Sinch Service configuration is incomplete. Please check environment variables.');
}
this.httpClient = axios.create({
baseURL: config.apiUrl,
headers: {
Authorization: `Bearer ${config.apiToken}`,
'Content-Type': 'application/json',
},
});
}
/**
* Sends a WhatsApp text message via the Sinch Conversation API.
* @paramrecipientPhoneNumber - The recipient's phone number in E.164 format (e.g., +15551234567).
* @paramtextContent - The text message content.
* @returns A promise resolving to the structured Sinch API response.
*/asyncsendWhatsAppTextMessage(
recipientPhoneNumber: string,
textContent: string,
): Promise<SinchSendMessageResponseDto> { // Use specific Response DTOconst endpoint = '/messages:send';
// Payload structure based on common Sinch Conversation API usage for WhatsApp.// **Important Note:** Verify this payload structure against the latest official// Sinch Conversation API documentation before using in production.// Specifically, confirm the usage of `app_id` vs. sender ID and `contact_id` for recipients.const payload = {
app_id: this.config.whatsappSenderId, // Using sender ID as app_id reference for WhatsApprecipient: {
contact_id: recipientPhoneNumber, // Use phone number directly for WhatsApp via contact_id
},
message: {
text_message: {
text: textContent,
},
},
channel_priority_order: ['WHATSAPP'], // Ensure it uses WhatsApp channel
};
this.logger.log(`Sending WhatsApp message to ${recipientPhoneNumber} via app_id ${this.config.whatsappSenderId}`);
try {
const response = awaitthis.httpClient.post<SinchSendMessageResponseDto>(endpoint, payload);
this.logger.log(`Message sent successfully. Message ID: ${response.data?.message_id}`);
return response.data;
} catch (error) {
const axiosError = error asAxiosError; // Type assertion for better error handlingconst errorDetails = axiosError.response?.data || axiosError.message;
this.logger.error(
`Failed to send WhatsApp message to ${recipientPhoneNumber}. Status: ${axiosError.response?.status}. Details: ${JSON.stringify(errorDetails)}`,
axiosError.stack// Include stack trace for better debugging
);
// Re-throw or handle specific Sinch errors (consider using a custom exception class)throw error; // Re-throw the original error to be handled by the controller
}
}
// Add methods for other message types (templates, media) as needed// based on Sinch Conversation API documentation.// Example: sendTemplateMessage, sendMediaMessage etc.
<!-- GAP: Missing implementation examples for template messages and media messages (Type: Substantive, Priority: High) -->
/**
* Verifies the signature of an incoming webhook request from Sinch.
*
* Sinch webhook signature verification algorithm:
* 1. Sinch sends headers: x-sinch-signature (format: "t=<timestamp>,<base64_signature>") and x-sinch-timestamp
* 2. Construct signed payload: "<timestamp>.<raw_request_body_utf8>"
* 3. Compute HMAC-SHA256 of signed payload using webhook secret
* 4. Encode result as Base64
* 5. Compare with signature from header using timing-safe comparison
*
* Reference: https://developers.sinch.com/docs/conversation/keyconcepts/#callback-signature-verification
*
* @paramrawBody - The raw request body buffer.
* @paramsignatureHeader - The value of the 'x-sinch-signature' header.
* @paramtimestampHeader - The value of the 'x-sinch-timestamp' header.
* @returns True if the signature is valid, false otherwise.
*/verifyWebhookSignature(
rawBody: Buffer,
signatureHeader: string | undefined,
timestampHeader: string | undefined,
): boolean {
if (!signatureHeader || !timestampHeader || !this.config.webhookSecret) {
this.logger.warn('Webhook verification failed: Missing signature, timestamp, or webhook secret.');
returnfalse;
}
try {
const hmac = crypto.createHmac('sha256', this.config.webhookSecret);
const [signatureTimestamp, receivedSignature] = signatureHeader.split(',');
// Validate the timestamp format in the header partif (!signatureTimestamp || !receivedSignature || signatureTimestamp !== `t=${timestampHeader}`) {
this.logger.warn(`Webhook verification failed: Malformed or mismatched timestamp in signature header. Header: "${signatureHeader}", Timestamp: "${timestampHeader}"`);
returnfalse;
}
// Construct the payload string exactly as Sinch signs it: timestamp + "." + rawBodyconst signedPayload = `${timestampHeader}.${rawBody.toString('utf8')}`;
const expectedSignature = hmac.update(signedPayload).digest('base64'); // Sinch uses Base64 encoding for the signature// Use timingSafeEqual for security against timing attacksconst receivedSigBuffer = Buffer.from(receivedSignature, 'base64');
const expectedSigBuffer = Buffer.from(expectedSignature, 'base64');
// Ensure buffers are valid and have the same length before comparingif (receivedSigBuffer.length === expectedSigBuffer.length &&
receivedSigBuffer.length > 0 && // Ensure non-empty buffers
crypto.timingSafeEqual(receivedSigBuffer, expectedSigBuffer)) {
this.logger.debug('Webhook signature verified successfully.');
returntrue;
} else {
this.logger.warn(`Webhook verification failed: Invalid signature. Received: "${receivedSignature}", Expected: "${expectedSignature}" (Base64)`);
returnfalse;
}
} catch (error) {
this.logger.error('Error during webhook signature verification:', error);
returnfalse;
}
}
}
2.4. Update SinchModule:
Register the service and the configuration.
TypeScript
// src/sinch/sinch.module.tsimport { Module } from'@nestjs/common';
import { ConfigModule } from'@nestjs/config'; // Import ConfigModuleimport { SinchService } from'./sinch.service';
import sinchConfig from'../config/sinch.config'; // Import the config loader@Module({
imports: [
ConfigModule.forFeature(sinchConfig), // Register the specific config
],
providers: [SinchService],
exports: [SinchService], // Export if needed in other modules
})
exportclassSinchModule {}
3. Building the API Layer (Example: Sending a Message)
Expose an endpoint to trigger sending a message.
3.1. Create DTOs for Sending Messages and Responses:
Define the expected request body structure and validation rules. Also, define a basic DTO for the Sinch API response.
TypeScript
// src/sinch/dto/send-message.dto.tsimport { IsNotEmpty, IsPhoneNumber, IsString, MaxLength } from'class-validator';
exportclassSendMessageDto {
@IsNotEmpty({ message: 'Recipient phone number should not be empty.' })
@IsPhoneNumber(null, { message: 'Recipient phone number must be a valid E.164 format phone number (e.g., +15551234567).' }) // Use null for international format validationreadonlyrecipientPhoneNumber: string;
@IsNotEmpty({ message: 'Message text should not be empty.' })
@IsString()
@MaxLength(4096, { message: 'WhatsApp message text cannot exceed 4096 characters per message.' }) // WhatsApp Business API limit per messagereadonlymessageText: string;
}
TypeScript
// src/sinch/dto/sinch-send-response.dto.ts// Basic DTO for the expected successful response from Sinch /messages:send// Flesh this out based on the actual fields returned by Sinch APIexportclassSinchSendMessageResponseDto {
message_id: string;
accepted_time?: string; // Example optional field// Add other fields as documented by Sinch
}
3.2. Create a Controller (e.g., add to AppController or create a dedicated MessageController):
Validation:@UsePipes(new ValidationPipe(...)) validates the request body against SendMessageDto. forbidNonWhitelisted: true rejects requests with extra fields.
Response: Returns 202 Accepted with the messageId on success.
Error Handling: Catches errors, specifically checks for AxiosError to provide more context from Sinch API failures, and throws appropriate HttpException instances which NestJS handles.
3.3. Testing the Endpoint:
Run your NestJS application:
Bash
npm run start:dev
Use curl or Postman. Using curl (note the use of single quotes around the JSON data for shell safety):
Bash
curl -X POST http://localhost:3000/send-whatsapp \
-H 'Content-Type: application/json' \
-d '{
"recipientPhoneNumber": "+15551234567",
"messageText": "Hello from NestJS via Sinch!"
}'
Expected Response (Success – Status Code 202):
JSON
{"success":true,"messageId":"01HXXXX..."}
Expected Response (Validation Error – Status Code 400):
JSON
{"statusCode":400,"message":["Recipient phone number must be a valid E.164 format phone number (e.g., +15551234567)."],"error":"Bad Request"}
Expected Response (Sinch API Error – Status Code e.g., 400, 401, 503):
The response will reflect the HttpException thrown in the controller, e.g.:
JSON
{"statusCode":400,"message":"Failed to send message via Sinch: Invalid recipient phone number"}
Sinch notifies your NestJS application about incoming WhatsApp messages and delivery status updates via webhooks. Create a secure endpoint to receive and process these real-time notifications.
4.1. Configure Webhook in Sinch Dashboard:
Log in to your Sinch Customer Dashboard.
Navigate to Apps under the Conversation API section.
Select the App associated with your WhatsApp sender.
Find the Webhook or Callback URL settings.
Enter the publicly accessible URL pointing to the webhook endpoint you'll create (e.g., https://your-public-domain.com/webhook/sinch or your ngrok URL like https://<your-id>.ngrok.io/webhook/sinch). This URL MUST use HTTPS.
Crucially: Find the field for the Webhook Secret. Paste the exact same secret you defined in your .env file (SINCH_WEBHOOK_SECRET). This is used for signature verification. Ensure there are no typos or extra spaces.
Select the events you want to subscribe to (e.g., MESSAGE_INBOUND, MESSAGE_DELIVERY). Start with MESSAGE_INBOUND.
Save the configuration.
Screenshot Example (Conceptual – Actual UI may vary):
Webhook signature verification requires the raw, unparsed request body. Apply body-parser's raw body middleware specifically to the webhook route. This is configured in the WebhookModule (see section 4.4).
Modify src/main.ts to ensure global pipes or other global body parsers don't interfere, although selective middleware is preferred:
TypeScript
// src/main.tsimport { NestFactory } from'@nestjs/core';
import { AppModule } from'./app.module';
import { Logger } from'@nestjs/common'; // Removed unused ValidationPipe importimport { ConfigService } from'@nestjs/config';
// Import NestExpressApplication if you need specific Express types, otherwise not strictly needed// import { NestExpressApplication } from '@nestjs/platform-express';asyncfunctionbootstrap() {
// No need for { rawBody: true } here when using selective middlewareconst app = awaitNestFactory.create(AppModule);
// const app = await NestFactory.create<NestExpressApplication>(AppModule); // Use if needing Express typesconst configService = app.get(ConfigService);
const port = configService.get<number>('PORT', 3000);
const logger = newLogger('Bootstrap');
// Global pipes can be applied here if needed for other routes// app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));await app.listen(port);
logger.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();
// src/webhook/guards/sinch-webhook.guard.tsimport { Injectable, CanActivate, ExecutionContext, Logger, RawBodyRequest, BadRequestException, ForbiddenException } from'@nestjs/common';
import { Observable } from'rxjs';
import { Request } from'express'; // Assuming Express adapterimport { SinchService } from'../../sinch/sinch.service';
@Injectable()
exportclassSinchWebhookGuardimplementsCanActivate {
privatereadonly logger = newLogger(SinchWebhookGuard.name);
constructor(privatereadonlysinchService: SinchService) {}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest<RawBodyRequest<Request>>(); // Get request with potential rawBody// Access the raw body buffer attached by the middlewareconst rawBody = request.rawBody;
if (!rawBody) {
// This should not happen if the middleware is configured correctlythis.logger.error('Webhook Guard Error: Raw body not available. Ensure raw body parsing middleware is applied correctly to the webhook route.');
// Throw an exception to signal a server configuration issuethrownewBadRequestException('Server configuration error: Raw body not available for webhook verification.');
}
const signature = request.headers['x-sinch-signature'] asstring | undefined;
const timestamp = request.headers['x-sinch-timestamp'] asstring | undefined;
this.logger.debug(`Verifying webhook signature. Timestamp: ${timestamp}, Signature Header: ${signature ? signature.substring(0, 10) + '...' : 'MISSING'}`); // Avoid logging full signatureconst isValid = this.sinchService.verifyWebhookSignature(
rawBody,
signature,
timestamp,
);
if (!isValid) {
this.logger.warn(`Invalid webhook signature received from IP: ${request.ip}. Blocking request.`);
// Throw ForbiddenException to clearly indicate an authentication/authorization failurethrownewForbiddenException('Invalid webhook signature.');
}
this.logger.log('Webhook signature verified successfully.');
returntrue; // Allow request to proceed to the controller
}
}
Middleware Configuration (Important!): Apply the raw body parser middleware in WebhookModule.
Modify src/webhook/webhook.module.ts:
TypeScript
// src/webhook/webhook.module.tsimport { Module, MiddlewareConsumer, NestModule, RequestMethod } from'@nestjs/common';
import { WebhookController } from'./webhook.controller';
import { WebhookService } from'./webhook.service';
import { SinchModule } from'../sinch/sinch.module'; // Import SinchModule to use SinchServiceimport * as bodyParser from'body-parser'; // Import body-parser@Module({
imports: [SinchModule], // Import SinchModule to inject SinchService into the Guardcontrollers: [WebhookController],
providers: [WebhookService], // Guard is provided where used (@UseGuards)
})
exportclassWebhookModuleimplementsNestModule { // Implement NestModuleconfigure(consumer: MiddlewareConsumer) {
consumer
// Apply raw body parser *only* for the Sinch webhook POST route
.apply(bodyParser.raw({ type: 'application/json' })) // Ensure this matches the Content-Type Sinch sends
.forRoutes({ path: 'webhook/sinch', method: RequestMethod.POST });
}
}
Map the structure of Sinch's webhook payloads. This DTO handles multiple event types using the trigger field. Note: The original provided DTO was incomplete; this version provides a more structured example for MESSAGE_INBOUND.
TypeScript
// src/webhook/dto/sinch-webhook.dto.tsimport { Type } from'class-transformer';
import { IsString, IsNotEmpty, ValidateNested, IsOptional, IsEnum, Allow, IsObject } from'class-validator';
// --- Nested DTOs (Simplify or expand based on needs and Sinch Docs) ---classContactIdentifier {
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() user_id?: string;
@IsOptional() @IsString() whatsapp_id?: string; // Common for WhatsApp
}
classRecipientInfo {
// Use @Allow() if validation isn't strictly needed or structure varies@Allow() contact_id?: ContactIdentifier | string; // Can be object or just ID string sometimes@IsOptional() @IsString() identified_by?: string; // Alternative way Sinch might identify
}
classSenderInfo {
@Allow() contact_id?: ContactIdentifier | string;
@IsOptional() @IsString() user_display_name?: string;
}
classTextMessageContent {
@IsNotEmpty() @IsString() text: string;
}
// Add other content types as needed (MediaMessageContent, ChoiceMessageContent, etc.)// class MediaMessageContent { ... }classMessageContent {
@IsOptional() @ValidateNested() @Type(() =>TextMessageContent)
text_message?: TextMessageContent;
// Add other message types here// @IsOptional() @ValidateNested() @Type(() => MediaMessageContent)// media_message?: MediaMessageContent;
}
classMessageMetadata {
@IsOptional() @IsString() locale?: string;
// Add other metadata fields
}
// --- Payload for MESSAGE_INBOUND ---// This structure might vary slightly based on Sinch API version and specific event.// Always refer to the official Sinch documentation for the exact payload structure.classInboundMessagePayload {
@IsNotEmpty() @IsString() id: string; // Sinch message ID@IsOptional() @IsString() direction?: 'INBOUND'; // Should be INBOUND@IsNotEmpty() @ValidateNested() @Type(() =>RecipientInfo)
recipient: RecipientInfo; // Your App/Sender ID info@IsNotEmpty() @ValidateNested() @Type(() =>SenderInfo)
sender: SenderInfo; // The end-user info@IsNotEmpty() @ValidateNested() @Type(() =>MessageContent)
message: MessageContent;
@IsNotEmpty() @IsString() @IsEnum(['WHATSAPP', 'SMS', 'MESSENGER'], { message: 'Channel must be a valid Sinch channel' })
channel: string; // Expecting 'WHATSAPP' mostly@IsNotEmpty() @IsString() // Consider @IsISO8601() for stricter validationtimestamp: string; // ISO 8601 timestamp@IsOptional() @ValidateNested() @Type(() =>MessageMetadata)
metadata?: MessageMetadata;
@IsOptional() @IsString() app_id?: string; // The Sinch App ID@IsOptional() @IsString() conversation_id?: string;
@IsOptional() @IsString() contact_id?: string; // Might be present at top level too@IsOptional() @IsString() processing_mode?: string; // e.g., 'CONVERSATION'
}
// Add other payload types like DeliveryReportPayload, ContactCreatePayload etc.// --- Main Webhook DTO ---// This DTO uses the 'trigger' field to determine which payload type to expect.// You might need a more sophisticated approach (e.g., custom validation) if triggers aren't always present// or if payloads share common base fields. For simplicity, we assume distinct payloads per trigger.exportclassSinchWebhookDto {
@IsNotEmpty() @IsString() trigger: string; // e.g., 'MESSAGE_INBOUND', 'MESSAGE_DELIVERY'@IsNotEmpty() @IsString() app_id: string; // Sinch App ID@IsNotEmpty() @IsString() // Consider @IsISO8601()accepted_time: string; // When Sinch accepted the event// Use @ValidateNested based on the trigger type.// This basic example assumes only MESSAGE_INBOUND for simplicity.// In a real app, you'd likely have a union type or conditional validation.@IsOptional() @ValidateNested() @Type(() =>InboundMessagePayload)
message_inbound?: InboundMessagePayload;
// Add other potential payloads based on triggers// @IsOptional() @ValidateNested() @Type(() => DeliveryReportPayload)// message_delivery?: DeliveryReportPayload;// Allow other fields that might be present but not strictly validated
[key: string]: any;
}
4.6. Implement Webhook Controller:
4.7. Implement Webhook Service:
5. Database Integration (Optional)
6. Testing Strategy
7. Security Best Practices
8. Deployment Guide
9. Monitoring and Observability
10. Troubleshooting Common Issues
Frequently Asked Questions
How do I authenticate with the Sinch WhatsApp API in NestJS?
Authenticate using Bearer token authentication. Store your SINCH_API_TOKEN in your .env file and include it in the Authorization header as Bearer YOUR_TOKEN for all API requests. The Sinch API Token is available in your Sinch Dashboard under APIs > API Credentials. Use @nestjs/config to inject the token securely into your services and create an axios instance with the Authorization header configured globally.
What phone number format does Sinch require for WhatsApp messaging?
Sinch requires E.164 format for all phone numbers: +[country code][number] with no spaces, dashes, or parentheses. For example, a US number would be +14155551234, not (415) 555-1234 or 415-555-1234. This international standard ensures proper routing across global telecommunications networks. Use the @IsPhoneNumber() validator from class-validator in your DTOs to enforce this format. Learn more about E.164 phone number formatting.
How do I verify webhook signatures from Sinch?
Sinch uses HMAC-SHA256 signature verification with Base64 encoding. Extract the x-sinch-signature header (format: t=<timestamp>,<base64_signature>) and x-sinch-timestamp header. Construct the signed payload as <timestamp>.<raw_request_body_utf8>, compute HMAC-SHA256 using your webhook secret, encode as Base64, and compare using crypto.timingSafeEqual() to prevent timing attacks. Apply body-parser.raw() middleware specifically to webhook routes to preserve the raw body. See official documentation at https://developers.sinch.com/docs/conversation/keyconcepts/#callback-signature-verification.
How do I set up NestJS modules for Sinch integration?
Create separate modules for concerns: a SinchModule for API interaction (services for sending messages), a WebhookModule for receiving callbacks (controller with guards for signature verification), and optionally a DatabaseModule for persisting message history. Use @nestjs/config with ConfigModule.forFeature() to inject typed configuration into each module. Export SinchService from SinchModule so other modules can inject it, particularly the WebhookGuard for signature verification.
What is the WhatsApp message length limit through Sinch?
WhatsApp Business API allows up to 4,096 characters per text message through Sinch Conversation API. Use @MaxLength(4096) validator in your SendMessageDto to enforce this limit. Messages exceeding this length will be rejected by the API. For longer content, consider breaking it into multiple messages or using media attachments. Template messages may have different character limits depending on the template configuration.
How do I handle errors from the Sinch API in NestJS?
Wrap Sinch API calls in try-catch blocks and check for AxiosError instances to extract detailed error information. Access error.response.status for HTTP status codes and error.response.data for Sinch-specific error details. Throw appropriate HttpException instances with meaningful messages (e.g., BadRequestException, UnauthorizedException, BadGatewayException). Log detailed errors internally using NestJS Logger but return generic messages to clients to avoid exposing sensitive information.
How can I test WhatsApp webhooks locally in NestJS?
Yes, use ngrok to create a secure tunnel to your local NestJS development server. Install ngrok (npm install -g ngrok), run ngrok http 3000 (or your port), and use the generated HTTPS URL (e.g., https://abc123.ngrok.io/webhook/sinch) in your Sinch Dashboard webhook configuration. Ensure you configure the same webhook secret in both your .env file and Sinch Dashboard. ngrok provides a web interface at http://localhost:4040 to inspect webhook payloads, which is invaluable for debugging.
What environment variables are required for production deployment?
For production, configure these required environment variables: SINCH_SERVICE_PLAN_ID, SINCH_API_TOKEN, SINCH_PROJECT_ID, SINCH_WHATSAPP_SENDER_ID (E.164 format), and SINCH_WEBHOOK_SECRET. Never commit .env files to version control. Use your hosting provider's environment variable configuration (e.g., AWS Systems Manager, Heroku Config Vars, Vercel Environment Variables). Ensure the apiUrl in your Sinch config matches your account's region (us or eu). Verify all credentials are correctly loaded on startup using validation in your service constructor.
Frequently asked questions
Integrate Sinch WhatsApp with NestJS by setting up a new NestJS project, installing necessary dependencies like @nestjs/config, axios, and class-validator, configuring environment variables for Sinch credentials, and structuring the project into modules for Sinch API interaction, webhook handling, and optional database integration.
The Sinch Conversation API is a unified platform provided by Sinch that allows developers to manage conversations across various channels, including WhatsApp. It simplifies the process of sending and receiving messages, managing contacts, and handling other communication aspects.
NestJS provides a robust and scalable framework for building server-side applications. Its modular architecture, dependency injection, and TypeScript support make it well-suited for complex integrations like WhatsApp messaging through the Sinch API.
MongoDB is optional but recommended if you need to persist message history or other data related to your WhatsApp interactions. Use it when you require data storage and retrieval capabilities beyond the immediate request-response cycle.
Create a Sinch service in your NestJS project to handle API calls. Use axios to send HTTP requests to the Sinch API endpoint for sending WhatsApp messages. Ensure you have the recipient's phone number and the message text content.
The webhook secret is a crucial security measure to verify the authenticity of incoming webhook requests from Sinch. It ensures that the requests originated from Sinch and haven't been tampered with.
Secure your webhook endpoint by verifying the signature of incoming requests using the shared webhook secret. Implement a guard or middleware that calculates the signature from the request body and compares it with the signature provided in the x-sinch-signature header.
You'll need Node.js, npm or yarn, the NestJS CLI, a Sinch account with Conversation API access, a registered and approved WhatsApp Business Sender, a publicly accessible HTTPS URL for webhooks, and a basic understanding of NestJS concepts.
Your Sinch API token and Service Plan ID are found in your Sinch Customer Dashboard under APIs > API Credentials. Ensure these are kept secure and not exposed in your codebase.
Sinch sends webhook notifications to your specified endpoint for incoming WhatsApp messages. Create a webhook controller in your NestJS app to receive these notifications and process the message data accordingly. Verify the signature of the webhook requests for security.
The body-parser middleware, specifically bodyParser.raw(), is crucial for accessing the raw request body of incoming webhooks. This raw body is required for webhook signature verification to ensure the message integrity and security.
Your application interacts with your NestJS backend, which communicates with the Sinch platform using the Conversation API. Sinch then sends the messages to WhatsApp users, and incoming messages flow back through webhooks to your NestJS backend.
Docker is optional but recommended for containerizing your NestJS application and Sinch integration for consistent deployment across different environments.
Create DTOs for sending messages (recipient's number, message content) and receiving webhook payloads. These DTOs enhance code clarity and provide validation for incoming data. For webhooks, DTOs should handle various event types via validation and nested properties.
The Sinch webhook signature is verified using a SHA256 HMAC with your webhook secret and a concatenation of the timestamp and raw request body. This is compared to the received signature in the x-sinch-signature header to ensure message integrity.
Learn how to integrate WhatsApp messaging into your NestJS application using the Sinch Conversation API. This comprehensive guide covers sending WhatsApp messages, receiving webhook notifications, and implementing production-ready security patterns for Node.js backends.
Leverage NestJS's robust framework to build scalable applications that communicate with customers on the world's most popular messaging platform.
Technologies Used:
System Architecture:
(Note: This ASCII diagram illustrates the flow. For final documentation, consider generating a visual diagram using tools like MermaidJS or other diagramming software.)
Prerequisites:
npm install -g @nestjs/cli).1. Project Setup and Configuration
Initialize your NestJS project and set up the basic structure and environment configuration.
1.1. Create NestJS Project:
Open your terminal and run:
nest new sinch-whatsapp-integration cd sinch-whatsapp-integrationChoose your preferred package manager (npm or yarn).
1.2. Install Dependencies:
Install modules for HTTP requests, configuration management, data validation, and handling raw request bodies for webhooks. Optionally, add Mongoose for database interaction.
# Core Dependencies npm install @nestjs/config axios class-validator class-transformer body-parser @types/body-parser # Optional Database Dependencies npm install @nestjs/mongoose mongoose@nestjs/config: Manages environment variables.axios: A promise-based HTTP client for making requests to the Sinch API. (Alternatively, use NestJS's built-inHttpModule).class-validator&class-transformer: For validating incoming webhook payloads and API request bodies.body-parser&@types/body-parser: Required for accessing the raw request body, necessary for webhook signature verification.@nestjs/mongoose&mongoose: For MongoDB integration (if storing messages).1.3. Environment Variables:
Store sensitive credentials outside your codebase using a
.envfile – a security best practice.Create a
.envfile in the project root:How to Obtain Sinch Credentials:
SINCH_WHATSAPP_SENDER_ID. This must be the exact, approved number in E.164 format (including+and country code, e.g., +12345678900).SINCH_WEBHOOK_SECRET(use a password manager or online generator). Provide this exact same secret to Sinch when configuring the webhook later. Treat this like a password.Note on E.164 Format: All phone numbers used with Sinch Conversation API must be in E.164 format:
+[country code][number]with no spaces, dashes, or parentheses. For example, a US number: +14155551234. This international phone number standard ensures proper routing across global telecommunications networks.1.4. Configure Environment Module:
Load the
.envfile into the application using@nestjs/config.Modify
src/app.module.ts:// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; // Import other modules (Sinch, Webhook, Database) here later @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Makes ConfigModule available globally envFilePath: '.env', // Specifies the env file path }), // Add other modules here as you create them ], controllers: [AppController], providers: [AppService], }) export class AppModule {}1.5. Project Structure:
Organize your code into modules for better separation of concerns.
Create these folders (
sinch,webhook,config, etc.) within thesrcdirectory.2. Implementing Core Functionality: Sending WhatsApp Messages
Create a dedicated NestJS service to interact with the Sinch Conversation API for sending WhatsApp messages programmatically.
2.1. Create Sinch Module and Service:
Generate the module and service using the NestJS CLI:
2.2. Define Sinch Configuration (Optional but Recommended):
Create a typed configuration file for Sinch settings.
// src/config/sinch.config.ts import { registerAs } from '@nestjs/config'; export default registerAs('sinch', () => ({ servicePlanId: process.env.SINCH_SERVICE_PLAN_ID, apiToken: process.env.SINCH_API_TOKEN, projectId: process.env.SINCH_PROJECT_ID || process.env.SINCH_SERVICE_PLAN_ID, // Fallback if separate Project ID isn't used whatsappSenderId: process.env.SINCH_WHATSAPP_SENDER_ID, webhookSecret: process.env.SINCH_WEBHOOK_SECRET, // Ensure the region (us, eu, etc.) in the URL matches your account's region // Sinch operates in multiple regions. Check your account settings for the correct region. // Available regions: us, eu. Default shown below is 'us'. apiUrl: `https://us.conversation.api.sinch.com/v1/projects/${process.env.SINCH_PROJECT_ID || process.env.SINCH_SERVICE_PLAN_ID}`, }));Inject this configuration into your
SinchService.2.3. Implement
SinchService:This service contains the logic to call the Sinch API.
// src/sinch/sinch.service.ts import { Injectable, Logger, Inject } from '@nestjs/common'; import { ConfigType } from '@nestjs/config'; import axios, { AxiosInstance, AxiosError } from 'axios'; // Import AxiosError import * as crypto from 'crypto'; // Use ES6 import for crypto import sinchConfig from '../config/sinch.config'; // Import the typed config import { SinchSendMessageResponseDto } from './dto/sinch-send-response.dto'; // Import response DTO @Injectable() export class SinchService { private readonly logger = new Logger(SinchService.name); private readonly httpClient: AxiosInstance; constructor( @Inject(sinchConfig.KEY) // Inject the typed config private config: ConfigType<typeof sinchConfig>, ) { // Crucial check for essential configuration if (!config.projectId || !config.apiToken || !config.whatsappSenderId || !config.webhookSecret) { this.logger.error('Sinch configuration incomplete. Check .env file for SINCH_PROJECT_ID, SINCH_API_TOKEN, SINCH_WHATSAPP_SENDER_ID, and SINCH_WEBHOOK_SECRET.'); throw new Error('Sinch Service configuration is incomplete. Please check environment variables.'); } this.httpClient = axios.create({ baseURL: config.apiUrl, headers: { Authorization: `Bearer ${config.apiToken}`, 'Content-Type': 'application/json', }, }); } /** * Sends a WhatsApp text message via the Sinch Conversation API. * @param recipientPhoneNumber - The recipient's phone number in E.164 format (e.g., +15551234567). * @param textContent - The text message content. * @returns A promise resolving to the structured Sinch API response. */ async sendWhatsAppTextMessage( recipientPhoneNumber: string, textContent: string, ): Promise<SinchSendMessageResponseDto> { // Use specific Response DTO const endpoint = '/messages:send'; // Payload structure based on common Sinch Conversation API usage for WhatsApp. // **Important Note:** Verify this payload structure against the latest official // Sinch Conversation API documentation before using in production. // Specifically, confirm the usage of `app_id` vs. sender ID and `contact_id` for recipients. const payload = { app_id: this.config.whatsappSenderId, // Using sender ID as app_id reference for WhatsApp recipient: { contact_id: recipientPhoneNumber, // Use phone number directly for WhatsApp via contact_id }, message: { text_message: { text: textContent, }, }, channel_priority_order: ['WHATSAPP'], // Ensure it uses WhatsApp channel }; this.logger.log(`Sending WhatsApp message to ${recipientPhoneNumber} via app_id ${this.config.whatsappSenderId}`); try { const response = await this.httpClient.post<SinchSendMessageResponseDto>(endpoint, payload); this.logger.log(`Message sent successfully. Message ID: ${response.data?.message_id}`); return response.data; } catch (error) { const axiosError = error as AxiosError; // Type assertion for better error handling const errorDetails = axiosError.response?.data || axiosError.message; this.logger.error( `Failed to send WhatsApp message to ${recipientPhoneNumber}. Status: ${axiosError.response?.status}. Details: ${JSON.stringify(errorDetails)}`, axiosError.stack // Include stack trace for better debugging ); // Re-throw or handle specific Sinch errors (consider using a custom exception class) throw error; // Re-throw the original error to be handled by the controller } } // Add methods for other message types (templates, media) as needed // based on Sinch Conversation API documentation. // Example: sendTemplateMessage, sendMediaMessage etc. <!-- GAP: Missing implementation examples for template messages and media messages (Type: Substantive, Priority: High) --> /** * Verifies the signature of an incoming webhook request from Sinch. * * Sinch webhook signature verification algorithm: * 1. Sinch sends headers: x-sinch-signature (format: "t=<timestamp>,<base64_signature>") and x-sinch-timestamp * 2. Construct signed payload: "<timestamp>.<raw_request_body_utf8>" * 3. Compute HMAC-SHA256 of signed payload using webhook secret * 4. Encode result as Base64 * 5. Compare with signature from header using timing-safe comparison * * Reference: https://developers.sinch.com/docs/conversation/keyconcepts/#callback-signature-verification * * @param rawBody - The raw request body buffer. * @param signatureHeader - The value of the 'x-sinch-signature' header. * @param timestampHeader - The value of the 'x-sinch-timestamp' header. * @returns True if the signature is valid, false otherwise. */ verifyWebhookSignature( rawBody: Buffer, signatureHeader: string | undefined, timestampHeader: string | undefined, ): boolean { if (!signatureHeader || !timestampHeader || !this.config.webhookSecret) { this.logger.warn('Webhook verification failed: Missing signature, timestamp, or webhook secret.'); return false; } try { const hmac = crypto.createHmac('sha256', this.config.webhookSecret); const [signatureTimestamp, receivedSignature] = signatureHeader.split(','); // Validate the timestamp format in the header part if (!signatureTimestamp || !receivedSignature || signatureTimestamp !== `t=${timestampHeader}`) { this.logger.warn(`Webhook verification failed: Malformed or mismatched timestamp in signature header. Header: "${signatureHeader}", Timestamp: "${timestampHeader}"`); return false; } // Construct the payload string exactly as Sinch signs it: timestamp + "." + rawBody const signedPayload = `${timestampHeader}.${rawBody.toString('utf8')}`; const expectedSignature = hmac.update(signedPayload).digest('base64'); // Sinch uses Base64 encoding for the signature // Use timingSafeEqual for security against timing attacks const receivedSigBuffer = Buffer.from(receivedSignature, 'base64'); const expectedSigBuffer = Buffer.from(expectedSignature, 'base64'); // Ensure buffers are valid and have the same length before comparing if (receivedSigBuffer.length === expectedSigBuffer.length && receivedSigBuffer.length > 0 && // Ensure non-empty buffers crypto.timingSafeEqual(receivedSigBuffer, expectedSigBuffer)) { this.logger.debug('Webhook signature verified successfully.'); return true; } else { this.logger.warn(`Webhook verification failed: Invalid signature. Received: "${receivedSignature}", Expected: "${expectedSignature}" (Base64)`); return false; } } catch (error) { this.logger.error('Error during webhook signature verification:', error); return false; } } }2.4. Update
SinchModule:Register the service and the configuration.
// src/sinch/sinch.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; // Import ConfigModule import { SinchService } from './sinch.service'; import sinchConfig from '../config/sinch.config'; // Import the config loader @Module({ imports: [ ConfigModule.forFeature(sinchConfig), // Register the specific config ], providers: [SinchService], exports: [SinchService], // Export if needed in other modules }) export class SinchModule {}2.5. Import
SinchModuleintoAppModule:// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { SinchModule } from './sinch/sinch.module'; // Import SinchModule // import { WebhookModule } from './webhook/webhook.module'; // Import WebhookModule later @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env', }), SinchModule, // Add SinchModule here // WebhookModule will be added later ], controllers: [AppController], providers: [AppService], }) export class AppModule {}3. Building the API Layer (Example: Sending a Message)
Expose an endpoint to trigger sending a message.
3.1. Create DTOs for Sending Messages and Responses:
Define the expected request body structure and validation rules. Also, define a basic DTO for the Sinch API response.
// src/sinch/dto/send-message.dto.ts import { IsNotEmpty, IsPhoneNumber, IsString, MaxLength } from 'class-validator'; export class SendMessageDto { @IsNotEmpty({ message: 'Recipient phone number should not be empty.' }) @IsPhoneNumber(null, { message: 'Recipient phone number must be a valid E.164 format phone number (e.g., +15551234567).' }) // Use null for international format validation readonly recipientPhoneNumber: string; @IsNotEmpty({ message: 'Message text should not be empty.' }) @IsString() @MaxLength(4096, { message: 'WhatsApp message text cannot exceed 4096 characters per message.' }) // WhatsApp Business API limit per message readonly messageText: string; }// src/sinch/dto/sinch-send-response.dto.ts // Basic DTO for the expected successful response from Sinch /messages:send // Flesh this out based on the actual fields returned by Sinch API export class SinchSendMessageResponseDto { message_id: string; accepted_time?: string; // Example optional field // Add other fields as documented by Sinch }3.2. Create a Controller (e.g., add to
AppControlleror create a dedicatedMessageController):// src/app.controller.ts (Example adding to AppController) import { Controller, Get, Post, Body, UsePipes, ValidationPipe, HttpCode, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { AppService } from './app.service'; import { SinchService } from './sinch/sinch.service'; // Import SinchService import { SendMessageDto } from './sinch/dto/send-message.dto'; // Import Request DTO import { AxiosError } from 'axios'; @Controller() export class AppController { private readonly logger = new Logger(AppController.name); // Add logger constructor( private readonly appService: AppService, private readonly sinchService: SinchService, // Inject SinchService ) {} @Get() getHello(): string { return this.appService.getHello(); } @Post('send-whatsapp') @HttpCode(HttpStatus.ACCEPTED) // Return 202 Accepted on success @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) // Enable validation async sendWhatsAppMessage(@Body() sendMessageDto: SendMessageDto) { try { const result = await this.sinchService.sendWhatsAppTextMessage( sendMessageDto.recipientPhoneNumber, sendMessageDto.messageText, ); // Return only essential info, like the message ID return { success: true, messageId: result.message_id }; } catch (error) { // Handle potential errors from SinchService (e.g., Axios errors) if (error instanceof AxiosError && error.response) { // Log the detailed error internally but return a generic message externally this.logger.error(`Sinch API Error: Status ${error.response.status}, Data: ${JSON.stringify(error.response.data)}`); throw new HttpException( `Failed to send message via Sinch: ${error.response.data?.message || error.response.statusText || 'API error'}`, error.response.status || HttpStatus.BAD_GATEWAY ); } else if (error instanceof Error) { this.logger.error(`Internal Error: ${error.message}`, error.stack); throw new HttpException(`Internal server error: ${error.message}`, HttpStatus.INTERNAL_SERVER_ERROR); } else { this.logger.error('Unknown Error sending message', error); throw new HttpException('An unexpected error occurred', HttpStatus.INTERNAL_SERVER_ERROR); } } } }Explanation:
SinchServiceis injected.POST /send-whatsappis defined.@UsePipes(new ValidationPipe(...))validates the request body againstSendMessageDto.forbidNonWhitelisted: truerejects requests with extra fields.sinchService.sendWhatsAppTextMessage.202 Acceptedwith themessageIdon success.AxiosErrorto provide more context from Sinch API failures, and throws appropriateHttpExceptioninstances which NestJS handles.3.3. Testing the Endpoint:
Run your NestJS application:
Use
curlor Postman. Usingcurl(note the use of single quotes around the JSON data for shell safety):curl -X POST http://localhost:3000/send-whatsapp \ -H 'Content-Type: application/json' \ -d '{ "recipientPhoneNumber": "+15551234567", "messageText": "Hello from NestJS via Sinch!" }'Expected Response (Success – Status Code 202):
{ "success": true, "messageId": "01HXXXX..." }Expected Response (Validation Error – Status Code 400):
{ "statusCode": 400, "message": [ "Recipient phone number must be a valid E.164 format phone number (e.g., +15551234567)." ], "error": "Bad Request" }Expected Response (Sinch API Error – Status Code e.g., 400, 401, 503):
The response will reflect the
HttpExceptionthrown in the controller, e.g.:{ "statusCode": 400, "message": "Failed to send message via Sinch: Invalid recipient phone number" }4. Receiving WhatsApp Messages: Webhook Integration
Sinch notifies your NestJS application about incoming WhatsApp messages and delivery status updates via webhooks. Create a secure endpoint to receive and process these real-time notifications.
4.1. Configure Webhook in Sinch Dashboard:
https://your-public-domain.com/webhook/sinchor your ngrok URL likehttps://<your-id>.ngrok.io/webhook/sinch). This URL MUST use HTTPS..envfile (SINCH_WEBHOOK_SECRET). This is used for signature verification. Ensure there are no typos or extra spaces.MESSAGE_INBOUND,MESSAGE_DELIVERY). Start withMESSAGE_INBOUND.Screenshot Example (Conceptual – Actual UI may vary):
4.2. Create Webhook Module, Controller, and Service:
4.3. Enable Raw Body Parsing (Via Middleware):
Webhook signature verification requires the raw, unparsed request body. Apply
body-parser's raw body middleware specifically to the webhook route. This is configured in theWebhookModule(see section 4.4).Modify
src/main.tsto ensure global pipes or other global body parsers don't interfere, although selective middleware is preferred:// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { Logger } from '@nestjs/common'; // Removed unused ValidationPipe import import { ConfigService } from '@nestjs/config'; // Import NestExpressApplication if you need specific Express types, otherwise not strictly needed // import { NestExpressApplication } from '@nestjs/platform-express'; async function bootstrap() { // No need for { rawBody: true } here when using selective middleware const app = await NestFactory.create(AppModule); // const app = await NestFactory.create<NestExpressApplication>(AppModule); // Use if needing Express types const configService = app.get(ConfigService); const port = configService.get<number>('PORT', 3000); const logger = new Logger('Bootstrap'); // Global pipes can be applied here if needed for other routes // app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); await app.listen(port); logger.log(`Application is running on: ${await app.getUrl()}`); } bootstrap();4.4. Create Webhook Verification Guard & Configure Middleware:
This guard checks the
x-sinch-signatureheader.// src/webhook/guards/sinch-webhook.guard.ts import { Injectable, CanActivate, ExecutionContext, Logger, RawBodyRequest, BadRequestException, ForbiddenException } from '@nestjs/common'; import { Observable } from 'rxjs'; import { Request } from 'express'; // Assuming Express adapter import { SinchService } from '../../sinch/sinch.service'; @Injectable() export class SinchWebhookGuard implements CanActivate { private readonly logger = new Logger(SinchWebhookGuard.name); constructor(private readonly sinchService: SinchService) {} canActivate( context: ExecutionContext, ): boolean | Promise<boolean> | Observable<boolean> { const request = context.switchToHttp().getRequest<RawBodyRequest<Request>>(); // Get request with potential rawBody // Access the raw body buffer attached by the middleware const rawBody = request.rawBody; if (!rawBody) { // This should not happen if the middleware is configured correctly this.logger.error('Webhook Guard Error: Raw body not available. Ensure raw body parsing middleware is applied correctly to the webhook route.'); // Throw an exception to signal a server configuration issue throw new BadRequestException('Server configuration error: Raw body not available for webhook verification.'); } const signature = request.headers['x-sinch-signature'] as string | undefined; const timestamp = request.headers['x-sinch-timestamp'] as string | undefined; this.logger.debug(`Verifying webhook signature. Timestamp: ${timestamp}, Signature Header: ${signature ? signature.substring(0, 10) + '...' : 'MISSING'}`); // Avoid logging full signature const isValid = this.sinchService.verifyWebhookSignature( rawBody, signature, timestamp, ); if (!isValid) { this.logger.warn(`Invalid webhook signature received from IP: ${request.ip}. Blocking request.`); // Throw ForbiddenException to clearly indicate an authentication/authorization failure throw new ForbiddenException('Invalid webhook signature.'); } this.logger.log('Webhook signature verified successfully.'); return true; // Allow request to proceed to the controller } }Middleware Configuration (Important!): Apply the raw body parser middleware in
WebhookModule.Modify
src/webhook/webhook.module.ts:// src/webhook/webhook.module.ts import { Module, MiddlewareConsumer, NestModule, RequestMethod } from '@nestjs/common'; import { WebhookController } from './webhook.controller'; import { WebhookService } from './webhook.service'; import { SinchModule } from '../sinch/sinch.module'; // Import SinchModule to use SinchService import * as bodyParser from 'body-parser'; // Import body-parser @Module({ imports: [SinchModule], // Import SinchModule to inject SinchService into the Guard controllers: [WebhookController], providers: [WebhookService], // Guard is provided where used (@UseGuards) }) export class WebhookModule implements NestModule { // Implement NestModule configure(consumer: MiddlewareConsumer) { consumer // Apply raw body parser *only* for the Sinch webhook POST route .apply(bodyParser.raw({ type: 'application/json' })) // Ensure this matches the Content-Type Sinch sends .forRoutes({ path: 'webhook/sinch', method: RequestMethod.POST }); } }Import
WebhookModuleintoAppModule:// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { SinchModule } from './sinch/sinch.module'; import { WebhookModule } from './webhook/webhook.module'; // Import WebhookModule // MongooseModule if used @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env', }), SinchModule, WebhookModule, // Add WebhookModule here // MongooseModule.forRootAsync(...) // Example if using DB ], controllers: [AppController], providers: [AppService], }) export class AppModule {}4.5. Define DTO for Incoming Webhooks:
Map the structure of Sinch's webhook payloads. This DTO handles multiple event types using the
triggerfield. Note: The original provided DTO was incomplete; this version provides a more structured example forMESSAGE_INBOUND.// src/webhook/dto/sinch-webhook.dto.ts import { Type } from 'class-transformer'; import { IsString, IsNotEmpty, ValidateNested, IsOptional, IsEnum, Allow, IsObject } from 'class-validator'; // --- Nested DTOs (Simplify or expand based on needs and Sinch Docs) --- class ContactIdentifier { @IsOptional() @IsString() phone?: string; @IsOptional() @IsString() user_id?: string; @IsOptional() @IsString() whatsapp_id?: string; // Common for WhatsApp } class RecipientInfo { // Use @Allow() if validation isn't strictly needed or structure varies @Allow() contact_id?: ContactIdentifier | string; // Can be object or just ID string sometimes @IsOptional() @IsString() identified_by?: string; // Alternative way Sinch might identify } class SenderInfo { @Allow() contact_id?: ContactIdentifier | string; @IsOptional() @IsString() user_display_name?: string; } class TextMessageContent { @IsNotEmpty() @IsString() text: string; } // Add other content types as needed (MediaMessageContent, ChoiceMessageContent, etc.) // class MediaMessageContent { ... } class MessageContent { @IsOptional() @ValidateNested() @Type(() => TextMessageContent) text_message?: TextMessageContent; // Add other message types here // @IsOptional() @ValidateNested() @Type(() => MediaMessageContent) // media_message?: MediaMessageContent; } class MessageMetadata { @IsOptional() @IsString() locale?: string; // Add other metadata fields } // --- Payload for MESSAGE_INBOUND --- // This structure might vary slightly based on Sinch API version and specific event. // Always refer to the official Sinch documentation for the exact payload structure. class InboundMessagePayload { @IsNotEmpty() @IsString() id: string; // Sinch message ID @IsOptional() @IsString() direction?: 'INBOUND'; // Should be INBOUND @IsNotEmpty() @ValidateNested() @Type(() => RecipientInfo) recipient: RecipientInfo; // Your App/Sender ID info @IsNotEmpty() @ValidateNested() @Type(() => SenderInfo) sender: SenderInfo; // The end-user info @IsNotEmpty() @ValidateNested() @Type(() => MessageContent) message: MessageContent; @IsNotEmpty() @IsString() @IsEnum(['WHATSAPP', 'SMS', 'MESSENGER'], { message: 'Channel must be a valid Sinch channel' }) channel: string; // Expecting 'WHATSAPP' mostly @IsNotEmpty() @IsString() // Consider @IsISO8601() for stricter validation timestamp: string; // ISO 8601 timestamp @IsOptional() @ValidateNested() @Type(() => MessageMetadata) metadata?: MessageMetadata; @IsOptional() @IsString() app_id?: string; // The Sinch App ID @IsOptional() @IsString() conversation_id?: string; @IsOptional() @IsString() contact_id?: string; // Might be present at top level too @IsOptional() @IsString() processing_mode?: string; // e.g., 'CONVERSATION' } // Add other payload types like DeliveryReportPayload, ContactCreatePayload etc. // --- Main Webhook DTO --- // This DTO uses the 'trigger' field to determine which payload type to expect. // You might need a more sophisticated approach (e.g., custom validation) if triggers aren't always present // or if payloads share common base fields. For simplicity, we assume distinct payloads per trigger. export class SinchWebhookDto { @IsNotEmpty() @IsString() trigger: string; // e.g., 'MESSAGE_INBOUND', 'MESSAGE_DELIVERY' @IsNotEmpty() @IsString() app_id: string; // Sinch App ID @IsNotEmpty() @IsString() // Consider @IsISO8601() accepted_time: string; // When Sinch accepted the event // Use @ValidateNested based on the trigger type. // This basic example assumes only MESSAGE_INBOUND for simplicity. // In a real app, you'd likely have a union type or conditional validation. @IsOptional() @ValidateNested() @Type(() => InboundMessagePayload) message_inbound?: InboundMessagePayload; // Add other potential payloads based on triggers // @IsOptional() @ValidateNested() @Type(() => DeliveryReportPayload) // message_delivery?: DeliveryReportPayload; // Allow other fields that might be present but not strictly validated [key: string]: any; }4.6. Implement Webhook Controller:
4.7. Implement Webhook Service:
5. Database Integration (Optional)
6. Testing Strategy
7. Security Best Practices
8. Deployment Guide
9. Monitoring and Observability
10. Troubleshooting Common Issues
Frequently Asked Questions
How do I authenticate with the Sinch WhatsApp API in NestJS?
Authenticate using Bearer token authentication. Store your
SINCH_API_TOKENin your.envfile and include it in theAuthorizationheader asBearer YOUR_TOKENfor all API requests. The Sinch API Token is available in your Sinch Dashboard under APIs > API Credentials. Use@nestjs/configto inject the token securely into your services and create an axios instance with the Authorization header configured globally.What phone number format does Sinch require for WhatsApp messaging?
Sinch requires E.164 format for all phone numbers:
+[country code][number]with no spaces, dashes, or parentheses. For example, a US number would be+14155551234, not(415) 555-1234or415-555-1234. This international standard ensures proper routing across global telecommunications networks. Use the@IsPhoneNumber()validator fromclass-validatorin your DTOs to enforce this format. Learn more about E.164 phone number formatting.How do I verify webhook signatures from Sinch?
Sinch uses HMAC-SHA256 signature verification with Base64 encoding. Extract the
x-sinch-signatureheader (format:t=<timestamp>,<base64_signature>) andx-sinch-timestampheader. Construct the signed payload as<timestamp>.<raw_request_body_utf8>, compute HMAC-SHA256 using your webhook secret, encode as Base64, and compare usingcrypto.timingSafeEqual()to prevent timing attacks. Applybody-parser.raw()middleware specifically to webhook routes to preserve the raw body. See official documentation at https://developers.sinch.com/docs/conversation/keyconcepts/#callback-signature-verification.How do I set up NestJS modules for Sinch integration?
Create separate modules for concerns: a
SinchModulefor API interaction (services for sending messages), aWebhookModulefor receiving callbacks (controller with guards for signature verification), and optionally aDatabaseModulefor persisting message history. Use@nestjs/configwithConfigModule.forFeature()to inject typed configuration into each module. ExportSinchServicefromSinchModuleso other modules can inject it, particularly theWebhookGuardfor signature verification.What is the WhatsApp message length limit through Sinch?
WhatsApp Business API allows up to 4,096 characters per text message through Sinch Conversation API. Use
@MaxLength(4096)validator in yourSendMessageDtoto enforce this limit. Messages exceeding this length will be rejected by the API. For longer content, consider breaking it into multiple messages or using media attachments. Template messages may have different character limits depending on the template configuration.How do I handle errors from the Sinch API in NestJS?
Wrap Sinch API calls in try-catch blocks and check for
AxiosErrorinstances to extract detailed error information. Accesserror.response.statusfor HTTP status codes anderror.response.datafor Sinch-specific error details. Throw appropriateHttpExceptioninstances with meaningful messages (e.g.,BadRequestException,UnauthorizedException,BadGatewayException). Log detailed errors internally using NestJS Logger but return generic messages to clients to avoid exposing sensitive information.How can I test WhatsApp webhooks locally in NestJS?
Yes, use ngrok to create a secure tunnel to your local NestJS development server. Install ngrok (
npm install -g ngrok), runngrok http 3000(or your port), and use the generated HTTPS URL (e.g.,https://abc123.ngrok.io/webhook/sinch) in your Sinch Dashboard webhook configuration. Ensure you configure the same webhook secret in both your.envfile and Sinch Dashboard. ngrok provides a web interface athttp://localhost:4040to inspect webhook payloads, which is invaluable for debugging.What environment variables are required for production deployment?
For production, configure these required environment variables:
SINCH_SERVICE_PLAN_ID,SINCH_API_TOKEN,SINCH_PROJECT_ID,SINCH_WHATSAPP_SENDER_ID(E.164 format), andSINCH_WEBHOOK_SECRET. Never commit.envfiles to version control. Use your hosting provider's environment variable configuration (e.g., AWS Systems Manager, Heroku Config Vars, Vercel Environment Variables). Ensure theapiUrlin your Sinch config matches your account's region (us or eu). Verify all credentials are correctly loaded on startup using validation in your service constructor.