code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

How to Build WhatsApp Integration with Twilio, Node.js, and NestJS in 2025

Complete production-ready guide for integrating WhatsApp with NestJS and Twilio API. Includes webhooks, media messages, security, and deployment best practices.

This guide provides a comprehensive walkthrough for building a production-ready WhatsApp integration using Node.js, the NestJS framework, and the Twilio API. You'll cover everything from initial project setup to deployment and monitoring, enabling your application to send and receive WhatsApp messages, including media.

By following this tutorial, you'll create a NestJS application capable of handling incoming WhatsApp messages via webhooks, replying dynamically, and initiating outbound messages programmatically. This solves the common need for businesses to engage with customers on WhatsApp for notifications, support, and conversational interactions.

Technologies Used:

  • Node.js: The underlying JavaScript runtime environment (v22 LTS recommended for production, supported until April 2027).
  • NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications (v10+ recommended). Its modular architecture and use of TypeScript make it ideal for production systems.
  • Twilio API for WhatsApp: The third-party service providing the infrastructure to connect with the WhatsApp Business Platform.
  • TypeScript: Adds static typing to JavaScript (v5.0+ recommended), improving code quality and maintainability.
  • (Optional) Prisma/TypeORM: For database interactions to store message history or state.
  • (Optional) Docker: For containerizing the application for deployment.

Quick Reference

FeatureImplementation
FrameworkNestJS v10+ with TypeScript v5.0+
Node.js Versionv22 LTS (supported until April 2027)
API ServiceTwilio WhatsApp Business API
Phone FormatE.164: +[country code][number] (1–15 digits)
Messaging Window24 hours (templates required outside window)
Security MethodHMAC SHA1 webhook signature validation
Media SupportImages, videos, PDFs (caption limit: 1024 chars)
Sandbox vs ProductionSandbox for dev; Production needs Facebook Business Manager
Primary Use CasesCustomer support, notifications, two-way messaging
Deployment OptionsDocker, Heroku, AWS, DigitalOcean, Google Cloud

Prerequisites:

  • Install Node.js v22 LTS (or v20 minimum) and npm/yarn.
  • Create a Twilio account with an activated WhatsApp Sandbox (for development) or a registered WhatsApp Business Sender (for production).
  • Understand TypeScript, NestJS concepts (modules, controllers, services), and REST APIs.
  • Install a tool to expose your local development server to the internet (e.g., ngrok).
  • Access a WhatsApp-enabled mobile device for testing.

Important WhatsApp Business API Limitations:

  • 24-Hour Messaging Window: You can only send outbound messages to users who have messaged you within the last 24 hours, unless using approved WhatsApp Message Templates.
  • Message Templates Required: For messages outside the 24-hour window, use pre-approved message templates from Twilio/WhatsApp.
  • Sandbox vs Production: The Twilio Sandbox is for development only and requires users to opt in with a "join" command. Production requires a Facebook Business Manager account, an approved WhatsApp Business Profile, and can take several weeks for approval.

System Architecture:

The basic flow involves:

  1. Outbound: Your NestJS application calls the Twilio API to send a message. Twilio delivers it to the user's WhatsApp.
  2. Inbound: A user sends a message to your Twilio WhatsApp number. Twilio sends an HTTP POST request (webhook) to a predefined endpoint in your NestJS application. Your application processes the request and can optionally send a TwiML response back to Twilio to dictate a reply.

This guide walks you through building the NestJS Application component and configuring its interaction with Twilio.

How Do You Set Up the NestJS Project?

Initialize a new NestJS project and install the necessary dependencies.

  1. Create a new NestJS project: Open your terminal and run the Nest CLI command:

    bash
    # Install Nest CLI globally if you haven't already
    npm i -g @nestjs/cli
    
    # Create new project (NestJS v10+ will be installed)
    nest new nestjs-whatsapp-twilio
    cd nestjs-whatsapp-twilio

    Choose your preferred package manager (npm or yarn) when prompted. This creates a standard NestJS project structure.

  2. Install Dependencies: Install the official Twilio helper library, a configuration module for environment variables, and a rate limiter for security.

    bash
    # Using npm (installs latest stable versions)
    npm install twilio @nestjs/config @nestjs/throttler
    
    # Or using yarn
    yarn add twilio @nestjs/config @nestjs/throttler
    • twilio: The official Node.js SDK for interacting with the Twilio API (v4.x or v5.x compatible with Node.js 18+).
    • @nestjs/config: For managing environment variables securely.
    • @nestjs/throttler: Adds rate limiting to your webhook endpoint, protecting against abuse.
  3. Configure Environment Variables: Create a .env file in the project root directory. Never commit this file to version control. Add .env to your .gitignore.

    dotenv
    # .env
    
    # Twilio Credentials – Find these in your Twilio Console
    # https://www.twilio.com/console
    TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    TWILIO_AUTH_TOKEN=your_auth_token_xxxxxxxxxxxxxx
    
    # Your Twilio WhatsApp Number (Sandbox or Registered Sender)
    # Must include the 'whatsapp:' prefix and be in E.164 format
    # Example Sandbox Number: whatsapp:+14155238886
    # E.164 format: +[country code][number] (no spaces, hyphens, or parentheses)
    TWILIO_WHATSAPP_NUMBER=whatsapp:+1xxxxxxxxxx
    
    # (REQUIRED FOR PRODUCTION) Auth token for securing your webhook endpoint
    # Generate a strong random string (minimum 32 characters recommended)
    WEBHOOK_AUTH_TOKEN=a_very_secure_random_string_min_32_chars
    
    # (Optional) Application Port
    PORT=3000
    • TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN: Found on your main Twilio Console dashboard. These authenticate your API requests.
    • TWILIO_WHATSAPP_NUMBER: The specific Twilio number enabled for WhatsApp (either your Sandbox number or a purchased/registered number). Find this in the Twilio Console under Messaging > Senders > WhatsApp Senders or the WhatsApp Sandbox settings. It must start with whatsapp: followed by the number in E.164 format (e.g., whatsapp:+14155238886).
    • WEBHOOK_AUTH_TOKEN: Critical for production security. A secret token you define, used to verify that incoming webhook requests genuinely come from Twilio. Use a cryptographically secure random string (minimum 32 characters). Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
    • PORT: The port your NestJS application will listen on.
  4. Load Environment Variables: Modify src/app.module.ts to import and configure ConfigModule.

    typescript
    // src/app.module.ts
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config'; // Import ConfigModule
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    // Import other modules here later (e.g., TwilioModule, WebhookModule)
    
    @Module({
      imports: [
        ConfigModule.forRoot({
          isGlobal: true, // Make ConfigService available globally
          envFilePath: '.env', // Specify the env file path
        }),
        // Add other modules here
      ],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}

    This setup makes environment variables accessible throughout your application via NestJS's ConfigService.

How Do You Implement Core Functionality?

Create a dedicated module and service for handling Twilio interactions and another module/controller for the webhook.

How Do You Build the Twilio Service?

This service encapsulates the logic for initializing the Twilio client and sending messages.

  1. Generate the Twilio module and service:

    bash
    nest generate module twilio
    nest generate service twilio
  2. Implement the TwilioService:

    typescript
    // src/twilio/twilio.service.ts
    import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import twilio, { Twilio } from 'twilio';
    
    @Injectable()
    export class TwilioService implements OnModuleInit {
      private readonly logger = new Logger(TwilioService.name);
      private client: Twilio;
      private twilioWhatsAppNumber: string;
    
      constructor(private readonly configService: ConfigService) {}
    
      onModuleInit() {
        const accountSid = this.configService.get<string>('TWILIO_ACCOUNT_SID');
        const authToken = this.configService.get<string>('TWILIO_AUTH_TOKEN');
        this.twilioWhatsAppNumber = this.configService.get<string>(
          'TWILIO_WHATSAPP_NUMBER',
        );
    
        if (!accountSid || !authToken || !this.twilioWhatsAppNumber) {
          this.logger.error(
            'Twilio credentials missing in environment variables.',
          );
          throw new Error(
            'Twilio credentials missing. Ensure TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_WHATSAPP_NUMBER are set.',
          );
        }
    
        // Validate E.164 format for WhatsApp number
        if (!this.twilioWhatsAppNumber.match(/^whatsapp:\+[1-9]\d{1,14}$/)) {
          throw new Error(
            'TWILIO_WHATSAPP_NUMBER must be in format whatsapp:+[country code][number] (E.164 format)',
          );
        }
    
        this.client = twilio(accountSid, authToken);
        this.logger.log('Twilio client initialized successfully.');
      }
    
      async sendWhatsAppMessage(to: string, body: string): Promise<string | null> {
        // Ensure 'to' number is in E.164 format with whatsapp: prefix
        const formattedTo = to.startsWith('whatsapp:') ? to : `whatsapp:${to}`;
        
        // Validate E.164 format for recipient
        if (!formattedTo.match(/^whatsapp:\+[1-9]\d{1,14}$/)) {
          this.logger.error(`Invalid recipient phone number format: ${to}. Must be E.164 format: +[country code][number]`);
          return null;
        }
    
        try {
          const message = await this.client.messages.create({
            from: this.twilioWhatsAppNumber,
            to: formattedTo,
            body: body,
          });
          this.logger.log(`WhatsApp message sent to ${formattedTo}: SID ${message.sid}`);
          return message.sid;
        } catch (error) {
          this.logger.error(`Failed to send WhatsApp message to ${formattedTo}: ${error.message}`);
          return null;
        }
      }
    
      async sendWhatsAppMediaMessage(to: string, mediaUrl: string, body?: string): Promise<string | null> {
        // Ensure 'to' number is in E.164 format with whatsapp: prefix
        const formattedTo = to.startsWith('whatsapp:') ? to : `whatsapp:${to}`;
        
        // Validate E.164 format for recipient
        if (!formattedTo.match(/^whatsapp:\+[1-9]\d{1,14}$/)) {
          this.logger.error(`Invalid recipient phone number format: ${to}. Must be E.164 format: +[country code][number]`);
          return null;
        }
    
        // WhatsApp supports captions with images (up to 1024 characters per WhatsApp API specs)
        const messageOptions: any = {
          from: this.twilioWhatsAppNumber,
          to: formattedTo,
          mediaUrl: [mediaUrl], // Must be an array
        };
    
        if (body) {
          // WhatsApp caption limit is 1024 characters
          if (body.length > 1024) {
            this.logger.warn(`Caption exceeds WhatsApp limit of 1024 characters. It may be truncated.`);
          }
          messageOptions.body = body;
        }
    
        try {
          const message = await this.client.messages.create(messageOptions);
          this.logger.log(`WhatsApp media message sent to ${formattedTo}: SID ${message.sid}`);
          return message.sid;
        } catch (error) {
          this.logger.error(`Failed to send WhatsApp media message to ${formattedTo}: ${error.message}`);
          return null;
        }
      }
    
      // Helper to get the initialized client if needed elsewhere
      getClient(): Twilio {
        return this.client;
      }
    
      // Helper to get the configured WhatsApp number
      getWhatsAppNumber(): string {
        return this.twilioWhatsAppNumber;
      }
    }
    • Uses OnModuleInit to initialize the client when the module loads.
    • Injects ConfigService to securely retrieve credentials from environment variables.
    • E.164 validation ensures both sender and recipient numbers follow the format: +[country code][number] (1–15 digits total, no spaces/hyphens).
    • Includes error handling for missing credentials and failed API calls.
    • Ensures the to number includes the whatsapp: prefix.
    • Requires media URLs as an array.
    • WhatsApp caption limit (1024 characters) is validated and logged.
    • Provides getWhatsAppNumber() helper.
  3. Update TwilioModule: Ensure TwilioService is provided and exported, and import ConfigModule.

    typescript
    // src/twilio/twilio.module.ts
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config';
    import { TwilioService } from './twilio.service';
    
    @Module({
      imports: [ConfigModule], // Import ConfigModule
      providers: [TwilioService],
      exports: [TwilioService], // Export service for use in other modules
    })
    export class TwilioModule {}
  4. Import TwilioModule into AppModule:

    typescript
    // 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 { TwilioModule } from './twilio/twilio.module'; // Import TwilioModule
    
    @Module({
      imports: [
        ConfigModule.forRoot({
          isGlobal: true,
          envFilePath: '.env',
        }),
        TwilioModule, // Add TwilioModule
      ],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}

How Do You Create the Webhook Controller?

This controller handles incoming messages from Twilio.

  1. Generate the Webhook module and controller:

    bash
    nest generate module webhook
    nest generate controller webhook
  2. Implement the WebhookController:

    typescript
    // src/webhook/webhook.controller.ts
    import { Controller, Post, Body, Res, Logger, Req, Headers, HttpCode, HttpStatus } from '@nestjs/common';
    import { Response, Request } from 'express';
    import { twiml } from 'twilio';
    import { ConfigService } from '@nestjs/config';
    import * as crypto from 'crypto';
    
    @Controller('webhooks')
    export class WebhookController {
      private readonly logger = new Logger(WebhookController.name);
      private readonly webhookAuthToken: string;
    
      constructor(private readonly configService: ConfigService) {
        this.webhookAuthToken = this.configService.get<string>('WEBHOOK_AUTH_TOKEN');
        if (!this.webhookAuthToken) {
          this.logger.warn(
            'SECURITY WARNING: WEBHOOK_AUTH_TOKEN is not set. Twilio signature validation will be skipped. ' +
            'This is UNSAFE for production! Generate a secure token with: ' +
            'node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
          );
        }
      }
    
      @Post('twilio/whatsapp')
      @HttpCode(HttpStatus.OK)
      async handleIncomingWhatsApp(
        @Body() body: any, // Twilio sends form-urlencoded data
        @Res() res: Response,
        @Req() req: Request,
        @Headers('X-Twilio-Signature') twilioSignature: string,
      ) {
    
        // CRITICAL SECURITY: Validate Twilio Signature
        // Prevents malicious actors from spoofing webhook requests
        // Reference: https://www.twilio.com/docs/usage/webhooks/webhooks-security
        if (this.webhookAuthToken && !this.validateTwilioRequest(req, twilioSignature)) {
          this.logger.warn('Invalid Twilio signature received. Rejecting request.');
          res.status(HttpStatus.FORBIDDEN).send('Invalid Twilio Signature');
          return;
        } else if (!this.webhookAuthToken && !twilioSignature) {
          this.logger.log('No WEBHOOK_AUTH_TOKEN set and no X-Twilio-Signature header received. Proceeding without validation (UNSAFE for production).');
        } else if (!this.webhookAuthToken && twilioSignature) {
          this.logger.warn('Received X-Twilio-Signature, but skipping validation because WEBHOOK_AUTH_TOKEN is not set (UNSAFE for production).');
        }
    
        const sender = body.From; // e.g., whatsapp:+15551234567
        const messageBody = body.Body;
        const numMedia = parseInt(body.NumMedia || '0', 10);
        const messageSid = body.MessageSid;
    
        this.logger.log(`Received WhatsApp message SID: ${messageSid} from ${sender}`);
        if (messageBody) {
          this.logger.log(`Message Body: ${messageBody}`);
        }
    
        // Handle Media
        if (numMedia > 0) {
          const mediaUrl = body.MediaUrl0; // WhatsApp typically sends one media item
          const mediaContentType = body.MediaContentType0;
          this.logger.log(`Received media (${mediaContentType}) from ${sender} at ${mediaUrl}`);
          // Add your media processing logic here if needed
        }
    
        // Generate TwiML Reply (Example: Echo Bot)
        const twimlResponse = new twiml.MessagingResponse();
    
        if (numMedia > 0) {
          twimlResponse.message(`Thanks for the media! We received ${numMedia} item(s).`);
        } else if (messageBody?.toLowerCase().trim() === 'hello') {
          twimlResponse.message(`Hi there! You said: ${messageBody}`);
        } else if (messageBody) {
          twimlResponse.message(`Echo: ${messageBody}`);
        } else {
          twimlResponse.message('Received your message.');
        }
    
        // Send Response
        res.setHeader('Content-Type', 'text/xml');
        res.send(twimlResponse.toString());
      }
    
      // Twilio Signature Validation Helper
      // Implements Twilio's webhook security validation
      // Reference: https://www.twilio.com/docs/usage/webhooks/webhooks-security
      private validateTwilioRequest(req: Request, twilioSignature: string): boolean {
        if (!this.webhookAuthToken) {
          this.logger.error('validateTwilioRequest called without webhookAuthToken being set.');
          return false;
        }
        if (!twilioSignature) {
          this.logger.warn('Validation failed: X-Twilio-Signature header missing.');
          return false;
        }
    
        // Construct the full URL Twilio used for the request
        // Use X-Forwarded-Proto if behind a proxy (common in production)
        const protocol = req.headers['x-forwarded-proto'] || req.protocol;
        const fullUrl = `${protocol}://${req.get('host')}${req.originalUrl}`;
    
        // Twilio calculates signature based on URL + sorted POST parameters
        // Reference: https://www.twilio.com/docs/usage/security#validating-requests
        const params = req.body || {};
        const sortedKeys = Object.keys(params).sort();
        const paramString = sortedKeys.reduce((acc, key) => acc + key + params[key], '');
    
        const expectedSignature = crypto
          .createHmac('sha1', this.webhookAuthToken)
          .update(Buffer.from(fullUrl + paramString, 'utf-8'))
          .digest('base64');
    
        // Compare signatures using timing-safe comparison (prevents timing attacks)
        let isValid = false;
        try {
          isValid = crypto.timingSafeEqual(
            Buffer.from(twilioSignature),
            Buffer.from(expectedSignature)
          );
        } catch (e) {
          this.logger.warn(`Error during timingSafeEqual comparison: ${e.message}`);
          isValid = false;
        }
    
        if (!isValid) {
          this.logger.warn(`Signature validation failed. Expected: ${expectedSignature}, Received: ${twilioSignature}`);
        }
    
        return isValid;
      }
    }
    • Listens for POST requests at /webhooks/twilio/whatsapp.
    • CRITICAL SECURITY: Implements Twilio signature validation using HMAC SHA1 per Twilio's webhook security documentation.
    • Uses crypto.timingSafeEqual to prevent timing attacks during signature comparison.
    • Logs explicit warnings if WEBHOOK_AUTH_TOKEN is not set (unsafe for production).
    • Parses standard Twilio webhook parameters.
    • Uses twilio.twiml.MessagingResponse to generate TwiML for replies.
    • Sets response Content-Type to text/xml.
  3. Update WebhookModule:

    typescript
    // src/webhook/webhook.module.ts
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config'; // Import ConfigModule
    import { WebhookController } from './webhook.controller';
    // Import PrismaModule if you inject PrismaService into the controller
    // import { PrismaModule } from '../prisma/prisma.module';
    
    @Module({
      imports: [
          ConfigModule, // Make ConfigService available
          // PrismaModule // Import if needed by controller/services
      ],
      controllers: [WebhookController],
      providers: [], // Add any webhook-specific services here if needed
    })
    export class WebhookModule {}
  4. Import WebhookModule into AppModule:

    typescript
    // 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 { TwilioModule } from './twilio/twilio.module';
    import { WebhookModule } from './webhook/webhook.module'; // Import WebhookModule
    // Other imports...
    // import { PrismaModule } from './prisma/prisma.module'; // Import if using Prisma
    // import { MessageModule } from './message/message.module'; // Import if using API layer
    
    @Module({
      imports: [
        ConfigModule.forRoot({
          isGlobal: true,
          envFilePath: '.env',
        }),
        TwilioModule,
        WebhookModule, // Add WebhookModule
        // PrismaModule, // Add if using Prisma
        // MessageModule, // Add if using API layer
        // Other modules...
      ],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}

How Do You Build a Complete API Layer (Optional)?

While the core functionality is driven by the webhook, you might want an API endpoint to trigger outbound messages from other parts of your system or external clients.

  1. Generate a Message module, controller, and service:

    bash
    nest generate module message
    nest generate controller message
    nest generate service message
  2. Create Data Transfer Objects (DTOs) for validation: Install class-validator and class-transformer:

    bash
    npm install class-validator class-transformer
    # or
    yarn add class-validator class-transformer

    Enable ValidationPipe globally in src/main.ts:

    typescript
    // src/main.ts
    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    import { ConfigService } from '@nestjs/config';
    import { Logger, ValidationPipe } from '@nestjs/common';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      const configService = app.get(ConfigService);
      const port = configService.get<number>('PORT', 3000);
      const logger = new Logger('Bootstrap');
    
      // Enable global validation pipe with strict settings
      app.useGlobalPipes(new ValidationPipe({
        whitelist: true, // Strip properties not defined in DTO
        transform: true, // Automatically transform payloads to DTO instances
        forbidNonWhitelisted: true, // Throw error if unknown properties are present
        transformOptions: {
          enableImplicitConversion: true, // Allow automatic type conversion
        },
      }));
    
      await app.listen(port);
      logger.log(`Application listening on port ${port}`);
    }
    bootstrap();

    Create DTO files:

    typescript
    // src/message/dto/send-message.dto.ts
    import { IsNotEmpty, IsString, Matches, IsOptional, IsUrl, ValidateIf } from 'class-validator';
    
    export class SendMessageDto {
      @IsNotEmpty()
      @IsString()
      // E.164 format: +[country code][number] (1–15 digits total, no spaces/hyphens)
      // Reference: ITU-T Recommendation E.164
      @Matches(/^\+?[1-9]\d{1,14}$/, { 
        message: 'Phone number must be in E.164 format: +[country code][number] (e.g., +15551234567)' 
      })
      to: string;
    
      @ValidateIf(o => !o.mediaUrl) // body required if mediaUrl not provided
      @IsNotEmpty({ message: 'Either body or mediaUrl must be provided' })
      @IsString()
      @IsOptional()
      body?: string;
    
      @ValidateIf(o => !o.body) // mediaUrl required if body not provided
      @IsNotEmpty({ message: 'Either body or mediaUrl must be provided' })
      @IsString()
      @IsUrl({}, { message: 'mediaUrl must be a valid URL' })
      @IsOptional()
      mediaUrl?: string;
    }
  3. Implement MessageService: This service uses TwilioService to send messages.

    typescript
    // src/message/message.service.ts
    import { Injectable, Logger, BadRequestException, InternalServerErrorException } from '@nestjs/common';
    import { TwilioService } from '../twilio/twilio.service';
    import { SendMessageDto } from './dto/send-message.dto';
    
    @Injectable()
    export class MessageService {
      private readonly logger = new Logger(MessageService.name);
    
      constructor(
        private readonly twilioService: TwilioService,
      ) {}
    
      async sendWhatsApp(sendMessageDto: SendMessageDto): Promise<{ sid: string }> {
        const { to, body, mediaUrl } = sendMessageDto;
    
        // DTO validation should prevent both being empty, but double-check for safety
        if (!body && !mediaUrl) {
          throw new BadRequestException('Either body or mediaUrl must be provided.');
        }
    
        let sid: string | null = null;
        let errorOccurred = false;
    
        try {
          if (mediaUrl) {
            // TwilioService adds the 'whatsapp:' prefix internally
            sid = await this.twilioService.sendWhatsAppMediaMessage(to, mediaUrl, body);
          } else if (body) {
            // TwilioService adds the 'whatsapp:' prefix internally
            sid = await this.twilioService.sendWhatsAppMessage(to, body);
          }
        } catch (error) {
          this.logger.error(`Error sending WhatsApp message to ${to}: ${error.message}`, error.stack);
          errorOccurred = true;
        }
    
        if (!sid || errorOccurred) {
          throw new InternalServerErrorException(`Failed to send WhatsApp message to ${to} via Twilio.`);
        }
    
        this.logger.log(`Successfully queued WhatsApp message to ${to}. SID: ${sid}`);
        return { sid };
      }
    }
  4. Implement MessageController:

    typescript
    // src/message/message.controller.ts
    import { Controller, Post, Body, Logger, HttpCode, HttpStatus } from '@nestjs/common';
    import { MessageService } from './message.service';
    import { SendMessageDto } from './dto/send-message.dto';
    
    @Controller('messages')
    export class MessageController {
      private readonly logger = new Logger(MessageController.name);
    
      constructor(private readonly messageService: MessageService) {}
    
      @Post('whatsapp/send')
      @HttpCode(HttpStatus.ACCEPTED) // Use 202 Accepted as sending is asynchronous
      async sendWhatsAppMessage(@Body() sendMessageDto: SendMessageDto): Promise<{ message: string; sid: string }> {
        this.logger.log(`Received request to send WhatsApp message to ${sendMessageDto.to}`);
        const result = await this.messageService.sendWhatsApp(sendMessageDto);
        return {
          message: 'WhatsApp message accepted for delivery.',
          sid: result.sid,
        };
      }
    }
    • Uses the DTO for request body validation.
    • Delegates sending logic to MessageService.
    • Consider adding authentication/authorization (@UseGuards) to protect this endpoint.
    • Returns 202 Accepted as the message is queued, not necessarily delivered instantly.
  5. Update Modules: Ensure MessageService is in providers and MessageController is in controllers within src/message/message.module.ts. Import TwilioModule into MessageModule to make TwilioService available for injection. Finally, import MessageModule into src/app.module.ts.

    typescript
    // src/message/message.module.ts
    import { Module } from '@nestjs/common';
    import { MessageService } from './message.service';
    import { MessageController } from './message.controller';
    import { TwilioModule } from '../twilio/twilio.module'; // Import TwilioModule
    // Import PrismaModule if MessageService uses it
    // import { PrismaModule } from '../prisma/prisma.module';
    // Import ConfigModule if MessageService uses it
    // import { ConfigModule } from '@nestjs/config';
    
    @Module({
      imports: [
          TwilioModule, // Make TwilioService available
          // PrismaModule // Import if needed
          // ConfigModule // Import if needed
      ],
      controllers: [MessageController],
      providers: [MessageService],
    })
    export class MessageModule {}
    
    // src/app.module.ts (ensure MessageModule is added)
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    import { TwilioModule } from './twilio/twilio.module';
    import { WebhookModule } from './webhook/webhook.module';
    import { MessageModule } from './message/message.module'; // Import MessageModule
    // import { PrismaModule } from './prisma/prisma.module'; // Import if using Prisma
    
    @Module({
        imports: [
            ConfigModule.forRoot({
              isGlobal: true,
              envFilePath: '.env',
            }),
            TwilioModule,
            WebhookModule,
            MessageModule, // Add MessageModule
            // PrismaModule, // Add if using Prisma
        ],
        controllers: [AppController],
        providers: [AppService],
    })
    export class AppModule {}

Testing the API Endpoint:

Test the /messages/whatsapp/send endpoint using curl or Postman (replace YOUR_PORT and YOUR_PHONE_NUMBER):

bash
# Send a text message
curl -X POST http://localhost:YOUR_PORT/messages/whatsapp/send \
  -H 'Content-Type: application/json' \
  -d '{
    "to": "+1YOUR_PHONE_NUMBER",
    "body": "Hello from NestJS API!"
  }'

# Send a media message (replace URL)
curl -X POST http://localhost:YOUR_PORT/messages/whatsapp/send \
  -H 'Content-Type: application/json' \
  -d '{
    "to": "+1YOUR_PHONE_NUMBER",
    "mediaUrl": "https://demo.twilio.com/owl.png",
    "body": "Optional caption for image"
  }'

Frequently Asked Questions

What is the WhatsApp 24-hour messaging window?

The 24-hour messaging window is a WhatsApp Business API policy that allows you to send free-form messages to users only within 24 hours after they last messaged you. Outside this window, you must use pre-approved WhatsApp Message Templates. This policy ensures businesses don't spam users and maintains WhatsApp's user experience standards.

How do I use WhatsApp Message Templates?

WhatsApp Message Templates are pre-approved message formats required for messaging outside the 24-hour window. You create templates in your Twilio Console under WhatsApp > Message Templates, submit them for WhatsApp approval (typically 24–48 hours), and then reference the approved template SID when sending messages via the Twilio API. Templates support variables for personalization.

What's the difference between Twilio Sandbox and Production?

The Twilio WhatsApp Sandbox is a development environment where users must opt in by sending a "join" command to your sandbox number. It's free but limited to testing. Production requires a Facebook Business Manager account, an approved WhatsApp Business Profile, and a registered phone number. Production setup can take several weeks for approval but allows unrestricted messaging to any WhatsApp user.

How do I validate E.164 phone numbers?

E.164 is the international phone number format: +[country code][number] with 1–15 digits total, no spaces, hyphens, or parentheses. Use the regex pattern /^\+?[1-9]\d{1,14}$/ to validate. For example, +15551234567 (US) and +447911123456 (UK) are valid. Always prepend whatsapp: when sending to Twilio (e.g., whatsapp:+15551234567).

What are WhatsApp caption limits for media messages?

WhatsApp allows captions of up to 1024 characters when sending media (images, videos, PDFs). If you exceed this limit, WhatsApp may truncate the caption. The TwilioService implementation in this guide includes validation that logs a warning when captions exceed 1024 characters, helping you catch issues during development.

How do I secure Twilio webhooks with signature validation?

Twilio sends an X-Twilio-Signature header with each webhook request, calculated using HMAC SHA1 with your Auth Token. Your application reconstructs the signature using the full webhook URL plus sorted POST parameters, then compares it with the received signature using crypto.timingSafeEqual() to prevent timing attacks. This ensures requests genuinely come from Twilio. Reference: Twilio Webhook Security.

Can I send media messages with captions?

Yes, WhatsApp supports sending media (images, videos, PDFs, audio) with optional captions up to 1024 characters. Use the sendWhatsAppMediaMessage() method in the TwilioService, providing the media URL and optional body text. The media URL must be publicly accessible, and Twilio will download and forward it to WhatsApp. Supported formats include JPEG, PNG, MP4, PDF, and more.

What Node.js version should I use for production?

Node.js v22 LTS is recommended for production deployments. It's the current Active LTS release, supported until October 2025 and maintained until April 2027. This ensures long-term stability, security updates, and compatibility with modern libraries like Twilio SDK v5.x and NestJS v10+. Minimum requirement is Node.js v20, but v22 LTS provides the best production experience.

How do I handle webhook signature validation failures?

Signature validation failures indicate either configuration issues or potential security threats. Common causes include: incorrect WEBHOOK_AUTH_TOKEN (must match Twilio Auth Token), URL mismatch (Twilio uses the exact webhook URL), or proxy/HTTPS issues. Log the expected vs received signatures for debugging. In production, always reject invalid signatures with HTTP 403 to prevent spoofing attacks.

What are the production deployment requirements?

For production deployment, ensure: (1) Node.js v22 LTS installed, (2) Environment variables configured securely (use secrets management, not .env files), (3) HTTPS endpoint for webhooks (Twilio requires HTTPS in production), (4) WEBHOOK_AUTH_TOKEN configured for security, (5) Facebook Business Manager account with approved WhatsApp Business Profile, (6) Registered phone number with WhatsApp Business API access, and (7) Rate limiting enabled (@nestjs/throttler) to prevent abuse.

Conclusion

You've successfully built a production-ready WhatsApp integration using NestJS, TypeScript, and the Twilio API. This implementation provides:

Key Implementation Points:

  • Secure webhook handling with HMAC SHA1 signature validation to prevent spoofing attacks
  • E.164 phone number validation ensuring proper international format for all messages
  • Comprehensive error handling with detailed logging for debugging and monitoring
  • Media message support with caption validation (1024-character limit)
  • Modular architecture following NestJS best practices for maintainability and scalability
  • Environment-based configuration using ConfigService for secure credential management
  • Production-ready code with TypeScript type safety and validation pipes

Next Steps for Production:

  1. Register for WhatsApp Business API through Facebook Business Manager and complete the approval process (typically 2–4 weeks)
  2. Implement message template management for messaging outside the 24-hour window using Twilio's template API
  3. Add database persistence using Prisma or TypeORM to store message history, conversation state, and user preferences
  4. Configure rate limiting with @nestjs/throttler to protect webhook endpoints from abuse
  5. Set up monitoring and alerting using services like Sentry, DataDog, or New Relic for error tracking
  6. Deploy with HTTPS to a cloud provider (AWS, Google Cloud, DigitalOcean, Heroku) as Twilio requires secure webhook endpoints
  7. Implement automated testing with Jest for unit tests and E2E tests to ensure reliability

Additional Resources:

This integration serves as a solid foundation for building customer support systems, notification services, conversational AI applications, and two-way messaging solutions at scale.

Frequently Asked Questions

how to integrate whatsapp with node.js

Integrate WhatsApp with Node.js using the Twilio API and the NestJS framework. This involves setting up a NestJS project, installing the Twilio Node.js library, and configuring your application to handle incoming and outgoing WhatsApp messages via webhooks and the Twilio API.

what is nestjs used for in whatsapp integration

NestJS provides a robust and scalable framework for building the server-side logic of your WhatsApp integration. Its modular architecture and TypeScript support enhance code organization and maintainability for production-level applications.

why use twilio for whatsapp integration

Twilio acts as the bridge between your Node.js application and the WhatsApp Business Platform. It handles the complex infrastructure and communication required to send and receive WhatsApp messages, simplifying the integration process.

when should I use prisma in whatsapp integration

Consider using Prisma or TypeORM if you need to persist data, such as message history or user interactions, within a database. These ORMs streamline database operations within your NestJS application.

can I send media messages with whatsapp twilio

Yes, you can send media messages (images, audio, video) through the Twilio API for WhatsApp. The `sendWhatsAppMediaMessage` function within the provided `TwilioService` handles this, accepting a media URL as a parameter. Ensure the URL is correct and publicly accessible.

how to set up twilio whatsapp sandbox

To set up a Twilio WhatsApp Sandbox, create a Twilio account and navigate to the WhatsApp Sandbox settings in the Twilio Console. You'll receive a dedicated Sandbox number and instructions for connecting your mobile device for testing purposes.

what is a twilio webhook for whatsapp

A Twilio webhook is an HTTP endpoint in your NestJS application that receives incoming WhatsApp messages. When a user sends a message to your Twilio WhatsApp number, Twilio forwards it to your specified webhook URL as an HTTP POST request.

how to validate twilio webhook signature

Validating the Twilio webhook signature ensures that incoming requests originate from Twilio. Use the `validateTwilioRequest` function provided in the `WebhookController`, using a strong secret `WEBHOOK_AUTH_TOKEN`. This is critical to prevent unauthorized access or malicious actions. The code will generate warnings if this token is missing.

why is the whatsapp number prefixed with whatsapp:

The `whatsapp:` prefix in the `TWILIO_WHATSAPP_NUMBER` environment variable and in the `to` parameter of sending functions explicitly identifies the destination as a WhatsApp number, distinguishing it from other communication channels Twilio supports.

how to send whatsapp message from nestjs api

Create an API endpoint in your NestJS application that utilizes the `TwilioService` to send outbound WhatsApp messages. The `MessageController` and `MessageService` examples show how to structure this, including DTO validation and error handling.

what is the x-twilio-signature header

The `X-Twilio-Signature` header, included in Twilio's webhook requests, contains a cryptographic signature of the request. This signature allows you to verify the request's authenticity and confirm it came from Twilio, essential for security.

how to reply to incoming whatsapp messages with twilio

To reply to incoming WhatsApp messages, use the Twilio Messaging Response TwiML (XML) format. The `WebhookController` example demonstrates constructing a `MessagingResponse` object and sending a TwiML reply back to Twilio, which then delivers it to the user.

when to use ngrok with twilio whatsapp

Use ngrok during local development to create a publicly accessible URL for your webhook endpoint. This allows Twilio to send webhook requests to your application even when it's running on your local machine.

what are twilio account sid and auth token

Your Twilio Account SID and Auth Token are unique credentials that authenticate your application with the Twilio API. Find these credentials in your Twilio account dashboard; keep them secure and never expose them in public code repositories.