code examples
code examples
MessageBird WhatsApp API Integration with NestJS: Complete Tutorial (2025)
A guide on setting up a NestJS application to send and receive WhatsApp messages via the MessageBird Conversations API, including project setup, sending messages, and handling webhooks.
MessageBird WhatsApp Integration with Node.js and NestJS: Complete Tutorial
Build a production-ready WhatsApp Business API integration for your Node.js application using NestJS and the MessageBird Conversations API. This complete tutorial covers project setup, webhook handling, sending messages (text and media), implementing secure authentication, and adapting to 2025 WhatsApp Business API changes.
Important Note (2025): MessageBird rebranded to Bird.com in February 2024. However, the legacy APIs, SDKs (including the Node.js messagebird package), and developer documentation still use the MessageBird branding. This guide uses "MessageBird" when referring to the SDK and APIs, but the company now operates as Bird.com.
By completing this NestJS WhatsApp integration tutorial, you'll have a functional application that sends and receives WhatsApp messages programmatically – perfect for building chatbots, automated notification systems, or customer support tools. You'll need basic familiarity with Node.js, TypeScript, and NestJS concepts.
What You'll Build with This WhatsApp API Integration
Create a NestJS backend service that:
- Exposes an API endpoint to send outbound WhatsApp messages (text and media)
- Receives inbound WhatsApp messages and status updates via MessageBird webhooks
- Securely handles API keys and webhook requests
- (Optional) Persists message history in a database
Why This Matters:
Leverage WhatsApp's ubiquity to communicate with users in their preferred messaging app. Automate interactions, send timely notifications, and provide support without requiring users to install a separate application.
Technologies:
- Node.js: JavaScript runtime environment
- NestJS: Progressive Node.js framework for building efficient, scalable server-side applications with TypeScript. Features modular architecture, dependency injection, and built-in validation and configuration support
- TypeScript: JavaScript superset adding static types for improved code quality and maintainability
- MessageBird (now Bird.com): Cloud communications platform with APIs for multiple channels, including WhatsApp. You'll use their official Node.js SDK and Conversations API
- MessageBird WhatsApp Sandbox: Development environment for testing WhatsApp integration without a fully approved WhatsApp Business Account
- (Optional) Prisma: Next-generation ORM for Node.js and TypeScript, used for database interaction if you need message persistence
- (Optional) PostgreSQL/SQLite: Relational database for storing message data
System Architecture:
(A sequence diagram illustrating the flow between Client, NestJS App, MessageBird API, and WhatsApp was intended here.)
Prerequisites:
- Node.js LTS version (v18.x, v20.x, or v22.x as of 2025) and npm/yarn. Note: NestJS 10 and 11 require Node.js v16 or higher
- MessageBird Account (sign up at https://messagebird.com/ or https://bird.com/)
- Access to the MessageBird WhatsApp Sandbox OR a provisioned WhatsApp Business channel
- API testing tool (Postman or
curl) - (Optional) Database instance (e.g., PostgreSQL) for persistence
- (Optional)
ngrokor similar tool to expose your local development server for webhook testing
SDK Version Notice: The messagebird npm package (v4.0.1) was last updated approximately 3 years ago (as of 2025). While functional for basic use cases, it may not receive regular updates or support for newer WhatsApp Business API features. Monitor the official Bird.com developer documentation for migration paths or new SDK releases.
1. Setting Up Your NestJS WhatsApp Project
Initialize your NestJS project and install the required dependencies for WhatsApp API integration.
Install NestJS CLI:
If you haven't installed it globally:
npm install -g @nestjs/cli
# or
yarn global add @nestjs/cliCreate Your NestJS Project:
nest new nestjs-whatsapp-messagebird
cd nestjs-whatsapp-messagebirdChoose your preferred package manager (npm or yarn) when prompted.
Install Required Dependencies:
Install the MessageBird SDK and NestJS configuration module:
npm install messagebird @nestjs/config class-validator class-transformer
# or
yarn add messagebird @nestjs/config class-validator class-transformermessagebird: Official Node.js SDK for the MessageBird API@nestjs/config: Securely manages environment variablesclass-validator&class-transformer: Validate incoming request data (DTOs)
Configure Environment Variables:
Create a .env file in your project root to store sensitive credentials. Never commit this file to version control. Ensure .env is in your .gitignore.
# .env
# MessageBird Credentials
MESSAGEBIRD_API_KEY=YOUR_MESSAGEBIRD_LIVE_OR_TEST_API_KEY
MESSAGEBIRD_WHATSAPP_CHANNEL_ID=YOUR_WHATSAPP_CHANNEL_ID_FROM_MESSAGEBIRD
# Webhook Security
MESSAGEBIRD_WEBHOOK_SIGNING_KEY=YOUR_GENERATED_WEBHOOK_SIGNING_KEY
WEBHOOK_BASE_URL=http://localhost:3000 # Replace with ngrok URL or deployed URLObtain Your MessageBird API Credentials:
-
MESSAGEBIRD_API_KEY:- Log in to your MessageBird Dashboard (accessible via https://dashboard.messagebird.com/ or through Bird.com)
- Navigate to Developers > API access
- Click "Add access key." Choose "Live" or "Test" mode. Copy the generated key. Note: Test keys work with the Sandbox but won't send real messages
-
MESSAGEBIRD_WHATSAPP_CHANNEL_ID:- Navigate to Channels > WhatsApp
- For Sandbox: Find the "WhatsApp Sandbox" entry. The Channel ID is listed there
- For provisioned channel: Find your channel name. The Channel ID is listed there
-
MESSAGEBIRD_WEBHOOK_SIGNING_KEY:- Navigate to Developers > Webhook Signing
- Click "Generate new signing key." Copy the generated key
-
WEBHOOK_BASE_URL: Set the public URL where MessageBird sends webhook events. For local development, usengrok:- Install ngrok: https://ngrok.com/download
- Run:
ngrok http 3000(assuming your NestJS app runs on port 3000) - Copy the
httpsforwarding URL from ngrok (e.g.,https://abcdef123456.ngrok.io). Use this as your base URL - Important: Update the webhook configuration in MessageBird whenever your ngrok URL changes. Ngrok provides temporary URLs for local development and testing only. For staging or production, use a stable, publicly accessible HTTPS endpoint on your server
Load Environment Variables:
Import and configure ConfigModule in your main application module (src/app.module.ts) to make environment variables accessible throughout your application.
// 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 later (e.g., WhatsappModule, MessageBirdModule, PrismaModule)
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Make ConfigService available globally
envFilePath: '.env',
}),
// Add other modules here later
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}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 (if needed beyond .env)
├── core/ # Core services like MessageBird client wrapper, Prisma
│ ├── messagebird/
│ │ ├── messagebird.module.ts
│ │ └── messagebird.service.ts
│ └── prisma/ # (Optional) If using Prisma
│ ├── prisma.module.ts
│ └── prisma.service.ts
├── modules/ # Feature modules
│ ├── whatsapp/
│ │ ├── dto/
│ │ │ └── send-whatsapp-message.dto.ts
│ │ ├── whatsapp.controller.ts
│ │ ├── whatsapp.module.ts
│ │ └── whatsapp.service.ts
│ └── webhooks/
│ ├── dto/
│ │ └── messagebird-webhook.dto.ts # Defines expected payload structure
│ ├── webhooks.controller.ts
│ └── webhooks.module.ts
├── common/ # Shared utilities, decorators, middleware etc.
│ └── middleware/
│ └── messagebird-verify.middleware.ts # For signature verification
prisma/ # (Optional) Prisma schema and migrations
└── schema.prisma
# ... other configuration files (.env, .gitignore, tsconfig.json, etc.)
This structure promotes modularity and makes the application easier to maintain and scale.
2. Configure the MessageBird Client Service
Let's create a dedicated service to initialize and provide the MessageBird client instance.
1. Create the Module and Service:
nest generate module core/messagebird --flat
nest generate service core/messagebird --flat2. Implement the Service:
This service will inject NestJS's ConfigService to retrieve the API key and initialize the messagebird client.
// src/core/messagebird/messagebird.service.ts
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as MessageBird from 'messagebird'; // Import the SDK
@Injectable()
export class MessageBirdService implements OnModuleInit {
private readonly logger = new Logger(MessageBirdService.name);
private client: MessageBird.MessageBird; // Type for the client instance
constructor(private configService: ConfigService) {}
onModuleInit() {
const apiKey = this.configService.get<string>('MESSAGEBIRD_API_KEY');
if (!apiKey) {
this.logger.error('MESSAGEBIRD_API_KEY is not defined in environment variables.');
throw new Error('MessageBird API Key is required.');
}
// Initialize the client
this.client = MessageBird(apiKey);
this.logger.log('MessageBird client initialized successfully.');
}
// Method to get the initialized client
getClient(): MessageBird.MessageBird {
if (!this.client) {
// This should ideally not happen due to OnModuleInit, but defensive check
this.logger.error('MessageBird client requested before initialization.');
throw new Error('MessageBird client not initialized.');
}
return this.client;
}
}3. Create the Module:
Export the MessageBirdService so other modules can use it.
// src/core/messagebird/messagebird.module.ts
import { Module, Global } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; // Ensure ConfigModule is available
import { MessageBirdService } from './messagebird.service';
@Global() // Make MessageBirdService available globally without importing MessageBirdModule everywhere
@Module({
imports: [ConfigModule], // Import ConfigModule if not already global in AppModule
providers: [MessageBirdService],
exports: [MessageBirdService],
})
export class MessageBirdModule {}4. Import MessageBirdModule in 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 { MessageBirdModule } from './core/messagebird/messagebird.module'; // Import
// Import other modules later
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
MessageBirdModule, // Add MessageBirdModule
// Add other modules here later (WhatsappModule, WebhooksModule, PrismaModule)
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}Now, the MessageBirdService can be injected into any other service or controller in our application.
3. How to Send WhatsApp Messages with NestJS
We'll create a dedicated module (WhatsappModule) with a controller and service to handle sending WhatsApp messages.
1. Create Module, Controller, Service, and DTO:
nest generate module modules/whatsapp
nest generate controller modules/whatsapp
nest generate service modules/whatsapp
mkdir -p src/modules/whatsapp/dto
touch src/modules/whatsapp/dto/send-whatsapp-message.dto.ts2. Define the Request DTO:
Create a Data Transfer Object (DTO) to define the expected structure and validation rules for the request body when sending a message.
// src/modules/whatsapp/dto/send-whatsapp-message.dto.ts
import { IsNotEmpty, IsString, IsPhoneNumber, IsOptional, IsUrl } from 'class-validator';
export class SendWhatsappMessageDto {
@IsNotEmpty()
@IsPhoneNumber(null) // Use null for generic phone number validation, adjust region if needed
@IsString()
readonly to: string; // Recipient's WhatsApp number (E.164 format recommended)
@IsNotEmpty()
@IsString()
readonly text: string; // Message content (required even if sending media with caption)
// Optional fields for media messages
@IsOptional()
@IsUrl()
readonly imageUrl?: string;
@IsOptional()
@IsUrl()
readonly videoUrl?: string;
@IsOptional()
@IsUrl()
readonly audioUrl?: string;
@IsOptional()
@IsUrl()
readonly fileUrl?: string;
@IsOptional()
@IsString()
readonly caption?: string; // Caption for media
}3. Implement the WhatsApp Service:
Inject MessageBirdService and ConfigService to interact with the API.
Note: The specific types imported from messagebird/types/conversations can change between SDK versions. Ensure these types are compatible with your installed messagebird package version.
// src/modules/whatsapp/whatsapp.service.ts
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MessageBirdService } from '../../core/messagebird/messagebird.service';
import { SendWhatsappMessageDto } from './dto/send-whatsapp-message.dto';
import {
StartConversationParameter,
Content,
TextContent,
ImageContent,
VideoContent,
AudioContent,
FileContent,
} from 'messagebird/types/conversations'; // Import necessary types
// Optional: Import PrismaService if using DB
// import { PrismaService } from '../../core/prisma/prisma.service';
@Injectable()
export class WhatsappService {
private readonly logger = new Logger(WhatsappService.name);
private readonly channelId: string;
constructor(
private readonly messageBirdService: MessageBirdService,
private readonly configService: ConfigService,
// Optional: Inject PrismaService if storing messages
// private readonly prisma: PrismaService,
) {
this.channelId = this.configService.get<string>('MESSAGEBIRD_WHATSAPP_CHANNEL_ID');
if (!this.channelId) {
this.logger.error('MESSAGEBIRD_WHATSAPP_CHANNEL_ID is not defined.');
throw new Error('WhatsApp Channel ID is required.');
}
}
async sendMessage(dto: SendWhatsappMessageDto): Promise<any> {
const client = this.messageBirdService.getClient();
let content: Content;
// Determine content type based on DTO fields
if (dto.imageUrl) {
content = { image: { url: dto.imageUrl, caption: dto.caption } } as ImageContent;
} else if (dto.videoUrl) {
content = { video: { url: dto.videoUrl, caption: dto.caption } } as VideoContent;
} else if (dto.audioUrl) {
content = { audio: { url: dto.audioUrl } } as AudioContent; // Captions may not be supported on all audio types
} else if (dto.fileUrl) {
content = { file: { url: dto.fileUrl, caption: dto.caption } } as FileContent;
} else {
content = { text: dto.text } as TextContent;
}
const params: StartConversationParameter = {
to: dto.to,
channelId: this.channelId,
type: this.getContentType(content), // Dynamically set type based on content
content: content,
// reportUrl: Optional: Override global webhook URL for status updates for this specific message
};
this.logger.log(`Attempting to send message via channel ${this.channelId} to ${dto.to}. Payload: ${JSON.stringify(params)}`);
try {
// Use conversations.start - this works for initiating or replying
// IMPORTANT: WhatsApp 24-Hour Window Policy
// - Within 24 hours of a user message: You can send any message type freely
// - Outside 24-hour window: You MUST use pre-approved Message Templates (HSM)
// - As of April 1, 2025: Utility templates sent within the 24-hour window are FREE
// - As of April 1, 2025: Marketing templates to US phone numbers are temporarily paused
// - As of October 7, 2025: WhatsApp messaging limits are changing - check Meta's documentation
const result = await client.conversations.start(params);
this.logger.log(`Message accepted by MessageBird. ConversationID: ${result.id}, MessageID: ${result.messages?.lastMessageId}`);
// Optional: Store outgoing message in DB
// await this.storeOutgoingMessage(result, params);
return result;
} catch (error) {
const statusCode = error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR;
const errorData = error.response?.data || error.message;
this.logger.error(`MessageBird API Error (${statusCode}) for recipient ${dto.to}: ${JSON.stringify(errorData)}`, error.stack);
// Map MessageBird errors to NestJS HttpExceptions if desired
if (statusCode === 400 || statusCode === 422) { // Example: Bad Request or Unprocessable Entity
throw new HttpException({
message: 'Failed to send message due to invalid data or number.',
details: errorData
}, HttpStatus.BAD_REQUEST);
}
// Throw a generic error for others
throw new HttpException('Failed to send WhatsApp message.', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
// Helper to determine the content type string
private getContentType(content: Content): string {
if ('text' in content) return 'text';
if ('image' in content) return 'image';
if ('video' in content) return 'video';
if ('audio' in content) return 'audio';
if ('file' in content) return 'file';
// Add other types like 'location', 'hsm' if needed
return 'text'; // Default fallback
}
// Optional: Method to store outgoing message
// private async storeOutgoingMessage(result: any, params: StartConversationParameter): Promise<void> {
// if (!this.prisma) return;
// try {
// // ... Prisma upsert/create logic ...
// this.logger.log(`Outgoing message ${result.messages.lastMessageId} stored in DB.`);
// } catch (dbError) {
// this.logger.error(`Database error storing outgoing message ${result.messages.lastMessageId}:`, dbError);
// }
// }
}- Why
conversations.start? This API call is versatile. It can initiate a new conversation (if one doesn't exist or is archived) or send a message to an existing active conversation. For initiating conversations outside WhatsApp's 24-hour window, you'll need HSM templates.conversations.replyrequires an existingconversationId.
4. Implement the WhatsApp Controller:
Expose a POST endpoint to trigger the sendMessage service method. Use ValidationPipe to automatically validate incoming DTOs.
// src/modules/whatsapp/whatsapp.controller.ts
import { Controller, Post, Body, UsePipes, ValidationPipe, HttpCode, HttpStatus } from '@nestjs/common';
import { WhatsappService } from './whatsapp.service';
import { SendWhatsappMessageDto } from './dto/send-whatsapp-message.dto';
@Controller('whatsapp') // Route prefix: /whatsapp
export class WhatsappController {
constructor(private readonly whatsappService: WhatsappService) {}
@Post('send') // Endpoint: POST /whatsapp/send
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) // Enable validation
@HttpCode(HttpStatus.ACCEPTED) // Return 202 Accepted as sending is async
async sendMessage(@Body() sendWhatsappMessageDto: SendWhatsappMessageDto) {
try {
const result = await this.whatsappService.sendMessage(sendWhatsappMessageDto);
// Return a simplified response or the full MessageBird response
return {
message: 'Message accepted for delivery.',
details: {
conversationId: result.id, // Conversation ID
messageId: result.messages?.lastMessageId // ID of the newly sent message
}
};
} catch (error) {
// Error is logged in the service, NestJS will handle the HTTP response
// based on the error type (e.g., 500 for unexpected, 400 for validation/API error)
throw error; // Re-throw for NestJS default exception filter or custom filter
}
}
}5. Register WhatsappModule in 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 { MessageBirdModule } from './core/messagebird/messagebird.module';
import { WhatsappModule } from './modules/whatsapp/whatsapp.module'; // Import
// Import WebhooksModule and PrismaModule later if used
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
MessageBirdModule,
WhatsappModule, // Add WhatsappModule
// Add WebhooksModule later
// Add PrismaModule later (if using DB)
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}6. Testing the WhatsApp API Endpoint:
Start your NestJS application:
npm run start:dev
# or
yarn start:devUse curl or Postman to send a request to POST http://localhost:3000/whatsapp/send (replace port if needed).
Example curl (Text Message):
Remember to use a number registered with your WhatsApp Sandbox for testing initially.
curl -X POST http://localhost:3000/whatsapp/send \
-H "Content-Type: application/json" \
-d '{
"to": "+12345678900",
"text": "Hello from NestJS and MessageBird!"
}'Example curl (Image Message):
curl -X POST http://localhost:3000/whatsapp/send \
-H "Content-Type: application/json" \
-d '{
"to": "+12345678900",
"text": "Check out this logo",
"imageUrl": "https://www.messagebird.com/assets/images/og/messagebird.png",
"caption": "MessageBird Logo"
}'Expected Response (Success - 202 Accepted):
{
"message": "Message accepted for delivery.",
"details": {
"conversationId": "conv_id_from_messagebird_xxxxxxxx",
"messageId": "msg_id_from_messagebird_yyyyyyyy"
}
}You should receive the WhatsApp message on the target device shortly after. Check your application logs for details from WhatsappService.
4. Receiving WhatsApp Messages via Webhooks
MessageBird uses webhooks to notify your application about incoming messages (message.created) and status updates for outgoing messages (message.updated).
1. Configure Webhook in MessageBird Dashboard:
- Go to your MessageBird Dashboard > Channels > WhatsApp > Select your Channel (e.g., Sandbox).
- Find the 'Webhooks' or 'Conversation Webhooks' section.
- Click 'Add webhook'.
- URL: Enter your public webhook URL. Using the
.envvariable:${WEBHOOK_BASE_URL}/webhooks/messagebird. For local dev with ngrok, this would be likehttps://abcdef123456.ngrok.io/webhooks/messagebird. Must be HTTPS. - Events: Select at least
message.createdandmessage.updated. You might also wantconversation.created,conversation.updated. - Signing Key: Ensure your generated signing key (from step 1.4) is active.
- Save the webhook.
2. Create Webhooks Module, Controller, and DTO:
nest generate module modules/webhooks
nest generate controller modules/webhooks
mkdir -p src/modules/webhooks/dto
touch src/modules/webhooks/dto/messagebird-webhook.dto.tsDefine Webhook Payload DTO:
Create a DTO to provide basic type safety for the incoming webhook payload.
// src/modules/webhooks/dto/messagebird-webhook.dto.ts
import { Type } from 'class-transformer';
import { IsString, IsOptional, IsObject, ValidateNested, IsDefined } from 'class-validator';
// Basic nested structures - can be expanded significantly based on MessageBird docs
class MessageContentDto {
@IsOptional() @IsString() text?: string;
@IsOptional() @IsObject() image?: { url: string; caption?: string };
@IsOptional() @IsObject() video?: { url: string; caption?: string };
@IsOptional() @IsObject() audio?: { url: string };
@IsOptional() @IsObject() file?: { url: string; caption?: string };
// Add other content types as needed (location, hsm, etc.)
}
class MessagePayloadDto {
@IsOptional() @IsString() id?: string; // Message ID
@IsOptional() @IsString() conversationId?: string;
@IsOptional() @IsString() channelId?: string;
@IsOptional() @IsString() from?: string; // Sender phone number for incoming
@IsOptional() @IsString() to?: string; // Recipient for outgoing status updates
@IsOptional() @IsString() direction?: 'incoming' | 'outgoing';
@IsOptional() @IsString() status?: string; // 'pending', 'sent', 'delivered', 'read', 'failed'
@IsOptional() @IsString() type?: string; // 'text', 'image', 'video', 'audio', 'file', 'hsm', 'location' etc.
@IsOptional() @ValidateNested() @Type(() => MessageContentDto) content?: MessageContentDto;
@IsOptional() @IsString() createdDatetime?: string; // ISO 8601 timestamp
@IsOptional() @IsString() updatedDatetime?: string; // ISO 8601 timestamp
@IsOptional() @IsObject() error?: { code?: number; description?: string }; // For 'failed' status
}
class ConversationPayloadDto {
@IsOptional() @IsString() id?: string; // Conversation ID
@IsOptional() @IsString() contactId?: string;
@IsOptional() @IsString() status?: 'active' | 'archived';
@IsOptional() @IsString() createdDatetime?: string; // ISO 8601 timestamp
@IsOptional() @IsString() updatedDatetime?: string; // ISO 8601 timestamp
// Add other conversation fields if needed
}
export class MessageBirdWebhookPayloadDto {
// The top-level structure can vary, ensure validation handles optional fields
@IsOptional() @ValidateNested() @Type(() => ConversationPayloadDto) conversation?: ConversationPayloadDto;
@IsOptional() @ValidateNested() @Type(() => MessagePayloadDto) message?: MessagePayloadDto;
// 'type' is usually the key indicator of the event
@IsDefined() // Type should always be present
@IsString()
type: 'message.created' | 'message.updated' | 'conversation.created' | 'conversation.updated' | string; // Allow other types but specify common ones
// Add other potential top-level fields if necessary (e.g., contact details)
}3. Implement Webhook Signature Verification (Security):
We need to verify that incoming requests genuinely originate from MessageBird. We'll create a middleware for this.
mkdir -p src/common/middleware
touch src/common/middleware/messagebird-verify.middleware.ts// src/common/middleware/messagebird-verify.middleware.ts
import { Injectable, NestMiddleware, Logger, ForbiddenException, RawBodyRequest } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
@Injectable()
export class MessageBirdVerifyMiddleware implements NestMiddleware {
private readonly logger = new Logger(MessageBirdVerifyMiddleware.name);
private readonly signingKey: string;
constructor(private configService: ConfigService) {
this.signingKey = this.configService.get<string>('MESSAGEBIRD_WEBHOOK_SIGNING_KEY');
if (!this.signingKey) {
// IMPORTANT: Verification MUST be enabled in production.
// Consider throwing an error here during startup if the key is missing in a 'production' environment.
this.logger.error(
'MESSAGEBIRD_WEBHOOK_SIGNING_KEY is not defined. ' +
'Webhook verification will be DISABLED. This is INSECURE for production!'
);
}
}
use(req: RawBodyRequest<Request>, res: Response, next: NextFunction) {
if (!this.signingKey) {
// Skip verification if key is missing (development only!)
this.logger.warn('CRITICAL: Skipping MessageBird webhook verification as signing key is not configured. THIS IS INSECURE AND MUST NOT HAPPEN IN PRODUCTION.');
return next();
}
const signature = req.headers['messagebird-signature'] as string;
const timestamp = req.headers['messagebird-request-timestamp'] as string;
// Requires rawBody to be enabled in main.ts
const requestBody = req.rawBody;
if (!signature || !timestamp || !requestBody) {
this.logger.warn('Missing MessageBird signature headers or body for verification.');
throw new ForbiddenException('MessageBird signature verification failed: Missing headers or body.');
}
try {
const hmac = crypto.createHmac('sha256', this.signingKey);
// Use utf8 encoding for the body when creating the signature base string
const signedContent = `${timestamp}.${requestBody.toString('utf8')}`;
const expectedSignature = hmac.update(signedContent).digest('hex');
// Use timingSafeEqual for security against timing attacks
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
this.logger.log('MessageBird webhook signature verified successfully.');
next();
} else {
this.logger.warn(`Webhook signature mismatch. Expected: ${expectedSignature}, Received: ${signature}`);
throw new ForbiddenException('MessageBird signature verification failed: Invalid signature.');
}
} catch (error) {
this.logger.error('Error during webhook signature verification:', error);
throw new ForbiddenException(`MessageBird signature verification failed: ${error.message}`);
}
}
}RawBodyRequest: This middleware relies on accessing the raw request body before it's parsed as JSON. We need to enable this inmain.ts.
4. Enable Raw Body Parsing:
Modify src/main.ts to enable rawBody.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common'; // Import ValidationPipe
import { NestExpressApplication } from '@nestjs/platform-express'; // Import NestExpressApplication
async function bootstrap() {
// Specify NestExpressApplication to access underlying Express features
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
rawBody: true, // <<< Enable raw body parsing for signature verification
});
// Global Validation Pipe (applied after rawBody parsing, good for other routes)
// Note: Webhook validation might need specific pipe settings (see controller)
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true, // Automatically transform payloads to DTO instances
}));
// Enable CORS if your client is on a different origin
app.enableCors();
const port = process.env.PORT || 3000;
await app.listen(port);
console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();5. Implement the Webhooks Controller:
Create an endpoint to receive POST requests from MessageBird. Use the DTO and potentially a validation pipe.
// src/modules/webhooks/webhooks.controller.ts
import { Controller, Post, Body, Headers, Logger, HttpCode, HttpStatus, UsePipes, ValidationPipe, Req } from '@nestjs/common';
import { Request } from 'express';
import { MessageBirdWebhookPayloadDto } from './dto/messagebird-webhook.dto';
// Optional: Import PrismaService if handling DB logic here or in a dedicated service
// import { PrismaService } from '../../core/prisma/prisma.service';
@Controller('webhooks') // Route prefix: /webhooks
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
// Optional: Inject PrismaService or a dedicated processing service
constructor(
// private readonly prismaService: PrismaService
) {}
@Post('messagebird') // Endpoint: POST /webhooks/messagebird
@HttpCode(HttpStatus.OK) // Acknowledge receipt successfully
// Apply validation pipe, skip missing properties as payload structure varies
@UsePipes(new ValidationPipe({ skipMissingProperties: true, whitelist: true }))
handleMessageBirdWebhook(
@Body() payload: MessageBirdWebhookPayloadDto,
@Headers('messagebird-signature') signature: string, // For logging/debugging if needed
@Headers('messagebird-request-timestamp') timestamp: string, // For logging/debugging
@Req() req: Request // Access raw request if needed, though middleware handles verification
): void { // Return void or a simple confirmation
this.logger.log(`Received MessageBird webhook. Type: ${payload.type}, Signature: ${signature ? 'Present' : 'Missing'}, Timestamp: ${timestamp}`);
this.logger.debug(`Webhook Payload: ${JSON.stringify(payload)}`);
// Process based on event type
switch (payload.type) {
case 'message.created':
if (payload.message?.direction === 'incoming') {
this.logger.log(`Incoming message received from ${payload.message.from}. Content: ${JSON.stringify(payload.message.content)}`);
// TODO: Implement logic to handle incoming message
// - Parse payload.message.content (text, image, etc.)
// - Potentially reply using WhatsappService
// - Store in DB
// Example: if (payload.message.content?.text === 'hello') { /* send reply */ }
} else {
this.logger.log(`Webhook for outgoing message creation (ID: ${payload.message?.id}). Usually handled by message.updated.`);
}
break;
case 'message.updated':
if (payload.message?.direction === 'outgoing') {
this.logger.log(`Status update for outgoing message ${payload.message.id} to ${payload.message.to}: ${payload.message.status}`);
// TODO: Implement logic to handle status update
// - Update message status in DB
// - Trigger notifications based on status (e.g., 'delivered', 'read', 'failed')
if (payload.message.status === 'failed') {
this.logger.error(`Message ${payload.message.id} failed: ${JSON.stringify(payload.message.error)}`);
// Handle failure (retry logic, alert admin, etc.)
}
}
break;
case 'conversation.created':
this.logger.log(`New conversation started: ${payload.conversation?.id}`);
// Optional: Handle conversation creation event
break;
case 'conversation.updated':
this.logger.log(`Conversation ${payload.conversation?.id} updated. Status: ${payload.conversation?.status}`);
// Optional: Handle conversation status changes (e.g., archived)
break;
default:
this.logger.warn(`Received unhandled webhook event type: ${payload.type}`);
}
// Acknowledge receipt - NestJS handles sending the 200 OK response implicitly
// No explicit return needed unless sending back data (not typical for webhooks)
}
}6. Apply Middleware and Register Module:
Apply the MessageBirdVerifyMiddleware specifically to the webhook route in the WebhooksModule. Then, register WebhooksModule in AppModule.
// src/modules/webhooks/webhooks.module.ts
import { Module, MiddlewareConsumer, NestModule, RequestMethod } from '@nestjs/common';
import { WebhooksController } from './webhooks.controller';
import { MessageBirdVerifyMiddleware } from '../../common/middleware/messagebird-verify.middleware';
// Import ConfigModule if not global
// import { ConfigModule } from '@nestjs/config';
// Import PrismaModule if needed for DB operations
// import { PrismaModule } from '../../core/prisma/prisma.module';
@Module({
imports: [
// ConfigModule, // Only if not global
// PrismaModule, // If handling DB logic here or in a service
],
controllers: [WebhooksController],
providers: [
// Add any specific services for webhook processing if needed
],
})
export class WebhooksModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(MessageBirdVerifyMiddleware)
.forRoutes({ path: 'webhooks/messagebird', method: RequestMethod.POST });
}
}// 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 { MessageBirdModule } from './core/messagebird/messagebird.module';
import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
import { WebhooksModule } from './modules/webhooks/webhooks.module'; // Import
// Import PrismaModule later if used
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
MessageBirdModule,
WhatsappModule,
WebhooksModule, // Add WebhooksModule
// Add PrismaModule later (if using DB)
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}7. Testing Webhooks:
- Ensure your NestJS app is running (
npm run start:dev). - Ensure
ngrokis running and forwarding to your app's port (ngrok http 3000). - Ensure the
httpsngrok URL is configured as the webhook URL in MessageBird for your WhatsApp channel, pointing to/webhooks/messagebird. - Test Incoming Message: Send a message from your registered Sandbox phone number to the Sandbox WhatsApp number provided by MessageBird. You should see logs in your NestJS console from
WebhooksControllerindicating amessage.createdevent. - Test Status Update: Send an outgoing message using the
/whatsapp/sendendpoint (from Step 3). As the message progresses (sent, delivered, read), you should see logs in your NestJS console formessage.updatedevents.
Check the ngrok web interface (http://localhost:4040 by default) to inspect incoming requests and responses, which is helpful for debugging. Ensure you see 200 OK responses from your webhook endpoint.
Related Resources
For more WhatsApp Business API integrations and guides, explore these related tutorials:
- Twilio WhatsApp Integration with NestJS - Alternative provider implementation
- Infobip WhatsApp Integration with NestJS - Production-ready integration guide
- Node.js WhatsApp Chatbot Development - Build conversational interfaces