code examples
code examples
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:
- An end user sends a message via WhatsApp.
- The message reaches the Meta/WhatsApp Platform.
- Meta forwards the message to AWS End User Messaging Social based on your WABA configuration.
- AWS End User Messaging Social publishes a notification to a configured AWS SNS Topic.
- The SNS Topic sends an HTTP/S POST notification to your NestJS application's designated HTTPS endpoint.
- 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.
- Note on Credentials: While the AWS CLI often uses access keys stored locally (
- A publicly accessible HTTPS endpoint for your deployed NestJS application (required for SNS subscription confirmation). Services like
ngrokcan 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.
-
Create NestJS Project: Open your terminal and run:
bashnest new whatsapp-sns-nestjs cd whatsapp-sns-nestjs -
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).bashnpm 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.envfile (primarily for local development).body-parser: Middleware to parse request bodies. We specifically need itstextparser.sns-validator: To verify the authenticity of incoming SNS messages.axios: To make HTTP requests (e.g., confirming SNS subscription).
-
Environment Variables Setup: Create a
.envfile 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
.envfiles 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.
- Important: For production environments, avoid bundling
-
Configure ConfigModule: Import and configure
ConfigModuleinsrc/app.module.tsto load the.envfile.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 {} -
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
bodyParserduring app creation. Then, we selectively applybodyParser.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.
- Explanation: We disable the default
2. Implementing Core Functionality: The SNS Webhook Listener
We'll create a dedicated module, controller, and service to handle incoming SNS messages.
-
Generate Module, Controller, Service:
bashnest g module sns-webhook nest g controller sns-webhook --no-spec nest g service sns-webhook --no-spec -
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 thehandleSnsNotificationmethod 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 aSubscriptionConfirmationorNotification.@Body() rawBody: stringreceives the raw request body as a string. This works because thebodyParser.text()middleware configured inmain.tsspecifically handles the/webhook/snsroute and makes the raw text available via@Body()when the parameter type isstring.- 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.
- The
- Explanation:
-
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'svalidatemethod 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
SubscriptionConfirmationby extracting theSubscribeURLand making an HTTP GET request usingaxiosto confirm the endpoint ownership to AWS. - It handles
Notificationby parsing theMessagefield (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.
- The service parses the
- Explanation:
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:
- HTTPS: The endpoint must be served over HTTPS. SNS will not send to HTTP endpoints.
- SNS Signature Verification: The
sns-validatorlogic inSnsWebhookServiceensures only legitimate messages from your configured AWS SNS topic are processed. - (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):
# 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
- Navigate to the Amazon SNS console in your chosen AWS Region.
- Click Topics in the left navigation pane.
- Click Create topic.
- Select Standard as the type (FIFO is not needed here).
- Enter a Name for your topic (e.g.,
whatsapp-incoming-messages). - Scroll down and click Create topic.
- Once created, copy the Topic ARN. It will look like
arn:aws:sns:us-east-1:123456789012:whatsapp-incoming-messages. - Paste this ARN into your
.envfile for theSNS_INCOMING_WHATSAPP_TOPIC_ARNvariable (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
- Navigate to the AWS End User Messaging Social console (ensure you select your correct AWS region in the console).
- Click Add WhatsApp phone number.
- Click Launch Facebook portal. This will open a pop-up window from Meta.
- 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.
- Once the connection is established, you should be redirected back to the AWS console, and your WABA should appear.
- 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.comservice principal to perform thesns:Publishaction 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 } } }
- Example Policy Statement:
- 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.
- 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).
- 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.
- 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
- Navigate back to the Amazon SNS console.
- Go to Topics and select the topic you created (e.g.,
whatsapp-incoming-messages). - Click the Create subscription button.
- Topic ARN: Should be pre-filled.
- Protocol: Select HTTPS.
- Endpoint: Enter the full public HTTPS URL of your deployed NestJS webhook listener (e.g.,
https://your-app-domain.com/webhook/sns). - 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
Messagefield. Raw delivery would only send the content of theMessagefield directly, skipping metadata and signature details needed for validation. - Click Create subscription.
Step 4.5: Confirm the Subscription
- AWS SNS will immediately send a
SubscriptionConfirmationmessage (withType: SubscriptionConfirmation) to your HTTPS endpoint. - Your running NestJS application should receive this message.
- The
SnsWebhookService'shandleSubscriptionConfirmationlogic will parse the message, validate its signature, extract theSubscribeURL, and automatically make a GET request to that URL. - Check your NestJS application logs for messages indicating the receipt of the
SubscriptionConfirmationand the attempt to visit theSubscribeURL. 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
curlor an online SSL checker). - Verify firewall rules, security groups, or network ACLs are not blocking requests from SNS servers to your endpoint.
- Your application logs for errors (parsing, validation, network errors calling
5. Error Handling, Logging, and Retry Mechanisms
- Error Handling: The provided code includes basic
try...catchblocks. Log errors clearly using NestJSLogger. 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 likeBadRequestExceptionwhere appropriate. - Logging: Use the built-in NestJS
Loggerservice. 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 (DEBUGorINFO, be careful with PII). Log theMessageIdfrom 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.
- Schema: Consider a table like
whatsapp_messageswith columns such as:id(Primary Key, e.g., UUID or auto-increment)sns_message_id(VARCHAR, Unique - from SNS NotificationMessageId, useful for idempotency/debugging)whatsapp_message_id(VARCHAR, Unique - from WhatsApp payloadmessages[0].id)sender_number(VARCHAR - from WhatsApp payloadmessages[0].from)recipient_number(VARCHAR - your WABA number, frommetadata.display_phone_number)message_timestamp(TIMESTAMP WITH TIME ZONE - derived from WhatsApp payloadmessages[0].timestamp)message_type(VARCHAR - from WhatsApp payloadmessages[0].type, e.g., 'text', 'image', 'interactive')message_body(TEXT - for text messages, frommessages[0].text.body, nullable)media_id(VARCHAR - for media messages, frommessages[0].image.id, etc., nullable)media_mime_type(VARCHAR - if applicable, nullable)raw_payload(JSONB or TEXT - store the parsednotificationPayloadfor 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)
- Data Layer: Implement a NestJS service (e.g.,
MessagePersistenceService) injected intoSnsWebhookServiceto 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 onwhatsapp_message_idorsns_message_idto 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.