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
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.
-
Create NestJS Project: Open your terminal and run:
nest 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).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 itstext
parser.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
.env
file in the project root (primarily for local development):# .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.
- Important: For production environments, avoid bundling
-
Configure ConfigModule: Import and configure
ConfigModule
insrc/app.module.ts
to load the.env
file.// 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.// 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 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:
nest 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.// 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 thehandleSnsNotification
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 aSubscriptionConfirmation
orNotification
.@Body() rawBody: string
receives the raw request body as a string. This works because thebodyParser.text()
middleware configured inmain.ts
specifically handles the/webhook/sns
route 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.// 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
'svalidate
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 theSubscribeURL
and making an HTTP GET request usingaxios
to confirm the endpoint ownership to AWS. - It handles
Notification
by parsing theMessage
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.
- 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-validator
logic inSnsWebhookService
ensures 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
.env
file for theSNS_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
- 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.com
service principal to perform thesns:Publish
action on your specific topic ARN.- Example Policy Statement:
{ ""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
Message
field. Raw delivery would only send the content of theMessage
field directly, skipping metadata and signature details needed for validation. - Click Create subscription.
Step 4.5: Confirm the Subscription
- AWS SNS will immediately send a
SubscriptionConfirmation
message (withType: SubscriptionConfirmation
) to your HTTPS endpoint. - Your running NestJS application should receive this message.
- The
SnsWebhookService
'shandleSubscriptionConfirmation
logic 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
SubscriptionConfirmation
and 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
curl
or 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...catch
blocks. 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 likeBadRequestException
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
orINFO
, be careful with PII). Log theMessageId
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.
- 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 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 parsednotificationPayload
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)
- Data Layer: Implement a NestJS service (e.g.,
MessagePersistenceService
) injected intoSnsWebhookService
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 onwhatsapp_message_id
orsns_message_id
to avoid processing duplicate messages if SNS retries occur after successful processing but before a successful response was sent.