code examples
code examples
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.
Integrate Sinch WhatsApp API with Node.js & NestJS
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.
System Architecture:
<!-- EXPAND: Could add sequence diagram showing complete request/response flow including webhook callbacks, error scenarios, and retry logic (Type: Enhancement, Priority: Medium) -->+-----------------+ +-------------------+ +-----------------+ +-------------------+
| |----->| |----->| |----->| |
| Your Application| | NestJS Backend | | Sinch Platform | | WhatsApp User |
| (e.g., Frontend)| | (This Guide) | | (Conversation API)|<-----| |
| |<-----| |<-----| |<-----| |
+-----------------+ +-------------------+ +-----------------+ +-------------------+
| (API Call to Send) ^ | (API Call) ^
| | (Webhook Post) | |
+-----------------------------+ +--------------------------+
(Receive Message) (Send/Receive)(Note: This ASCII diagram illustrates the flow. For final documentation, consider generating a visual diagram using tools like MermaidJS or other diagramming software.)
Prerequisites:
<!-- GAP: Missing detailed instructions for WhatsApp Business Account setup and approval process through Sinch (Type: Critical, Priority: High) -->- Node.js (LTS version recommended, v18+ for best compatibility) and npm/yarn installed.
- NestJS CLI installed (
npm install -g @nestjs/cli). - A Sinch account with access to the Conversation API (sign up at https://www.sinch.com/). For more on SMS integration options, see our phone number formatting guide.
- 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:
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 .env file – a security best practice.
Create a .env file in the project root:
# .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/databaseHow to Obtain Sinch Credentials:
- Log in to your Sinch Customer Dashboard at https://dashboard.sinch.com/.
- 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:
// 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.
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.tsCreate these folders (sinch, webhook, config, etc.) within the src directory.
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:
nest generate module sinch
nest generate service sinch2.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 SinchModule into AppModule:
// 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 AppController or create a dedicated MessageController):
// 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:
- Injection:
SinchServiceis injected. - Endpoint:
POST /send-whatsappis defined. - Validation:
@UsePipes(new ValidationPipe(...))validates the request body againstSendMessageDto.forbidNonWhitelisted: truerejects requests with extra fields. - Logic: Calls
sinchService.sendWhatsAppTextMessage. - Response: Returns
202 Acceptedwith themessageIdon success. - Error Handling: Catches errors, specifically checks for
AxiosErrorto provide more context from Sinch API failures, and throws appropriateHttpExceptioninstances which NestJS handles.
3.3. Testing the Endpoint:
Run your NestJS application:
npm run start:devUse curl or Postman. Using curl (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 HttpException thrown 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:
- 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/sinchor your ngrok URL likehttps://<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
.envfile (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 withMESSAGE_INBOUND. - Save the configuration.
Screenshot Example (Conceptual – Actual UI may vary):
+-------------------------------------------------+
| Sinch App Configuration |
+-------------------------------------------------+
| App Name: My WhatsApp Service |
| App ID: <your_app_id> |
| |
| Webhook Settings: |
| Callback URL: [ https://<your_domain>/webhook/sinch ] | (Must be HTTPS)
| Webhook Secret: [ *************************** ] | <--- Paste your exact secret here
| |
| Subscribed Events: |
| [x] MESSAGE_INBOUND |
| [x] MESSAGE_DELIVERY |
| [ ] CONTACT_CREATE |
| ... |
| |
| [ Save Changes ] |
+-------------------------------------------------+4.2. Create Webhook Module, Controller, and Service:
nest generate module webhook
nest generate controller webhook
nest generate service webhook4.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 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:
// 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-signature header.
// 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 WebhookModule into AppModule:
// 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 trigger field. Note: The original provided DTO was incomplete; this version provides a more structured example for MESSAGE_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:
<!-- GAP: Complete WebhookController implementation missing - should show POST endpoint with guard, DTO validation, and service delegation (Type: Critical, Priority: High) -->4.7. Implement Webhook Service:
<!-- GAP: WebhookService implementation missing - should show event processing logic, message persistence, and response handling (Type: Critical, Priority: High) -->5. Database Integration (Optional)
<!-- GAP: Missing complete database schema implementation and MessagePersistenceService code (Type: Substantive, Priority: Medium) --> <!-- DEPTH: Section needs explanation of why/when to persist messages and data retention considerations (Type: Substantive, Priority: Low) -->6. Testing Strategy
<!-- GAP: Missing unit test examples for SinchService and WebhookService (Type: Substantive, Priority: Medium) --> <!-- GAP: Missing integration test examples for webhook signature verification (Type: Substantive, Priority: Medium) --> <!-- GAP: Missing e2e test examples for complete message flow (Type: Enhancement, Priority: Low) -->7. Security Best Practices
<!-- GAP: Missing comprehensive security section covering API key rotation, secret management, input sanitization, and webhook IP whitelisting (Type: Critical, Priority: High) -->8. Deployment Guide
<!-- GAP: Missing Docker/docker-compose configuration example (Type: Substantive, Priority: Medium) --> <!-- GAP: Missing production deployment checklist (environment variables, SSL certificates, webhook URL configuration, monitoring setup) (Type: Substantive, Priority: High) --> <!-- DEPTH: Section needs discussion of scalability considerations, load balancing, and horizontal scaling (Type: Enhancement, Priority: Low) -->9. Monitoring and Observability
<!-- GAP: Missing logging strategy and structured logging examples (Type: Substantive, Priority: Medium) --> <!-- GAP: Missing metrics collection and monitoring setup (message success rates, latency, error rates) (Type: Substantive, Priority: Medium) --> <!-- GAP: Missing alerting recommendations for critical failures (Type: Substantive, Priority: Low) -->10. Troubleshooting Common Issues
<!-- GAP: Missing troubleshooting section with common errors and solutions (Type: Critical, Priority: High) --> <!-- DEPTH: Should include solutions for: webhook signature failures, authentication errors, message delivery failures, rate limit errors, and webhook timeout issues (Type: Critical, Priority: High) -->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.