code examples

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

Receiving WhatsApp Messages in NestJS via AWS SNS

A guide on setting up a NestJS application to receive WhatsApp messages using AWS SNS and AWS End User Messaging Social.

This guide provides a step-by-step walkthrough for building a production-ready system to receive incoming WhatsApp messages within a Node.js NestJS application. We'll leverage AWS Simple Notification Service (SNS) as the message bus, triggered by AWS End User Messaging Social, which connects directly to your WhatsApp Business Account (WABA).

Project Goal: To create a reliable backend service that listens for incoming WhatsApp messages sent to a specific business number, processes them securely, and makes the message content available within a NestJS application for further action.

Problem Solved: This architecture decouples your application logic from the direct WhatsApp integration complexities, providing a scalable and manageable way to handle incoming messages via standard cloud infrastructure patterns. It enables developers to focus on business logic rather than managing WebSocket connections or Meta's Webhook infrastructure directly.

Technologies Used:

  • Node.js: The runtime environment.
  • NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Chosen for its modular architecture, dependency injection, and built-in support for various application patterns.
  • AWS End User Messaging Social: An AWS service that connects your Meta Business Portfolio (including WABA) to your AWS account, simplifying integration and billing.
  • AWS Simple Notification Service (SNS): A fully managed pub/sub messaging service. Used here to receive notifications from AWS End User Messaging Social when a WhatsApp message arrives.
  • Meta Business Account & WABA: Required to have an official WhatsApp presence for your business.
  • (Optional) AWS CLI & Serverless Application Model (SAM) CLI: For infrastructure setup and deployment automation.

System Architecture:

  1. An end user sends a message via WhatsApp.
  2. The message reaches the Meta/WhatsApp Platform.
  3. Meta forwards the message to AWS End User Messaging Social based on your WABA configuration.
  4. AWS End User Messaging Social publishes a notification to a configured AWS SNS Topic.
  5. The SNS Topic sends an HTTP/S POST notification to your NestJS application's designated HTTPS endpoint.
  6. Your NestJS application receives the notification, validates it, parses the message, and processes it (e.g., saving to a database, triggering application logic).

Prerequisites:

  • An active AWS account with appropriate permissions to manage SNS, IAM, and potentially compute resources (e.g., EC2, Fargate, Lambda) for deployment.
  • A Meta Business Portfolio with a configured WhatsApp Business Account (WABA) and an associated phone number. Follow Meta's instructions if you don't have one.
  • A separate phone with the WhatsApp Messenger app installed for testing (cannot be the same number as the WABA).
  • Node.js (v18 LTS or later recommended) and npm/yarn installed locally.
  • NestJS CLI installed globally: npm install -g @nestjs/cli
  • AWS CLI installed and configured with credentials.
    • Note on Credentials: While the AWS CLI often uses access keys stored locally (~/.aws/credentials), this is generally suitable only for local development. For production environments, strongly prefer using IAM roles assigned to your compute resources (EC2 instances, ECS tasks, Lambda functions). The AWS SDK automatically retrieves credentials from these roles without needing hardcoded keys.
  • A publicly accessible HTTPS endpoint for your deployed NestJS application (required for SNS subscription confirmation). Services like ngrok can be used for local development testing, but a proper deployment is needed for production.

1. Setting up the NestJS Project

Let's initialize a new NestJS project and install necessary dependencies.

  1. Create NestJS Project: Open your terminal and run:

    bash
    nest new whatsapp-sns-nestjs
    cd whatsapp-sns-nestjs
  2. Install Dependencies: We need packages to handle incoming HTTP requests, manage configuration, parse raw text bodies (SNS sends text/plain), validate SNS messages, and potentially make HTTP requests (for subscription confirmation).

    bash
    npm install @nestjs/config dotenv body-parser sns-validator axios
    npm install --save-dev @types/body-parser @types/node
    • @nestjs/config: Manages environment variables.
    • dotenv: Loads environment variables from a .env file (primarily for local development).
    • body-parser: Middleware to parse request bodies. We specifically need its text parser.
    • sns-validator: To verify the authenticity of incoming SNS messages.
    • axios: To make HTTP requests (e.g., confirming SNS subscription).
  3. Environment Variables Setup: Create a .env file in the project root (primarily for local development):

    dotenv
    # .env
    
    # AWS Credentials (ONLY for local testing if IAM roles/profiles aren't used)
    # Avoid committing this file or hardcoding keys in production. Use IAM roles instead.
    # AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID
    # AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY
    AWS_REGION=us-east-1 # Replace with your target AWS region
    
    # Application Port
    PORT=3000
    
    # SNS Topic ARN (Replace this with the actual ARN after creating the topic in AWS)
    SNS_INCOMING_WHATSAPP_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:YourWhatsAppIncomingTopic
    
    # Optional: Log level
    LOG_LEVEL=debug
    • Important: For production environments, avoid bundling .env files or hardcoding AWS credentials. Use IAM roles associated with your compute environment (EC2, Fargate, Lambda) or retrieve secrets from AWS Secrets Manager at runtime. The AWS SDK automatically picks up credentials from standard locations like IAM roles.
  4. Configure ConfigModule: Import and configure ConfigModule in src/app.module.ts to load the .env file.

    typescript
    // src/app.module.ts
    import { Module } from '@nestjs/common';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    import { ConfigModule } from '@nestjs/config';
    import { SnsWebhookModule } from './sns-webhook/sns-webhook.module'; // We'll create this next
    
    @Module({
      imports: [
        ConfigModule.forRoot({
          isGlobal: true, // Makes ConfigService available globally
          envFilePath: '.env',
          // ignoreEnvFile: process.env.NODE_ENV === 'production', // Optional: Ignore .env in production
        }),
        SnsWebhookModule, // Import our webhook module
      ],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}
  5. Enable Raw Body Parsing: SNS sends notifications with Content-Type: text/plain. NestJS, by default, only parses JSON and URL-encoded bodies. We need to enable raw text parsing specifically for the SNS webhook route before the standard NestJS parsers might handle the request.

    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';
    import * as bodyParser from 'body-parser';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule, {
        // Disable NestJS's default body parser globally.
        // We will re-apply specific parsers selectively.
        bodyParser: false,
      });
      const configService = app.get(ConfigService);
      const port = configService.get<number>('PORT', 3000);
      const logger = new Logger('Bootstrap');
    
      // Middleware to parse text/plain specifically for the SNS webhook route.
      // This needs to be applied *before* the general JSON/URL-encoded parsers.
      // Adjust the path '/webhook/sns' if your controller route is different.
      app.use('/webhook/sns', bodyParser.text({ type: 'text/plain' }));
    
      // Re-enable default JSON and URL-encoded parsers for all other routes.
      // Ensure these run *after* the specific text parser for the SNS route.
      // Note: The order of middleware registration matters. Verify this setup works
      // as expected in your specific NestJS version and for other routes.
      app.use(bodyParser.json());
      app.use(bodyParser.urlencoded({ extended: true }));
    
      // Apply global validation pipe (optional but recommended)
      app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
    
      await app.listen(port);
      logger.log(`Application listening on port ${port}`);
      logger.log(`SNS Webhook Endpoint expected at: /webhook/sns`);
    
    }
    bootstrap();
    • Explanation: We disable the default bodyParser during app creation. Then, we selectively apply bodyParser.text() middleware only to the route where we expect SNS notifications (/webhook/sns). Finally, we apply standard JSON and URL-encoded parsers globally, which will handle other routes. This setup relies on the middleware execution order; ensure it functions correctly for all your application's routes.

2. Implementing Core Functionality: The SNS Webhook Listener

We'll create a dedicated module, controller, and service to handle incoming SNS messages.

  1. Generate Module, Controller, Service:

    bash
    nest g module sns-webhook
    nest g controller sns-webhook --no-spec
    nest g service sns-webhook --no-spec
  2. Implement the SNS Webhook Controller (sns-webhook.controller.ts): This controller defines the endpoint that AWS SNS will send HTTP POST requests to.

    typescript
    // src/sns-webhook/sns-webhook.controller.ts
    import { Controller, Post, Body, Headers, Logger, HttpCode, Req, BadRequestException } from '@nestjs/common';
    import { SnsWebhookService } from './sns-webhook.service';
    
    @Controller('webhook/sns')
    export class SnsWebhookController {
      private readonly logger = new Logger(SnsWebhookController.name);
    
      constructor(private readonly snsWebhookService: SnsWebhookService) {}
    
      @Post()
      @HttpCode(200) // SNS expects a 2xx response, send 200 for successful processing or logged errors
      // Note: The request Content-Type is expected to be 'text/plain' from SNS.
      async handleSnsNotification(
        @Headers('x-amz-sns-message-type') messageType: string,
        @Body() rawBody: string, // Receives raw text body due to bodyParser config in main.ts
        @Req() req: any, // Access raw headers if needed via req.headers (e.g., for debugging)
      ) {
        this.logger.debug(`Received SNS notification. Type: ${messageType}`);
        // Log headers for debugging signature verification if needed:
        // this.logger.verbose('Headers:', req.headers);
        // this.logger.verbose('Raw Body:', rawBody);
    
        if (!rawBody || typeof rawBody !== 'string') {
            this.logger.warn('Received empty or non-string body.');
            // Return OK to SNS to prevent retries for empty/malformed requests
            return 'OK';
        }
    
        if (!messageType) {
            this.logger.warn('Missing x-amz-sns-message-type header.');
            // Consider returning a 400 Bad Request if the header is essential
            // For now, return OK to avoid SNS retries, but log it.
            return 'OK';
        }
    
        try {
            // Service handles validation and processing
            await this.snsWebhookService.processSnsMessage(messageType, rawBody);
            return 'OK'; // Acknowledge receipt to SNS
        } catch (error) {
             // Log the specific error from the service
             this.logger.error(`Error processing SNS message: ${error.message}`, error.stack);
    
             // If it's a known bad request (e.g., invalid signature), we might still return 200
             // to prevent SNS retries, as the message is fundamentally invalid.
             // If it's a temporary processing error, consider if a 5xx is appropriate,
             // but be mindful of causing excessive SNS retries.
             // Returning 200 for logged errors is often safer.
             if (error instanceof BadRequestException) {
                 return 'Bad Request Logged';
             }
             return 'Processing Error Logged';
        }
      }
    }
    • Explanation:
      • The @Post() decorator marks the handleSnsNotification method to handle POST requests to /webhook/sns.
      • @HttpCode(200) ensures a 200 OK response is sent back to SNS upon successful handling (or even logged processing errors) to prevent unnecessary retries. SNS treats any 2xx response as success.
      • @Headers('x-amz-sns-message-type') extracts the crucial header indicating if it's a SubscriptionConfirmation or Notification.
      • @Body() rawBody: string receives the raw request body as a string. This works because the bodyParser.text() middleware configured in main.ts specifically handles the /webhook/sns route and makes the raw text available via @Body() when the parameter type is string.
      • The logic is delegated to SnsWebhookService. Error handling is included, aiming to return 200 OK to SNS even for processing errors to avoid retry storms, while logging the actual error.
  3. Implement the SNS Webhook Service (sns-webhook.service.ts): This service contains the logic for validating the SNS message signature and processing different message types.

    typescript
    // src/sns-webhook/sns-webhook.service.ts
    import { Injectable, Logger, BadRequestException } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import axios from 'axios';
    import { MessageValidator } from 'sns-validator'; // Use the validator
    
    // Define a basic type for parsed SNS messages for better type safety than 'any'
    // Note: The actual structure can be more complex; refine as needed.
    interface SnsMessage {
      Type: string;
      MessageId: string;
      TopicArn: string;
      Subject?: string;
      Message: string; // This is often a JSON string itself
      Timestamp: string;
      SignatureVersion: string;
      Signature: string;
      SigningCertURL: string;
      UnsubscribeURL?: string; // Present in Notification
      SubscribeURL?: string; // Present in SubscriptionConfirmation
      Token?: string; // Present in SubscriptionConfirmation
      [key: string]: unknown; // Allow other potential fields
    }
    
    @Injectable()
    export class SnsWebhookService {
      private readonly logger = new Logger(SnsWebhookService.name);
      // Initialize validator with encoding preference (optional, default is utf8)
      private readonly validator = new MessageValidator('utf8');
    
      constructor(private configService: ConfigService) {}
    
      async processSnsMessage(messageType: string, rawBody: string): Promise<void> {
        let message: SnsMessage;
    
        // 1. Parse the raw body into a JSON object
        try {
            message = JSON.parse(rawBody);
            if (typeof message !== 'object' || message === null) {
                throw new Error('Parsed body is not an object.');
            }
        } catch (error) {
            this.logger.error(`Failed to parse SNS message body: ${error.message}`, rawBody);
            throw new BadRequestException('Invalid SNS message format.');
        }
    
        // 2. Validate the SNS message signature (CRITICAL SECURITY STEP)
        try {
            // The validator modifies the message object in place if valid,
            // but we await the promise for error handling.
            await this.validateSnsMessage(message);
            this.logger.debug('SNS message signature validated successfully.');
        } catch (error) {
            this.logger.error(`SNS message validation failed: ${error.message}`, error.stack);
            // Do not process invalid messages
            throw new BadRequestException(`Invalid SNS signature: ${error.message}`);
        }
    
        // 3. Handle based on message type (use the type from the validated message body)
        switch (message.Type) { // Use message.Type after validation
          case 'SubscriptionConfirmation':
            await this.handleSubscriptionConfirmation(message);
            break;
          case 'Notification':
            await this.handleNotification(message);
            break;
          default:
            this.logger.warn(`Received unknown SNS message type: ${message.Type}`);
            // Acknowledge but don't process unknown types
            break;
        }
      }
    
      // Use the defined SnsMessage interface for better type checking
      private validateSnsMessage(message: SnsMessage): Promise<SnsMessage> {
        return new Promise((resolve, reject) => {
          // The sns-validator library expects a plain object.
          this.validator.validate(message, (err, validatedMessage) => {
            if (err) {
              // Log the specific validation error
              this.logger.warn(`SNS validation error details: ${err.message}`, err);
              return reject(err); // Reject the promise on error
            }
            // Type assertion might be needed if validator doesn't return a strongly typed object
            resolve(validatedMessage as SnsMessage);
          });
        });
      }
    
      private async handleSubscriptionConfirmation(message: SnsMessage): Promise<void> {
        const subscribeUrl = message.SubscribeURL;
        if (!subscribeUrl || typeof subscribeUrl !== 'string') {
            this.logger.error('SubscriptionConfirmation message missing or invalid SubscribeURL.', message);
            throw new BadRequestException('Missing or invalid SubscribeURL in SubscriptionConfirmation.');
        }
    
        this.logger.log(`Received SubscriptionConfirmation. Visiting SubscribeURL to confirm...`);
        try {
          // Make a GET request to the SubscribeURL to confirm the subscription
          const response = await axios.get(subscribeUrl);
          this.logger.log(`Successfully confirmed SNS topic subscription via GET request. Status: ${response.status}`);
        } catch (error) {
          const errorMessage = error.response ? `Status ${error.response.status}: ${error.response.data}` : error.message;
          this.logger.error(`Failed to confirm SNS subscription via GET request: ${errorMessage}`, error.stack);
          // This is critical - if confirmation fails, you won't receive notifications.
          // Implement retry logic or alerting here if needed.
          throw new Error('Failed to confirm SNS subscription.');
        }
      }
    
      private async handleNotification(message: SnsMessage): Promise<void> {
        this.logger.log(`Received Notification. Message ID: ${message.MessageId}`);
        // this.logger.debug('Full Notification Body:', JSON.stringify(message, null, 2));
    
        try {
          // The actual WhatsApp message content is within the 'Message' field,
          // which is itself a JSON string that needs parsing.
          const messageContentString = message.Message;
          if (!messageContentString || typeof messageContentString !== 'string') {
            this.logger.warn('Notification received but Message field is empty, missing, or not a string.', message);
            return; // Nothing to process
          }
    
          const notificationPayload = JSON.parse(messageContentString);
          if (typeof notificationPayload !== 'object' || notificationPayload === null) {
              throw new Error('Parsed Message field content is not an object.');
          }
    
          /*
           * Example structure of 'notificationPayload' (parsed from message.Message):
           * (Structure may vary slightly based on message type and Meta API version)
           * Consult AWS End User Messaging Social / Meta Cloud API docs for exact schema.
           * {
           *   ""object"": ""whatsapp_business_account"",
           *   ""entry"": [{
           *     ""id"": ""WABA_ID"",
           *     ""changes"": [{
           *       ""value"": {
           *         ""messaging_product"": ""whatsapp"",
           *         ""metadata"": {
           *           ""display_phone_number"": ""YOUR_WABA_NUMBER"",
           *           ""phone_number_id"": ""PHONE_NUMBER_ID""
           *         },
           *         ""contacts"": [{ ""profile"": { ""name"": ""USER_NAME"" }, ""wa_id"": ""USER_WHATSAPP_ID"" }],
           *         ""messages"": [{
           *           ""from"": ""USER_WHATSAPP_NUMBER"", // e.g., ""14155552671""
           *           ""id"": ""WHATSAPP_MESSAGE_ID"", // e.g., ""wamid.HBg...""
           *           ""timestamp"": ""1678886400"", // Unix timestamp string
           *           ""text"": { ""body"": ""Hello from WhatsApp!"" }, // If it's a text message
           *           ""type"": ""text"" // Can be ""image"", ""audio"", ""document"", ""location"", ""interactive"", etc.
           *           // Other fields for different message types (e.g., image: { id, mime_type }, location: { latitude, longitude })
           *         }]
           *       },
           *       ""field"": ""messages""
           *     }]
           *   }]
           * }
           */
    
          this.logger.log('Successfully parsed notification payload from Message field.');
          // Avoid logging potentially sensitive message content unless debugging
          // this.logger.debug(`Parsed WhatsApp Payload: ${JSON.stringify(notificationPayload)}`);
    
          // --- Your Business Logic Here ---
          // Example: Extract sender and text from the expected structure
          // Note: Accessing nested properties requires careful checking as structure can vary
          const messageEntry = notificationPayload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0];
    
          if (messageEntry && typeof messageEntry === 'object') {
            const sender = messageEntry.from; // e.g., ""14155552671""
            const messageType = messageEntry.type; // e.g., ""text""
            const text = messageType === 'text' ? messageEntry.text?.body : null; // Safely access text body
    
            if (sender && typeof sender === 'string' && messageType && typeof messageType === 'string') {
              this.logger.log(`Processing '${messageType}' message from ${sender}`);
              if (text && typeof text === 'string') {
                  this.logger.log(` -> Text: ""${text}""`);
              }
              // TODO: Implement your logic (e.g., save to DB, trigger workflow)
              // Example: this.messageProcessingService.handleIncomingWhatsApp(sender, messageType, text, messageEntry);
            } else {
              this.logger.warn('Could not extract valid sender or message type from notification payload structure.', messageEntry);
            }
          } else {
              this.logger.warn('Could not find message details in the expected path within notification payload.', notificationPayload);
          }
          // ---------------------------------
    
        } catch (error) {
            this.logger.error(`Failed to parse or process SNS Notification Message field: ${error.message}`, error.stack);
            // Decide if this error should prevent acknowledgment to SNS
            // Generally, log it and acknowledge (by not throwing here) to avoid retries unless it's recoverable.
            // Throwing here will cause the controller to return an error response.
            throw new Error(`Failed to process Notification payload: ${error.message}`);
        }
      }
    }
    • Explanation:
      • The service parses the rawBody.
      • Crucially, it uses sns-validator's validate method to verify the message's signature against the certificate provided by AWS. This prevents attackers from sending fake SNS messages to your endpoint. Never skip this step.
      • It handles SubscriptionConfirmation by extracting the SubscribeURL and making an HTTP GET request using axios to confirm the endpoint ownership to AWS.
      • It handles Notification by parsing the Message field (which contains the actual WhatsApp payload from AWS End User Messaging Social as a JSON string). An example structure and extraction logic are provided. This is where you integrate your application's business logic. You'll need to adapt the extraction logic based on the exact payload structure you receive from AWS/Meta for various message types.

3. Building the API Layer

The API layer is the SNS Webhook endpoint we just created (POST /webhook/sns). It doesn't require traditional REST API authentication because security is handled by:

  1. HTTPS: The endpoint must be served over HTTPS. SNS will not send to HTTP endpoints.
  2. SNS Signature Verification: The sns-validator logic in SnsWebhookService ensures only legitimate messages from your configured AWS SNS topic are processed.
  3. (Optional) Obscurity: While not true security, the webhook URL isn't typically guessable if made complex.

Testing the Endpoint (Simulated):

You can simulate an SNS message using curl or Postman, but signature verification will fail unless you craft a valid signed message (which is complex). It's better to test the processing logic directly or wait for the full integration test.

However, you can test the path and basic parsing (before validation):

bash
# NOTE: Signature validation WILL fail with this simple curl command.
# This only tests if the route exists and the basic text parsing works.
# Replace localhost:3000 with your actual host/port if needed.

curl -X POST http://localhost:3000/webhook/sns \
-H "Content-Type: text/plain" \
-H "x-amz-sns-message-type: Notification" \
-d '{
  "Type" : "Notification",
  "MessageId" : "some-fake-message-id",
  "TopicArn" : "arn:aws:sns:us-east-1:123456789012:YourWhatsAppIncomingTopic",
  "Subject" : "Optional Subject",
  "Message" : "{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"FAKE_WABA_ID\",\"changes\":[{\"value\":{\"messaging_product\":\"whatsapp\",\"metadata\":{\"display_phone_number\":\"+15550001234\",\"phone_number_id\":\"FAKE_PHONE_ID\"},\"contacts\":[{\"profile\":{\"name\":\"Test User\"},\"wa_id\":\"14155552671\"}],\"messages\":[{\"from\":\"14155552671\",\"id\":\"wamid.FAKE_ID\",\"timestamp\":\"1678886400\",\"text\":{\"body\":\"Hello from curl!\"},\"type\":\"text\"}]},\"field\":\"messages\"}]}]}",
  "Timestamp" : "2025-04-20T12:00:00.000Z",
  "SignatureVersion" : "1",
  "Signature" : "FAKE_SIGNATURE",
  "SigningCertURL" : "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-xxxxxxxxxxxxxxx.pem",
  "UnsubscribeURL" : "https://sns.us-east-1.amazonaws.com/unsubscribe..."
}'

# Expected Output (in NestJS logs, followed by the failure message):
# DEBUG [SnsWebhookController] Received SNS notification. Type: Notification
# WARN [SnsWebhookService] SNS validation error details: Invalid signature. ...
# ERROR [SnsWebhookService] SNS message validation failed: Invalid signature. ...
# ERROR [SnsWebhookController] Error processing SNS message: Invalid SNS signature: Invalid signature. ...

4. Integrating with AWS SNS and End User Messaging Social

This is where we configure the AWS services to talk to our NestJS application.

Step 4.1: Create the SNS Topic

  1. Navigate to the Amazon SNS console in your chosen AWS Region.
  2. Click Topics in the left navigation pane.
  3. Click Create topic.
  4. Select Standard as the type (FIFO is not needed here).
  5. Enter a Name for your topic (e.g., whatsapp-incoming-messages).
  6. Scroll down and click Create topic.
  7. Once created, copy the Topic ARN. It will look like arn:aws:sns:us-east-1:123456789012:whatsapp-incoming-messages.
  8. Paste this ARN into your .env file for the SNS_INCOMING_WHATSAPP_TOPIC_ARN variable (or configure it via environment variables in your deployment).

Step 4.2: Configure AWS End User Messaging Social

This step connects your WABA to the SNS topic. Follow the AWS documentation closely, as the UI might change. Reference: AWS Blog Post on WhatsApp Integration

  1. Navigate to the AWS End User Messaging Social console (ensure you select your correct AWS region in the console).
  2. Click Add WhatsApp phone number.
  3. Click Launch Facebook portal. This will open a pop-up window from Meta.
  4. Follow the instructions in the Meta pop-up:
    • Log in to your Facebook/Meta account that manages your Meta Business Portfolio.
    • Select the Meta Business Account you want to use.
    • Select the WhatsApp Business Account (WABA) you want to integrate.
    • Confirm the permissions AWS requires.
  5. Once the connection is established, you should be redirected back to the AWS console, and your WABA should appear.
  6. Configure the WABA integration within AWS End User Messaging Social:
    • Select the newly added WABA phone number.
    • Find the section for configuring incoming message notifications (the exact naming might vary, look for ""Incoming messages"" or similar).
    • Choose Forward to Amazon SNS topic.
    • Select or paste the ARN of the SNS topic you created in Step 4.1.
    • Grant IAM Permissions: Ensure AWS End User Messaging Social can publish to your SNS topic. The console might guide you to automatically update the topic's Access Policy. If not, you must manually edit the SNS topic's Access Policy (found under the Access policy tab in the SNS topic details). Add or modify a statement to allow the eum.amazonaws.com service principal to perform the sns:Publish action on your specific topic ARN.
      • Example Policy Statement:
        json
        {
          ""Sid"": ""AllowEUMSPublishToSNSTopic"",
          ""Effect"": ""Allow"",
          ""Principal"": {
            ""Service"": ""eum.amazonaws.com""
          },
          ""Action"": ""sns:Publish"",
          ""Resource"": ""YOUR_SNS_TOPIC_ARN"", // Replace with your actual Topic ARN
          ""Condition"": {
            ""StringEquals"": {
              ""aws:SourceAccount"": ""YOUR_AWS_ACCOUNT_ID"" // Replace with your Account ID
            }
          }
        }
    • Save the configuration.

Step 4.3: Deploy Your NestJS Application

Your NestJS application needs to be running and accessible via a public HTTPS URL for SNS to confirm the subscription and send notifications.

  1. Choose a Deployment Strategy: Options include AWS EC2, AWS Fargate, AWS Elastic Beanstalk, AWS App Runner, or potentially AWS Lambda (though handling webhooks in Lambda requires specific patterns like Function URLs or API Gateway integration).
  2. Configure HTTPS: Ensure your deployment includes a load balancer (like Application Load Balancer) or a reverse proxy (like Nginx, Caddy) that terminates TLS/SSL and provides a public HTTPS URL. Services like App Runner or Elastic Beanstalk often handle this automatically.
  3. Deploy: Deploy your application using your chosen method. Note the full public HTTPS URL including the webhook path (e.g., https://your-app-domain.com/webhook/sns).

Step 4.4: Create the SNS Subscription

  1. Navigate back to the Amazon SNS console.
  2. Go to Topics and select the topic you created (e.g., whatsapp-incoming-messages).
  3. Click the Create subscription button.
  4. Topic ARN: Should be pre-filled.
  5. Protocol: Select HTTPS.
  6. Endpoint: Enter the full public HTTPS URL of your deployed NestJS webhook listener (e.g., https://your-app-domain.com/webhook/sns).
  7. Enable raw message delivery: Keep this unchecked. We are handling the standard JSON structure SNS sends, which includes metadata and the message content within the Message field. Raw delivery would only send the content of the Message field directly, skipping metadata and signature details needed for validation.
  8. Click Create subscription.

Step 4.5: Confirm the Subscription

  • AWS SNS will immediately send a SubscriptionConfirmation message (with Type: SubscriptionConfirmation) to your HTTPS endpoint.
  • Your running NestJS application should receive this message.
  • The SnsWebhookService's handleSubscriptionConfirmation logic will parse the message, validate its signature, extract the SubscribeURL, and automatically make a GET request to that URL.
  • Check your NestJS application logs for messages indicating the receipt of the SubscriptionConfirmation and the attempt to visit the SubscribeURL. Look for success or error messages related to the confirmation GET request.
  • In the AWS SNS console, the subscription status should change from ""Pending confirmation"" to ""Confirmed"". If it doesn't confirm within a minute or two, check:
    • Your application logs for errors (parsing, validation, network errors calling SubscribeURL).
    • Ensure the endpoint URL entered in the subscription is correct and publicly accessible via HTTPS (use tools like curl or an online SSL checker).
    • Verify firewall rules, security groups, or network ACLs are not blocking requests from SNS servers to your endpoint.

5. Error Handling, Logging, and Retry Mechanisms

  • Error Handling: The provided code includes basic try...catch blocks. Log errors clearly using NestJS Logger. Distinguish between errors that should prevent acknowledgment to SNS (e.g., invalid signature - return 4xx or handle gracefully but log severity) and processing errors where you might still want to acknowledge receipt (return 2xx) but log the failure for investigation (e.g., database connection issue). Use specific exceptions like BadRequestException where appropriate.
  • Logging: Use the built-in NestJS Logger service. Configure log levels via environment variables (e.g., LOG_LEVEL). Log key events: message received (DEBUG), validation success/failure (DEBUG/ERROR), confirmation attempts (INFO/ERROR), notification processing start/end/error (INFO/ERROR), extracted data (DEBUG or INFO, be careful with PII). Log the MessageId from SNS notifications to correlate logs across systems. Consider structured logging (JSON format) for easier parsing in log aggregation tools (like CloudWatch Logs Insights, Datadog, Splunk).
  • Retry Mechanisms (SNS): SNS automatically retries delivery to HTTPS endpoints if they fail (return a non-2xx status code or time out). The default retry policy includes multiple retries over several hours with exponential backoff. You can customize this policy or configure a dead-letter queue (DLQ) on the SNS subscription to capture messages that consistently fail delivery after all retries.
    • To configure a DLQ: Create an SQS queue to serve as the DLQ. Then, edit your SNS subscription settings. Under Redrive policy (dead-letter queue), enable it and select the SQS queue you created. This allows for later analysis or reprocessing of failed messages without losing them. See AWS Docs on SNS DLQs.

6. Database Schema and Data Layer (Conceptual)

While not implemented in the core guide, you would typically store incoming messages or related data.

  1. Schema: Consider a table like whatsapp_messages with columns such as:
    • id (Primary Key, e.g., UUID or auto-increment)
    • sns_message_id (VARCHAR, Unique - from SNS Notification MessageId, useful for idempotency/debugging)
    • whatsapp_message_id (VARCHAR, Unique - from WhatsApp payload messages[0].id)
    • sender_number (VARCHAR - from WhatsApp payload messages[0].from)
    • recipient_number (VARCHAR - your WABA number, from metadata.display_phone_number)
    • message_timestamp (TIMESTAMP WITH TIME ZONE - derived from WhatsApp payload messages[0].timestamp)
    • message_type (VARCHAR - from WhatsApp payload messages[0].type, e.g., 'text', 'image', 'interactive')
    • message_body (TEXT - for text messages, from messages[0].text.body, nullable)
    • media_id (VARCHAR - for media messages, from messages[0].image.id, etc., nullable)
    • media_mime_type (VARCHAR - if applicable, nullable)
    • raw_payload (JSONB or TEXT - store the parsed notificationPayload for future reference or reprocessing)
    • status (VARCHAR - e.g., 'received', 'processing', 'processed', 'failed')
    • created_at (TIMESTAMP WITH TIME ZONE - record creation time)
    • updated_at (TIMESTAMP WITH TIME ZONE - record update time)
  2. Data Layer: Implement a NestJS service (e.g., MessagePersistenceService) injected into SnsWebhookService to handle database interactions using an ORM like TypeORM or Prisma, or a database client. Ensure database operations are handled asynchronously and include error handling. Consider idempotency checks based on whatsapp_message_id or sns_message_id to avoid processing duplicate messages if SNS retries occur after successful processing but before a successful response was sent.

Frequently Asked Questions

How to receive WhatsApp messages in NestJS?

Receive WhatsApp messages in your NestJS application by integrating with AWS SNS and End User Messaging Social. This involves setting up an SNS topic, connecting your WhatsApp Business Account (WABA) through AWS End User Messaging Social, and configuring your NestJS application to listen for incoming messages via an HTTPS webhook endpoint subscribed to the SNS topic. This architecture decouples your application logic from direct WhatsApp integration complexities.

What is AWS End User Messaging Social used for with WhatsApp?

AWS End User Messaging Social connects your Meta Business Portfolio, including your WhatsApp Business Account (WABA), to your AWS account. It simplifies the integration of WhatsApp with AWS services, streamlines billing, and acts as a bridge between the Meta/WhatsApp platform and AWS infrastructure like SNS for receiving incoming messages.

Why use AWS SNS for WhatsApp messages in NestJS?

AWS SNS provides a scalable and manageable way to handle incoming WhatsApp messages by acting as a message bus. It decouples your NestJS application from the direct complexities of managing WebSocket connections or Meta's Webhook infrastructure, allowing developers to focus on business logic.

When should I use IAM roles for AWS credentials?

Always use IAM roles for AWS credentials in production environments. Avoid hardcoding credentials in your application code or storing them in .env files, which pose security risks. IAM roles assigned to your compute resources allow the AWS SDK to automatically retrieve credentials securely.

How to confirm the SNS subscription for WhatsApp messages?

After creating the SNS subscription, AWS will send a 'SubscriptionConfirmation' message to your NestJS application's webhook endpoint. Your application must extract the 'SubscribeURL' from this message and make an HTTP GET request to that URL. This confirms ownership of the endpoint and enables SNS to send notifications.

What is the role of body-parser in receiving WhatsApp messages?

The body-parser middleware is essential for parsing the raw text body of incoming SNS messages, which contain the WhatsApp data. Since SNS sends messages with 'Content-Type: text/plain', configuring body-parser to handle this format is crucial for your NestJS application to correctly receive and process the message content.

How to handle different WhatsApp message types in NestJS?

The 'Message' field within the SNS notification contains the WhatsApp payload as a JSON string, including the message type (e.g., 'text', 'image', 'interactive'). Parse this JSON string in your NestJS application to access the message type and handle each type appropriately based on your application's logic. The guide provides an example of extracting the sender, message type, and text content.

What is the purpose of SNS message signature validation?

SNS message signature validation is a crucial security measure to ensure that incoming messages are genuinely from AWS and not forged by attackers. The 'sns-validator' library verifies the message signature against the certificate provided by AWS, preventing the processing of fraudulent messages. Never skip this step.

How to handle errors when receiving WhatsApp messages via SNS?

Implement robust error handling using try-catch blocks and the NestJS Logger to log errors effectively. Distinguish between errors that prevent acknowledgment to SNS (like invalid signatures) and processing failures. For the latter, acknowledge receipt to avoid excessive retries but log the error for investigation. Consider using dead-letter queues (DLQs) for messages that consistently fail delivery.

What database schema should I use for storing WhatsApp messages?

A suggested schema includes columns for various message attributes, including SNS and WhatsApp message IDs, sender and recipient numbers, timestamps, message type and body, media information (if applicable), raw payload, processing status, and creation/update timestamps. This allows for structured storage and retrieval of incoming WhatsApp messages and related data.

Can I test the SNS webhook locally before deployment?

You can test the basic route and parsing functionality with tools like curl or Postman, but proper signature verification requires a valid signed message from SNS, typically only possible in a real integration scenario. Focus on unit testing the processing logic or testing the fully deployed setup for comprehensive testing.

How to grant End User Messaging Social permission to publish to SNS?

You must grant AWS End User Messaging Social permission to publish to your SNS topic by modifying the topic's Access Policy. Add a statement allowing the 'eum.amazonaws.com' service principal to perform the 'sns:Publish' action on your specific topic ARN. Include a condition to restrict access based on your AWS account ID for enhanced security.

What is the 'Message' field in an SNS notification for WhatsApp?

The 'Message' field in the SNS notification contains the actual WhatsApp message payload as a JSON string. This string must be parsed to access the message content, including the sender's number, the message text, and other metadata. The article includes an example of the 'Message' field's structure and how to parse it within your NestJS application.

Why is HTTPS required for the NestJS webhook endpoint?

HTTPS is mandatory for the NestJS webhook endpoint because SNS requires secure communication for delivering messages. SNS will not send notifications to HTTP endpoints. This ensures message confidentiality and integrity in transit, protecting sensitive data.