This guide provides a step-by-step walkthrough for integrating the Sinch Conversation API for WhatsApp messaging into a NestJS application. We'll build a backend service capable of sending WhatsApp messages via Sinch and receiving incoming messages through webhooks.
This implementation enables businesses to leverage NestJS's robust framework for building 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:
+-----------------+ +-------------------+ +-----------------+ +-------------------+
| |----->| |----->| |----->| |
| 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:
- Node.js (LTS version recommended) and npm/yarn installed.
- NestJS CLI installed (
npm install -g @nestjs/cli
). - A Sinch account with access to the Conversation API.
- 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).
- (Optional) MongoDB instance (local or cloud like MongoDB Atlas).
- (Optional) Docker installed.
1. Project Setup and Configuration
Let's initialize our 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-integration
Choose your preferred package manager (npm or yarn).
1.2. Install Dependencies:
We need 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_ you can use NestJS's built-inHttpModule
).class-validator
&class-transformer
: Used 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:
Security best practice dictates storing sensitive credentials outside your codebase. We'll use a .env
file.
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/database
How to Obtain Sinch Credentials:
- Log in to your Sinch Customer Dashboard.
- Navigate to APIs > API Credentials. You should find your Service Plan ID and API Token. If you don't have one, you may need to create an API token. Ensure you 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). - Generate a strong, unique secret for
SINCH_WEBHOOK_SECRET
(e.g., using a password manager or online generator). You will need to provide this exact same secret to Sinch when configuring the webhook later. Treat this like a password.
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:
We'll organize our 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.ts
Create these folders (sinch
, webhook
, config
, etc.) within the src
directory.
2. Implementing Core Functionality: Sending Messages
Let's create a service to interact with the Sinch Conversation API for sending WhatsApp messages.
2.1. Create Sinch Module and Service:
Generate the module and service using the NestJS CLI:
nest generate module sinch
nest generate service sinch
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
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 will contain 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.
/**
* Verifies the signature of an incoming webhook request from Sinch.
* @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)
Let's 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(1600, { message: 'WhatsApp message text cannot exceed 1600 characters.' }) // WhatsApp message length limit
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:
SinchService
is injected. - Endpoint:
POST /send-whatsapp
is defined. - Validation:
@UsePipes(new ValidationPipe(...))
validates the request body againstSendMessageDto
.forbidNonWhitelisted: true
rejects requests with extra fields. - Logic: Calls
sinchService.sendWhatsAppTextMessage
. - Response: Returns
202 Accepted
with themessageId
on success. - Error Handling: Catches errors, specifically checks for
AxiosError
to provide more context from Sinch API failures, and throws appropriateHttpException
instances which NestJS handles.
3.3. Testing the Endpoint:
Run your NestJS application:
npm run start:dev
Use 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. Integrating Third-Party Services: Handling Incoming Webhooks
Sinch notifies your application about incoming messages or status updates via webhooks. We need an endpoint to receive and process these notifications securely.
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 we will create (e.g.,
https://your-public-domain.com/webhook/sinch
or 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
.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 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 webhook
4.3. Enable Raw Body Parsing (Via Middleware):
Webhook signature verification requires the raw_ unparsed request body. We will 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;
}