code examples

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

Build WhatsApp Integration with Vonage and NestJS: Complete Guide

Complete tutorial for building WhatsApp messaging with Vonage Messages API and NestJS. Includes webhooks, status updates, security, and 2025 production requirements.

Build WhatsApp Integration with Vonage and NestJS: Complete Guide

This guide walks you through building a robust WhatsApp messaging service using Node.js with the NestJS framework and the Vonage Messages API (Application Programming Interface). You'll learn to set up your environment, send messages, receive incoming messages via webhooks, handle message status updates, and implement production considerations like security, error handling, and deployment.

By completing this tutorial, you'll have a functional NestJS application that can:

  1. Send WhatsApp messages programmatically via the Vonage Messages API.
  2. Receive incoming WhatsApp messages through secure webhooks.
  3. Process message status updates (e.g., delivered, read).
  4. Handle configuration securely and effectively.

This guide assumes basic knowledge of Node.js, TypeScript, and REST APIs (Representational State Transfer Application Programming Interfaces). NestJS provides structured architecture, dependency injection, and modularity that simplify building scalable applications. Vonage provides the communication infrastructure for sending and receiving WhatsApp messages.

Prerequisites:

  • Node.js (v20 LTS or v22 LTS recommended for 2025 – v22 "Jod" is Active LTS through October 2025, then Maintenance LTS through April 2027. v20 "Iron" is in Maintenance LTS through April 2026. v18 reached end-of-life in April 2025.)
  • npm or yarn package manager
  • A Vonage API Account (Sign up for free credit if you don't have one)
  • ngrok installed (A free account is sufficient). Note: Use ngrok for local development testing only, allowing Vonage webhooks to reach your machine. Production deployments require a stable, publicly accessible HTTPS endpoint for your webhook handlers.
  • Access to a WhatsApp-enabled device for testing

Verify Your Installation:

Confirm your Node.js version meets requirements:

bash
node --version  # Should show v20.x.x or v22.x.x
npm --version   # Verify npm is installed
ngrok version   # Confirm ngrok is available

GitHub Repository:

Find a complete, working example of the code in this guide at [repository URL placeholder – update with actual URL].

Project Architecture

The system consists of the following components:

  1. NestJS Application: The core backend service built with NestJS. It exposes endpoints to trigger outgoing messages and handles incoming webhooks from Vonage.
  2. Vonage Messages API: The third-party service used to send and receive WhatsApp messages.
  3. WhatsApp Platform: Where end-users interact via their WhatsApp client.
  4. ngrok (Development): A tool to expose your local NestJS application to the internet so Vonage webhooks can reach it during development.
mermaid
sequenceDiagram
    participant User as WhatsApp User
    participant WhatsApp
    participant Vonage as Vonage Messages API
    participant Ngrok as ngrok (Dev Only)
    participant NestJS as NestJS Application

    User->>+WhatsApp: Sends message
    WhatsApp->>+Vonage: Delivers inbound message
    Vonage->>+Ngrok: Forwards message to webhook URL
    Ngrok->>+NestJS: Delivers message payload to /webhooks/inbound
    NestJS-->>-Ngrok: Responds 200 OK
    Ngrok-->>-Vonage: Forwards 200 OK
    Note right of NestJS: Process inbound message (e.g., log, reply)
    NestJS->>+Vonage: Sends reply message via API
    Vonage->>+WhatsApp: Delivers reply message
    WhatsApp->>-User: Shows reply message

    Note over Vonage, NestJS: Status Updates
    Vonage->>+Ngrok: Forwards status update to webhook URL
    Ngrok->>+NestJS: Delivers status payload to /webhooks/status
    NestJS-->>-Ngrok: Responds 200 OK
    Ngrok-->>-Vonage: Forwards 200 OK
    Note right of NestJS: Process status update (e.g., log)

1. Setting Up the NestJS Project

Initialize a new NestJS project and install the necessary dependencies.

1.1. Install NestJS CLI

bash
npm install -g @nestjs/cli

1.2. Create a New NestJS Project

bash
nest new vonage-whatsapp-nestjs
cd vonage-whatsapp-nestjs

Choose your preferred package manager (npm or yarn) when prompted.

1.3. Install Required Dependencies

Install the Vonage SDK packages and configuration modules:

bash
npm install @vonage/server-sdk @vonage/messages @vonage/jwt @nestjs/config dotenv
# Or using yarn:
# yarn add @vonage/server-sdk @vonage/messages @vonage/jwt @nestjs/config dotenv

What Each Package Does:

  • @vonage/server-sdk – Core Vonage SDK for Node.js
  • @vonage/messages – Messages API client for sending WhatsApp, SMS, and other messages
  • @vonage/jwt – JWT verification for webhook signature validation
  • @nestjs/config – NestJS module for managing environment variables
  • dotenv – Loads environment variables from .env files

Package Compatibility:

PackageMinimum VersionNotes
@vonage/server-sdk3.15.0+Supports latest Messages API features
@vonage/messages2.1.0+Required for WhatsApp support
@vonage/jwt1.2.0+Required for signature verification
@nestjs/config3.0.0+Compatible with NestJS 10+

1.4. Set Up Environment Variables

Create a .env file in the root of your project. This file stores your Vonage credentials and configuration. Never commit this file to version control.

dotenv
# .env

# Vonage API Credentials & Application Details
VONAGE_API_KEY=YOUR_API_KEY_HERE
VONAGE_API_SECRET=YOUR_API_SECRET_HERE
VONAGE_APPLICATION_ID=YOUR_APPLICATION_ID_HERE
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root
VONAGE_API_SIGNATURE_SECRET=YOUR_SIGNATURE_SECRET_HERE # For webhook verification

# Vonage WhatsApp Number (Sandbox or Purchased)
VONAGE_WHATSAPP_NUMBER=VONAGE_PROVIDED_WHATSAPP_NUMBER

# Application Port
PORT=3000 # Default NestJS port

# Vonage API Host (Optional: Use sandbox for testing initially)
VONAGE_API_HOST=https://messages-sandbox.nexmo.com # For production, remove this line to default to the production URL (api.nexmo.com)

Secure Your Credentials:

Add these entries to your .gitignore file:

gitignore
# Vonage credentials
.env
.env.*
!.env.example
private.key
*.key

1.5. Configure NestJS ConfigModule

Modify your main application module (src/app.module.ts) to load environment variables:

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 { VonageModule } from './vonage/vonage.module'; // We will create this
import { WebhooksModule } from './webhooks/webhooks.module'; // We will create this

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true, // Make ConfigModule available globally
      envFilePath: '.env', // Load variables from .env file
    }),
    VonageModule, // Add Vonage module
    WebhooksModule, // Add Webhooks module
  ],
  controllers: [AppController], // Default controller
  providers: [AppService], // Default service
})
export class AppModule {}

1.6. Vonage Account and Application Setup

Configure your Vonage account and application before proceeding.

Step 1: Access Your API Credentials

  1. Log in to the Vonage API Dashboard.
  2. Find your VONAGE_API_KEY and VONAGE_API_SECRET on the main dashboard page.
  3. Navigate to Settings to find the "API key signature secret" and copy your VONAGE_API_SIGNATURE_SECRET.

Step 2: Create a Vonage Application

  1. Go to ApplicationsCreate a new application.
  2. Name your application (e.g., "NestJS WhatsApp Service").
  3. Click Generate public and private key. A private.key file downloads automatically. Save this file in your NestJS project root directory. Update VONAGE_PRIVATE_KEY_PATH in your .env file if you save it elsewhere.
  4. Enable the Messages capability. Leave webhook URLs blank for now (we'll add ngrok URLs next).
  5. Click Generate new application.
  6. Copy the Application ID and save it as VONAGE_APPLICATION_ID in your .env file.

Step 3: Set Up WhatsApp Sandbox for Testing

  1. Navigate to Messages API Sandbox in the dashboard sidebar.
  2. Follow instructions to activate the sandbox by sending a specific message from your WhatsApp number to the provided Vonage sandbox number. This allowlists your number for testing.
  3. Copy the Vonage Sandbox WhatsApp number (e.g., 14157386102) and save it as VONAGE_WHATSAPP_NUMBER in your .env file.
  4. Leave Sandbox Webhooks blank for now. We'll update these with ngrok URLs.

Account Verification Requirements:

  • Activation Time: Sandbox access is immediate. Production WhatsApp Business API accounts require business verification (typically 1–3 business days).
  • Phone Verification: You must verify ownership of the phone number you'll use in production.
  • Business Profile: Complete your business profile information before going live.

Production WhatsApp Business API Requirements:

  • Phone Number Restrictions: The phone number cannot already be used on WhatsApp (personal or business app). Each business phone number can associate with only one API/BSP (Business Solution Provider) at a time. Meta limits businesses to 2 business phone numbers across all WhatsApp Business Accounts (WABAs) during initial registration.
  • Messaging Limits: Initially, you can send business-initiated conversations to 250 unique customers in a rolling 24-hour period. This scales automatically based on phone number status, quality rating, and conversation frequency. Customer-initiated conversations (24-hour messaging windows) are unlimited.
  • Business Verification: Optional but recommended to increase messaging limits. You can start messaging without completing business verification or display name reviews.
  • Facebook Business Manager: Required for production onboarding. You need access to your business's Facebook Business Manager Account.
  • Geographic Restrictions: WhatsApp Business Platform cannot send/receive messages to/from Ukraine (Crimea, Donetsk, Luhansk), Cuba, Iran, North Korea, or Syria.
  • Pricing Model Change (2025): Meta is transitioning from Conversation Based Pricing (CBP) to Per Message Price (PMP) effective July 1, 2025. Vonage Platform Fees updated October 1, 2025.

Cost Estimation:

CategoryCost EstimateNotes
Sandbox TestingFreeIncludes 10 EUR test credit
Customer-Initiated MessagesUSD 0.005–0.04/messageVaries by country
Business-Initiated MessagesUSD 0.01–0.15/messageHigher cost, country-dependent
Template Message ReviewsFree1–3 business day approval

1.7. Start ngrok

Open a new terminal window and run ngrok to expose port 3000:

bash
ngrok http 3000

Authenticate ngrok (First-Time Setup):

If you haven't authenticated ngrok:

  1. Sign up at ngrok.com.
  2. Get your authtoken from the dashboard.
  3. Run: ngrok config add-authtoken YOUR_TOKEN_HERE

ngrok displays a forwarding URL (e.g., https://<unique-subdomain>.ngrok.io or https://<unique-subdomain>.ngrok-free.app). Copy this HTTPS URL.

Production Alternatives:

For production deployments, replace ngrok with:

  • Cloud Platforms: AWS Lambda, Azure Functions, Google Cloud Run
  • Traditional Hosting: Heroku, DigitalOcean, AWS EC2 with SSL certificates
  • Reverse Proxies: Cloudflare Tunnel, Nginx with Let's Encrypt

1.8. Update Vonage Webhook URLs

Return to your Vonage dashboard and configure webhook endpoints:

Application Webhooks:

  1. Navigate to Applications → Your Application ("NestJS WhatsApp Service").
  2. Edit the Messages capability URLs:
    • Inbound URL: YOUR_NGROK_HTTPS_URL/webhooks/inbound
    • Status URL: YOUR_NGROK_HTTPS_URL/webhooks/status
  3. Save the changes.

Sandbox Webhooks:

  1. Navigate to Messages API Sandbox.
  2. Edit the Webhook URLs:
    • Inbound URL: YOUR_NGROK_HTTPS_URL/webhooks/inbound
    • Status URL: YOUR_NGROK_HTTPS_URL/webhooks/status
  3. Click Save webhooks.

Vonage now knows where to send incoming messages and status updates for your application and sandbox environment.

2. Implementing Core Functionality (Vonage Service)

Create a dedicated module and service to handle interactions with the Vonage SDK.

2.1. Generate Vonage Module and Service

Use the NestJS CLI to generate the necessary files:

bash
nest g module vonage
nest g service vonage --no-spec # --no-spec skips generating a test file for now

This creates src/vonage/vonage.module.ts and src/vonage/vonage.service.ts.

2.2. Configure VonageModule

Set up the module to export the service:

typescript
// src/vonage/vonage.module.ts
import { Module } from '@nestjs/common';
import { VonageService } from './vonage.service';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [ConfigModule], // Ensure ConfigService is available
  providers: [VonageService],
  exports: [VonageService], // Export the service so other modules can use it
})
export class VonageModule {}

2.3. Implement VonageService

This service initializes the Vonage SDK client using credentials from environment variables and provides methods to send WhatsApp messages.

typescript
// src/vonage/vonage.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Vonage } from '@vonage/server-sdk';
import { WhatsAppText } from '@vonage/messages';
import * as fs from 'fs';

@Injectable()
export class VonageService implements OnModuleInit {
  private readonly logger = new Logger(VonageService.name);
  private vonageClient: Vonage;
  private vonageWhatsAppNumber: string;
  private readonly MAX_RETRIES = 3;
  private readonly RETRY_DELAY_MS = 1000;

  constructor(private configService: ConfigService) {}

  // Initialize the client when the module loads
  onModuleInit() {
    try {
      const apiKey = this.configService.get<string>('VONAGE_API_KEY');
      const apiSecret = this.configService.get<string>('VONAGE_API_SECRET');
      const applicationId = this.configService.get<string>(
        'VONAGE_APPLICATION_ID',
      );
      const privateKeyPath = this.configService.get<string>(
        'VONAGE_PRIVATE_KEY_PATH',
      );
      // Check for optional sandbox host, default to production if not set
      const apiHost = this.configService.get<string>('VONAGE_API_HOST');
      this.vonageWhatsAppNumber = this.configService.get<string>(
        'VONAGE_WHATSAPP_NUMBER',
      );

      // Validate required config
      if (
        !apiKey ||
        !apiSecret ||
        !applicationId ||
        !privateKeyPath ||
        !this.vonageWhatsAppNumber
      ) {
        throw new Error('Missing required Vonage configuration in .env file');
      }

      // Ensure private key file exists
      if (!fs.existsSync(privateKeyPath)) {
        throw new Error(`Private key file not found at: ${privateKeyPath}`);
      }

      const privateKey = fs.readFileSync(privateKeyPath);

      this.vonageClient = new Vonage(
        {
          apiKey: apiKey,
          apiSecret: apiSecret,
          applicationId: applicationId,
          privateKey: privateKey,
        },
        // Optional: Specify API host for sandbox or specific region
        // If VONAGE_API_HOST is not set in .env, it defaults to production
        apiHost ? { apiHost: apiHost } : {},
      );

      this.logger.log('Vonage client initialized successfully.');
      if (apiHost) {
        this.logger.warn(`Vonage client using API host: ${apiHost}`);
      } else {
        this.logger.log('Vonage client using default production API host.');
      }
    } catch (error) {
      this.logger.error('Failed to initialize Vonage client:', error.message);
      throw new Error(`Vonage initialization failed: ${error.message}`);
    }
  }

  /**
   * Sends a WhatsApp text message with automatic retry logic.
   * @param to The recipient's phone number (E.164 format, without leading '+').
   * @param text The message content.
   * @returns The message UUID on success.
   * @throws Error if sending fails after retries.
   */
  async sendWhatsAppTextMessage(to: string, text: string): Promise<string> {
    if (!this.vonageClient) {
      this.logger.error(
        'Vonage client not initialized. Cannot send message.',
      );
      throw new Error('Vonage client not available.');
    }

    this.logger.log(`Attempting to send WhatsApp message to ${to}`);

    for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
      try {
        const response = await this.vonageClient.messages.send(
          new WhatsAppText({
            from: this.vonageWhatsAppNumber,
            to: to, // E.164 format without '+'
            text: text,
          }),
        );

        this.logger.log(
          `Message sent successfully to ${to}. Message UUID: ${response.messageUuid}`,
        );
        return response.messageUuid;
      } catch (error) {
        // Check for rate limiting
        if (error.response?.status === 429) {
          const retryAfter = error.response.headers['retry-after'];
          const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : this.RETRY_DELAY_MS * attempt;
          this.logger.warn(
            `Rate limited. Attempt ${attempt}/${this.MAX_RETRIES}. Waiting ${waitTime}ms before retry.`,
          );

          if (attempt < this.MAX_RETRIES) {
            await this.sleep(waitTime);
            continue;
          }
        }

        this.logger.error(`Error sending WhatsApp message to ${to}:`, error);

        // Log detailed error info if available
        if (error.response?.data) {
          this.logger.error('Vonage API Error details:', error.response.data);
        }

        // Re-throw on final attempt
        if (attempt === this.MAX_RETRIES) {
          throw new Error(
            `Failed to send WhatsApp message after ${this.MAX_RETRIES} attempts: ${
              error.response?.data?.title || error.message
            }`,
          );
        }

        // Wait before retry
        await this.sleep(this.RETRY_DELAY_MS * attempt);
      }
    }
  }

  /**
   * Sends a WhatsApp message with media attachment.
   * @param to The recipient's phone number (E.164 format, without leading '+').
   * @param mediaUrl The URL of the media to send (must be publicly accessible).
   * @param caption Optional caption for the media.
   * @returns The message UUID on success.
   */
  async sendWhatsAppMediaMessage(
    to: string,
    mediaUrl: string,
    caption?: string,
  ): Promise<string> {
    if (!this.vonageClient) {
      throw new Error('Vonage client not available.');
    }

    this.logger.log(`Sending WhatsApp media message to ${to}`);

    try {
      const response = await this.vonageClient.messages.send({
        channel: 'whatsapp',
        message_type: 'image',
        to: to,
        from: this.vonageWhatsAppNumber,
        image: {
          url: mediaUrl,
          caption: caption,
        },
      });

      this.logger.log(
        `Media message sent successfully to ${to}. Message UUID: ${response.messageUuid}`,
      );
      return response.messageUuid;
    } catch (error) {
      this.logger.error(`Error sending media message to ${to}:`, error);
      throw new Error(
        `Failed to send media message: ${
          error.response?.data?.title || error.message
        }`,
      );
    }
  }

  /**
   * Sends a WhatsApp template message.
   * @param to The recipient's phone number (E.164 format, without leading '+').
   * @param templateName The name of the approved template.
   * @param parameters Template parameters (if required).
   * @returns The message UUID on success.
   */
  async sendWhatsAppTemplateMessage(
    to: string,
    templateName: string,
    parameters: string[] = [],
  ): Promise<string> {
    if (!this.vonageClient) {
      throw new Error('Vonage client not available.');
    }

    this.logger.log(`Sending WhatsApp template "${templateName}" to ${to}`);

    try {
      const response = await this.vonageClient.messages.send({
        channel: 'whatsapp',
        message_type: 'template',
        to: to,
        from: this.vonageWhatsAppNumber,
        template: {
          name: templateName,
          parameters: parameters,
        },
      });

      this.logger.log(
        `Template message sent successfully to ${to}. Message UUID: ${response.messageUuid}`,
      );
      return response.messageUuid;
    } catch (error) {
      this.logger.error(`Error sending template message to ${to}:`, error);
      throw new Error(
        `Failed to send template message: ${
          error.response?.data?.title || error.message
        }`,
      );
    }
  }

  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

Key Features:

  1. @Injectable(): Marks the class for NestJS dependency injection.
  2. Logger: NestJS built-in logger for informative console output.
  3. ConfigService: Injected to access environment variables safely.
  4. OnModuleInit: Interface ensuring the onModuleInit method runs once the host module initializes. This sets up the Vonage client.
  5. Initialization Logic: Reads credentials from .env, validates them, reads the private key file, and creates the Vonage instance. Includes error handling for missing configuration or files. Explicitly checks if VONAGE_API_HOST is set and logs whether it's using the sandbox or defaulting to production.
  6. sendWhatsAppTextMessage: An async method that takes the recipient (to) number and message text. Uses the initialized vonageClient.messages.send method with a WhatsAppText object. Includes retry logic for rate limiting and robust error handling.
  7. Media and Template Support: Additional methods for sending images, documents, and template messages.

3. Building the Webhook Handler

Create the module and controller to handle incoming webhook requests from Vonage.

3.1. Generate Webhooks Module and Controller

bash
nest g module webhooks
nest g controller webhooks --no-spec

3.2. Configure WebhooksModule

This module needs access to VonageService to send replies:

typescript
// src/webhooks/webhooks.module.ts
import { Module } from '@nestjs/common';
import { WebhooksController } from './webhooks.controller';
import { ConfigModule } from '@nestjs/config';
import { VonageModule } from '../vonage/vonage.module'; // Import VonageModule

@Module({
  imports: [
    ConfigModule, // For accessing signature secret
    VonageModule, // Make VonageService available
  ],
  controllers: [WebhooksController],
})
export class WebhooksModule {}

3.3. Create DTO Classes for Validation

Create Data Transfer Objects (DTOs) to validate webhook payloads:

Inbound Message DTO:

typescript
// src/webhooks/dto/inbound-message.dto.ts
import {
  IsString,
  IsNotEmpty,
  IsOptional,
  ValidateNested,
  IsEnum,
} from 'class-validator';
import { Type } from 'class-transformer';

class FromDto {
  @IsString()
  @IsNotEmpty()
  type: string;

  @IsString()
  @IsNotEmpty()
  number: string;
}

class MessageContentDto {
  @IsString()
  @IsOptional()
  text?: string;

  @IsString()
  @IsOptional()
  image?: string;

  @IsString()
  @IsOptional()
  caption?: string;
}

class MessageDto {
  @ValidateNested()
  @Type(() => MessageContentDto)
  @IsOptional()
  content?: MessageContentDto;
}

export class InboundMessageDto {
  @IsString()
  @IsNotEmpty()
  message_uuid: string;

  @IsString()
  @IsNotEmpty()
  @IsEnum(['text', 'image', 'audio', 'video', 'file', 'location', 'button', 'list'])
  message_type: string;

  @IsString()
  @IsNotEmpty()
  timestamp: string;

  @ValidateNested()
  @Type(() => FromDto)
  from: FromDto;

  @ValidateNested()
  @Type(() => MessageDto)
  @IsOptional()
  message?: MessageDto;

  @IsString()
  @IsOptional()
  channel?: string;
}

Status Update DTO:

typescript
// src/webhooks/dto/status-update.dto.ts
import {
  IsString,
  IsNotEmpty,
  IsOptional,
  ValidateNested,
  IsEnum,
} from 'class-validator';
import { Type } from 'class-transformer';

class ToDto {
  @IsString()
  @IsNotEmpty()
  type: string;

  @IsString()
  @IsNotEmpty()
  number: string;
}

class ErrorDto {
  @IsString()
  @IsOptional()
  code?: string;

  @IsString()
  @IsOptional()
  reason?: string;
}

export class StatusUpdateDto {
  @IsString()
  @IsNotEmpty()
  message_uuid: string;

  @IsString()
  @IsNotEmpty()
  @IsEnum(['submitted', 'delivered', 'read', 'failed', 'rejected', 'undeliverable'])
  status: string;

  @IsString()
  @IsNotEmpty()
  timestamp: string;

  @ValidateNested()
  @Type(() => ToDto)
  to: ToDto;

  @ValidateNested()
  @Type(() => ErrorDto)
  @IsOptional()
  error?: ErrorDto;

  @IsString()
  @IsOptional()
  channel?: string;
}

3.4. Implement WebhooksController

This controller defines the /webhooks/inbound and /webhooks/status endpoints that Vonage calls. It handles POST requests, parses the JSON body, and verifies the JWT signature for security.

typescript
// src/webhooks/webhooks.controller.ts
import {
  Controller,
  Post,
  Body,
  Req,
  Res,
  HttpStatus,
  Logger,
  UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request, Response } from 'express';
import { verifySignature } from '@vonage/jwt';
import { VonageService } from '../vonage/vonage.service';
import { InboundMessageDto } from './dto/inbound-message.dto';
import { StatusUpdateDto } from './dto/status-update.dto';

@Controller('webhooks') // Base path for all routes in this controller
export class WebhooksController {
  private readonly logger = new Logger(WebhooksController.name);
  private readonly signatureSecret: string;
  private readonly processedMessages = new Set<string>(); // Simple idempotency tracking

  constructor(
    private configService: ConfigService,
    private vonageService: VonageService, // Inject VonageService
  ) {
    this.signatureSecret = this.configService.get<string>(
      'VONAGE_API_SIGNATURE_SECRET',
    );
    if (!this.signatureSecret) {
      this.logger.error('VONAGE_API_SIGNATURE_SECRET is not set in .env');
      throw new Error('Server configuration error: Missing signature secret.');
    }
  }

  // --- Helper Method for Signature Verification ---
  private verifyVonageSignature(req: Request): boolean {
    try {
      const authorizationHeader =
        req.headers['authorization'] || req.headers['Authorization'];
      if (!authorizationHeader || !authorizationHeader.startsWith('Bearer ')) {
        this.logger.warn('Missing or invalid Authorization header');
        return false;
      }

      const token = authorizationHeader.split(' ')[1];
      if (!token) {
        this.logger.warn('Bearer token missing in Authorization header');
        return false;
      }

      const rawBody = (req as any).rawBody;
      if (!rawBody) {
        this.logger.error(
          'Raw request body not available for signature verification. ' +
            'Ensure rawBody is enabled in main.ts (Section 5.2).',
        );
        return false;
      }

      // Verify the JWT signature against the raw request body buffer
      const isSignatureValid = verifySignature(
        token,
        this.signatureSecret,
        rawBody,
      );

      if (!isSignatureValid) {
        this.logger.warn('Invalid JWT signature received');
        return false;
      }

      this.logger.log('Valid JWT signature verified using raw body');
      return true;
    } catch (error) {
      this.logger.error('Error during signature verification:', error);
      return false;
    }
  }

  // --- Inbound Message Webhook ---
  @Post('inbound')
  async handleInboundMessage(
    @Body() body: InboundMessageDto,
    @Req() req: Request,
    @Res() res: Response,
  ) {
    this.logger.log('Received inbound message webhook');

    // 1. Verify Signature (CRITICAL FOR SECURITY)
    if (!this.verifyVonageSignature(req)) {
      this.logger.error('Unauthorized inbound request: Invalid signature');
      return res.status(HttpStatus.UNAUTHORIZED).send('Invalid signature');
    }

    // 2. Check for duplicate (idempotency)
    if (this.processedMessages.has(body.message_uuid)) {
      this.logger.warn(
        `Duplicate message received: ${body.message_uuid}. Ignoring.`,
      );
      return res.status(HttpStatus.OK).send(); // Still return 200 OK
    }

    // Mark as processed
    this.processedMessages.add(body.message_uuid);

    // Clean up old entries (simple approach for demo – use Redis in production)
    if (this.processedMessages.size > 10000) {
      const iterator = this.processedMessages.values();
      for (let i = 0; i < 1000; i++) {
        this.processedMessages.delete(iterator.next().value);
      }
    }

    // 3. Process the message
    const messageType = body.message_type;
    const fromNumber = body.from.number;
    const messageContent = body.message?.content?.text;

    try {
      switch (messageType) {
        case 'text':
          if (fromNumber && messageContent) {
            this.logger.log(
              `Received text message "${messageContent}" from ${fromNumber}`,
            );

            // Example: Simple echo reply
            const replyText = `You sent: "${messageContent}"`;
            const messageUuid = await this.vonageService.sendWhatsAppTextMessage(
              fromNumber,
              replyText,
            );
            this.logger.log(
              `Reply sent to ${fromNumber}. Message UUID: ${messageUuid}`,
            );
          }
          break;

        case 'image':
          const imageUrl = body.message?.content?.image;
          const caption = body.message?.content?.caption;
          this.logger.log(
            `Received image from ${fromNumber}: ${imageUrl} (Caption: ${caption || 'none'})`,
          );
          // Handle image processing here
          break;

        case 'location':
          this.logger.log(`Received location share from ${fromNumber}`);
          // Handle location data here
          break;

        default:
          this.logger.warn(
            `Received unsupported message type: ${messageType} from ${fromNumber}`,
          );
      }
    } catch (error) {
      this.logger.error(`Failed to process message from ${fromNumber}:`, error);
      // Still return 200 OK to prevent retries
    }

    // 4. Respond to Vonage (CRITICAL)
    // Always send a 200 OK quickly to acknowledge receipt
    res.status(HttpStatus.OK).send();
  }

  // --- Message Status Webhook ---
  @Post('status')
  handleMessageStatus(
    @Body() body: StatusUpdateDto,
    @Req() req: Request,
    @Res() res: Response,
  ) {
    this.logger.log('Received message status webhook');

    // 1. Verify Signature (CRITICAL FOR SECURITY)
    if (!this.verifyVonageSignature(req)) {
      this.logger.error('Unauthorized status request: Invalid signature');
      return res.status(HttpStatus.UNAUTHORIZED).send('Invalid signature');
    }

    // 2. Process the status update
    const messageUuid = body.message_uuid;
    const status = body.status;
    const timestamp = body.timestamp;
    const toNumber = body.to.number;

    this.logger.log(
      `Status update for message ${messageUuid} to ${toNumber}: ${status} at ${timestamp}`,
    );

    // Add logic here: update message status in a database, trigger notifications, etc.
    switch (status) {
      case 'delivered':
        this.logger.log(`Message ${messageUuid} delivered successfully.`);
        break;
      case 'read':
        this.logger.log(`Message ${messageUuid} read by recipient.`);
        break;
      case 'failed':
      case 'rejected':
        this.logger.error(
          `Message ${messageUuid} ${status}. Reason: ${
            body.error?.reason || body.error?.code || 'Unknown'
          }`,
          body.error,
        );
        break;
      default:
        this.logger.log(`Message ${messageUuid} status: ${status}`);
    }

    // 3. Respond to Vonage (CRITICAL)
    res.status(HttpStatus.OK).send();
  }
}

Key Features:

  1. @Controller('webhooks'): Defines the base route path.
  2. @Post('inbound') / @Post('status'): Decorators to handle POST requests to /webhooks/inbound and /webhooks/status.
  3. @Body(), @Req(), @Res(): Decorators to inject the validated request body (typed with DTOs), Express request object, and Express response object.
  4. Signature Verification: Retrieves the Authorization: Bearer <token> header, extracts the JWT token, uses req.rawBody (configured in Section 5.2), and verifies with verifySignature(token, secret, rawBody).
  5. Idempotency: Tracks processed message UUIDs to prevent duplicate processing.
  6. Message Type Handling: Switch statement handles different message types (text, image, location).
  7. Status Processing: Logs and processes different status types with specific handling for failures.
  8. 200 OK Response: Both handlers always send 200 OK to prevent Vonage from retrying.

4. Running and Testing the Application

4.1. Ensure ngrok is Running

Keep the ngrok http 3000 terminal window open.

4.2. Start the NestJS Application

In your main project terminal:

bash
npm run start:dev
# Or using yarn:
# yarn start:dev

Look for output indicating the Vonage client initialized and the server is listening:

log
[Nest] 12345  - 04/20/2025, 10:00:00 AM     LOG [NestFactory] Starting Nest application…
[Nest] 12345  - 04/20/2025, 10:00:01 AM     LOG [InstanceLoader] ConfigModule dependencies initialized
[Nest] 12345  - 04/20/2025, 10:00:02 AM     LOG [VonageService] Vonage client initialized successfully.
[Nest] 12345  - 04/20/2025, 10:00:02 AM     LOG [VonageService] Vonage client using default production API host.
[Nest] 12345  - 04/20/2025, 10:00:03 AM     LOG [NestApplication] Nest application successfully started

4.3. Test Sending an Inbound Message

  1. Open WhatsApp on the phone number you allowlisted in the Vonage Sandbox.
  2. Send a message (e.g., "Hello NestJS!") to the Vonage Sandbox WhatsApp number (VONAGE_WHATSAPP_NUMBER from your .env).

4.4. Check Application Logs

You should see logs like:

log
[Nest] 12345  - 04/20/2025, 10:05:00 AM     LOG [WebhooksController] Received inbound message webhook
[Nest] 12345  - 04/20/2025, 10:05:00 AM     LOG [WebhooksController] Valid JWT signature verified using raw body
[Nest] 12345  - 04/20/2025, 10:05:00 AM     LOG [WebhooksController] Received text message "Hello NestJS!" from 1xxxxxxxxxx
[Nest] 12345  - 04/20/2025, 10:05:00 AM     LOG [VonageService] Attempting to send WhatsApp message to 1xxxxxxxxxx
[Nest] 12345  - 04/20/2025, 10:05:01 AM     LOG [VonageService] Message sent successfully to 1xxxxxxxxxx. Message UUID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
[Nest] 12345  - 04/20/2025, 10:05:01 AM     LOG [WebhooksController] Reply sent to 1xxxxxxxxxx. Message UUID: a1b2c3d4-e5f6-7890-abcd-ef1234567890

4.5. Check Your WhatsApp

You should receive the reply message: "You sent: "Hello NestJS!""

4.6. Check Status Updates

You'll see status webhook logs:

log
[Nest] 12345  - 04/20/2025, 10:05:05 AM     LOG [WebhooksController] Received message status webhook
[Nest] 12345  - 04/20/2025, 10:05:05 AM     LOG [WebhooksController] Valid JWT signature verified using raw body
[Nest] 12345  - 04/20/2025, 10:05:05 AM     LOG [WebhooksController] Status update for message a1b2c3d4-e5f6-7890-abcd-ef1234567890 to 1xxxxxxxxxx: submitted at …
[Nest] 12345  - 04/20/2025, 10:05:06 AM     LOG [WebhooksController] Received message status webhook
[Nest] 12345  - 04/20/2025, 10:05:06 AM     LOG [WebhooksController] Valid JWT signature verified using raw body
[Nest] 12345  - 04/20/2025, 10:05:06 AM     LOG [WebhooksController] Status update for message a1b2c3d4-e5f6-7890-abcd-ef1234567890 to 1xxxxxxxxxx: delivered at …
[Nest] 12345  - 04/20/2025, 10:05:07 AM     LOG [WebhooksController] Message a1b2c3d4-e5f6-7890-abcd-ef1234567890 delivered successfully.

Troubleshooting Common Issues:

IssueCauseSolution
"Unauthorized: Invalid signature"Raw body not configuredComplete Section 5.2
"Private key file not found"Incorrect path in .envVerify VONAGE_PRIVATE_KEY_PATH points to private.key
No webhook receivedngrok URL not updatedUpdate webhook URLs in Vonage dashboard
Rate limit errorsToo many requestsImplement exponential backoff (already in service)
"Vonage client not available"Initialization failedCheck .env variables and logs

5. Enhancements for Production Readiness

The current setup works for development, but production applications require additional robustness.

5.1. Enable Request Validation with ValidationPipe

Configure NestJS to automatically validate webhook payloads using the DTOs we created.

Update your src/main.ts file:

typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

async function bootstrap() {
  // Enable rawBody parsing for webhook signature verification
  const app = await NestFactory.create(AppModule, {
    rawBody: true, // <<< Enable Raw Body
  });

  const configService = app.get(ConfigService);
  const port = configService.get<number>('PORT', 3000);

  // Enable global validation pipe
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true, // Strip properties not in DTO
      transform: true, // Automatically transform payloads to DTO instances
      forbidNonWhitelisted: true, // Throw error if unknown properties are received
      transformOptions: {
        enableImplicitConversion: true, // Allow basic type conversions
      },
    }),
  );

  // Optional: Enable CORS if your setup requires it
  // app.enableCors();

  await app.listen(port);
  Logger.log(`Application is running on: http://localhost:${port}`);
}
bootstrap();

What This Does:

  • rawBody: true: Preserves the raw request body buffer needed for JWT signature verification.
  • whitelist: true: Removes properties not defined in your DTOs, protecting against injection attacks.
  • transform: true: Converts plain JavaScript objects to DTO class instances.
  • forbidNonWhitelisted: true: Rejects requests with unexpected properties.

5.2. Configure Raw Body for Signature Verification

The rawBody: true option in NestFactory.create() automatically makes the raw body available at req.rawBody. This is required for the verifyVonageSignature method to work correctly.

Verification:

Add this log in your WebhooksController constructor to verify raw body is available:

typescript
constructor(
  private configService: ConfigService,
  private vonageService: VonageService,
) {
  this.signatureSecret = this.configService.get<string>(
    'VONAGE_API_SIGNATURE_SECRET',
  );
  if (!this.signatureSecret) {
    this.logger.error('VONAGE_API_SIGNATURE_SECRET is not set in .env');
    throw new Error('Server configuration error: Missing signature secret.');
  }

  // Verification: This will log when the controller initializes
  this.logger.log('WebhooksController initialized with signature verification enabled');
}

5.3. Implement Database Persistence

For production, persist message data and status updates to a database.

Install TypeORM and PostgreSQL Driver:

bash
npm install @nestjs/typeorm typeorm pg

Create Message Entity:

typescript
// src/entities/message.entity.ts
import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';

@Entity('messages')
export class Message {
  @PrimaryColumn()
  messageUuid: string;

  @Column()
  from: string;

  @Column()
  to: string;

  @Column()
  messageType: string;

  @Column('text', { nullable: true })
  content: string;

  @Column({ default: 'submitted' })
  status: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @Column('jsonb', { nullable: true })
  metadata: any;
}

Configure TypeORM in app.module.ts:

typescript
import { TypeOrmModule } from '@nestjs/typeorm';
import { Message } from './entities/message.entity';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: process.env.DB_HOST || 'localhost',
      port: parseInt(process.env.DB_PORT) || 5432,
      username: process.env.DB_USERNAME,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_DATABASE,
      entities: [Message],
      synchronize: process.env.NODE_ENV !== 'production', // Disable in production
    }),
    TypeOrmModule.forFeature([Message]),
    VonageModule,
    WebhooksModule,
  ],
  // …
})
export class AppModule {}

5.4. Implement Queue-Based Processing

For high-volume applications, process webhooks asynchronously using a message queue.

Install Bull Queue:

bash
npm install @nestjs/bull bull
npm install @types/bull --save-dev

Configure Bull Module:

typescript
// app.module.ts
import { BullModule } from '@nestjs/bull';

@Module({
  imports: [
    // …
    BullModule.forRoot({
      redis: {
        host: process.env.REDIS_HOST || 'localhost',
        port: parseInt(process.env.REDIS_PORT) || 6379,
      },
    }),
    BullModule.registerQueue({
      name: 'messages',
    }),
    // …
  ],
})
export class AppModule {}

Create Queue Processor:

typescript
// src/webhooks/message.processor.ts
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';
import { Logger } from '@nestjs/common';
import { VonageService } from '../vonage/vonage.service';

@Processor('messages')
export class MessageProcessor {
  private readonly logger = new Logger(MessageProcessor.name);

  constructor(private vonageService: VonageService) {}

  @Process('inbound')
  async handleInboundMessage(job: Job) {
    const { from, content } = job.data;
    this.logger.log(`Processing inbound message from ${from}`);

    try {
      const replyText = `You sent: "${content}"`;
      await this.vonageService.sendWhatsAppTextMessage(from, replyText);
      this.logger.log(`Reply sent to ${from}`);
    } catch (error) {
      this.logger.error(`Failed to process message from ${from}:`, error);
      throw error; // Bull will retry
    }
  }
}

5.5. Add Structured Logging

Replace Logger with a structured logging solution like Winston or Pino.

Install Winston:

bash
npm install nest-winston winston

Configure Winston:

typescript
// src/main.ts
import { WinstonModule } from 'nest-winston';
import * as winston from 'winston';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    rawBody: true,
    logger: WinstonModule.createLogger({
      transports: [
        new winston.transports.Console({
          format: winston.format.combine(
            winston.format.timestamp(),
            winston.format.json(),
          ),
        }),
        new winston.transports.File({
          filename: 'logs/error.log',
          level: 'error',
          format: winston.format.json(),
        }),
        new winston.transports.File({
          filename: 'logs/combined.log',
          format: winston.format.json(),
        }),
      ],
    }),
  });
  // …
}

5.6. Implement Rate Limiting

Protect your API from abuse with rate limiting.

Install Rate Limiter:

bash
npm install @nestjs/throttler

Configure Throttler:

typescript
// app.module.ts
import { ThrottlerModule } from '@nestjs/throttler';

@Module({
  imports: [
    ThrottlerModule.forRoot([{
      ttl: 60000, // 60 seconds
      limit: 10, // 10 requests per minute
    }]),
    // …
  ],
})
export class AppModule {}

Apply to Webhooks:

typescript
// webhooks.controller.ts
import { Throttle } from '@nestjs/throttler';

@Controller('webhooks')
export class WebhooksController {
  @Throttle({ default: { limit: 100, ttl: 60000 } }) // 100 requests per minute
  @Post('inbound')
  async handleInboundMessage(/* … */) {
    // …
  }
}

5.7. Deployment Configuration

Environment Variables for Production:

Add these to your .env for production:

dotenv
NODE_ENV=production
DB_HOST=your-db-host.com
DB_PORT=5432
DB_USERNAME=your_db_user
DB_PASSWORD=your_db_password
DB_DATABASE=vonage_whatsapp
REDIS_HOST=your-redis-host.com
REDIS_PORT=6379

Docker Configuration:

Create Dockerfile:

dockerfile
FROM node:22-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .
RUN npm run build

EXPOSE 3000

CMD ["node", "dist/main"]

Docker Compose:

yaml
version: '3.8'
services:
  app:
    build: .
    ports:
      - "3000:3000"
    env_file:
      - .env
    depends_on:
      - postgres
      - redis

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: vonage_whatsapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:

5.8. Health Checks and Monitoring

Add Health Check Endpoint:

bash
npm install @nestjs/terminus

Create Health Controller:

typescript
// src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus';

@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.db.pingCheck('database'),
    ]);
  }
}

5.9. Security Hardening Checklist

✓ Enable webhook signature verification (already implemented) ✓ Use HTTPS in production (remove ngrok) ✓ Validate all input with DTOs ✓ Implement rate limiting ✓ Use environment variables for secrets ✓ Enable CORS with specific origins only ✓ Implement request timeout limits ✓ Add helmet for security headers:

bash
npm install helmet
typescript
// main.ts
import helmet from 'helmet';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { rawBody: true });
  app.use(helmet());
  // …
}

6. Testing Your Application

6.1. Unit Tests

Create unit tests for your services:

typescript
// src/vonage/vonage.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { VonageService } from './vonage.service';

describe('VonageService', () => {
  let service: VonageService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        VonageService,
        {
          provide: ConfigService,
          useValue: {
            get: jest.fn((key: string) => {
              const config = {
                VONAGE_API_KEY: 'test_key',
                VONAGE_API_SECRET: 'test_secret',
                VONAGE_APPLICATION_ID: 'test_app_id',
                VONAGE_PRIVATE_KEY_PATH: './test.key',
                VONAGE_WHATSAPP_NUMBER: '14155551234',
              };
              return config[key];
            }),
          },
        },
      ],
    }).compile();

    service = module.get<VonageService>(VonageService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });
});

6.2. Integration Tests

Test webhook endpoints with real payloads:

typescript
// test/webhooks.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';

describe('WebhooksController (e2e)', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/webhooks/inbound (POST)', () => {
    return request(app.getHttpServer())
      .post('/webhooks/inbound')
      .send({
        message_uuid: 'test-uuid-123',
        message_type: 'text',
        timestamp: '2025-04-20T10:00:00.000Z',
        from: {
          type: 'whatsapp',
          number: '14155551234',
        },
        message: {
          content: {
            text: 'Test message',
          },
        },
      })
      .expect(200);
  });
});

6.3. Automated Testing with Postman

Create a Postman collection for manual and automated testing:

json
{
  "info": {
    "name": "Vonage WhatsApp NestJS",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "item": [
    {
      "name": "Inbound Webhook",
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/json"
          }
        ],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"message_uuid\": \"{{$guid}}\",\n  \"message_type\": \"text\",\n  \"timestamp\": \"{{$timestamp}}\",\n  \"from\": {\n    \"type\": \"whatsapp\",\n    \"number\": \"14155551234\"\n  },\n  \"message\": {\n    \"content\": {\n      \"text\": \"Hello World\"\n    }\n  }\n}"
        },
        "url": {
          "raw": "{{base_url}}/webhooks/inbound",
          "host": ["{{base_url}}"],
          "path": ["webhooks", "inbound"]
        }
      }
    }
  ]
}

7. Production Deployment Guide

7.1. Deploy to Heroku

Install Heroku CLI:

bash
curl https://cli-assets.heroku.com/install.sh | sh

Deploy:

bash
heroku login
heroku create vonage-whatsapp-app
heroku addons:create heroku-postgresql:mini
heroku addons:create heroku-redis:mini
heroku config:set VONAGE_API_KEY=your_key
heroku config:set VONAGE_API_SECRET=your_secret
# … set other environment variables
git push heroku main

7.2. Deploy to AWS (Elastic Beanstalk)

Install EB CLI:

bash
pip install awsebcli

Initialize and Deploy:

bash
eb init -p node.js vonage-whatsapp-app
eb create vonage-whatsapp-env
eb setenv VONAGE_API_KEY=your_key VONAGE_API_SECRET=your_secret
eb deploy

7.3. Deploy to Google Cloud Run

Build and Deploy:

bash
gcloud builds submit --tag gcr.io/PROJECT_ID/vonage-whatsapp
gcloud run deploy vonage-whatsapp \
  --image gcr.io/PROJECT_ID/vonage-whatsapp \
  --platform managed \
  --region us-central1 \
  --allow-unauthenticated \
  --set-env-vars "VONAGE_API_KEY=your_key,VONAGE_API_SECRET=your_secret"

7.4. CI/CD with GitHub Actions

Create .github/workflows/deploy.yml:

yaml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '22'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Build
        run: npm run build

      - name: Deploy to Heroku
        uses: akhileshns/heroku-deploy@v3.12.14
        with:
          heroku_api_key: ${{secrets.HEROKU_API_KEY}}
          heroku_app_name: "vonage-whatsapp-app"
          heroku_email: "your-email@example.com"

Conclusion

You now have a production-ready WhatsApp integration built with Vonage and NestJS. This implementation includes:

✓ Secure webhook handling with JWT signature verification ✓ Robust error handling and retry logic ✓ Database persistence for message tracking ✓ Queue-based asynchronous processing ✓ Comprehensive logging and monitoring ✓ Rate limiting and security hardening ✓ Deployment configurations for multiple platforms ✓ Automated testing and CI/CD pipelines

Next Steps:

  1. Implement template messages for marketing campaigns
  2. Add support for WhatsApp interactive messages (buttons, lists)
  3. Build analytics dashboard for message metrics
  4. Implement conversation context management
  5. Add multi-language support with i18n
  6. Set up alerting for failed messages
  7. Implement message scheduling functionality
  8. Add support for WhatsApp Business API catalog features

For questions or issues, refer to:

Frequently Asked Questions

How to send WhatsApp messages with Node.js?

Use the Vonage Messages API with a Node.js framework like NestJS. This setup allows you to send WhatsApp messages programmatically by initializing the Vonage Node.js SDK and calling the `messages.send` method with a properly formatted payload including the recipient's number and your message content. This guide provides a step-by-step tutorial on how to implement such a service.

What is the Vonage Messages API?

The Vonage Messages API is a service provided by Vonage (formerly Nexmo) for sending and receiving messages across various channels, including WhatsApp. It handles the complexities of communicating with the WhatsApp platform, providing a simplified interface for developers to integrate messaging into their applications. You can send different types of messages, including text, images, files, and templates.

Why use NestJS for a WhatsApp service?

NestJS offers benefits like structured architecture via modules, dependency injection, and tools for building scalable Node.js applications. These features make the WhatsApp service easier to organize, test, and maintain, especially as the project grows more complex.

What are the prerequisites for the WhatsApp tutorial?

You will need Node.js version 18 or higher, a package manager (npm or yarn), a Vonage API account, ngrok for local development, and a WhatsApp-enabled device for testing. The Vonage API account is required for utilizing their service, ngrok creates a public URL for your local server during testing, and a device is needed for end-to-end verification.

How to set up Vonage API credentials?

Obtain your API key and secret from the Vonage API Dashboard, generate a private key when creating a Vonage application, and create an API key signature secret in Settings for webhook security. These credentials should be stored securely, such as in environment variables (.env file) and never exposed in code repositories.

How to receive WhatsApp messages in my app?

Set up webhooks in your Vonage application dashboard and handle inbound messages in NestJS. Vonage forwards incoming messages to your specified endpoint, which you can handle using a controller and service within your application's logic.

What is ngrok used for in WhatsApp development?

ngrok creates a temporary public URL that tunnels to your locally running NestJS server, allowing Vonage webhooks to reach your development environment. This is important because Vonage needs a public HTTPS endpoint to send webhook requests.

How to handle webhook security for WhatsApp?

Vonage uses JWT signatures to ensure webhooks originate from them. Verify this signature using `@vonage/jwt` package to prevent unauthorized requests from reaching your webhook endpoints. This is critical to prevent security vulnerabilities.

How to handle message status updates?

Use the /webhooks/status endpoint to receive updates on message delivery, read status, or failures. By processing status updates, you gain valuable insights into the message lifecycle, allowing you to keep your application informed of successes or issues.

How to create a Vonage application?

Log into the Vonage API Dashboard, go to 'Applications', and click 'Create a new application'. Provide a name, generate public and private keys (download and securely store the private key), enable the Messages capability, and set the webhook URLs for inbound messages and status updates.

How to use DTOs for validating webhook requests?

Install 'class-validator' and 'class-transformer' packages, define DTO classes with validation decorators, and enable a global `ValidationPipe` in your NestJS application. DTOs enhance data integrity and security by ensuring webhook data conforms to your expected structure.

What is the purpose of a `.env` file?

The `.env` file stores sensitive information like API keys, secrets, and application IDs, allowing you to keep these values out of your codebase. It's important for security best practices and should be added to `.gitignore` to prevent it from being accidentally committed to version control.

Where can I find a working example of the NestJS WhatsApp service?

The complete, working project code from this tutorial is available on a public GitHub repository (linked in the article). You can refer to it as a reference implementation or use it as a starting point for your own WhatsApp project.