Tracking the delivery status of SMS messages is crucial for applications relying on timely communication. Simply sending a message isn't enough; knowing whether it reached the recipient's handset successfully, failed, or encountered an issue is vital for reliable workflows, user experience, and troubleshooting.
This guide provides a complete walkthrough for building a production-ready system within a Node.js NestJS application to send SMS messages via AWS Simple Notification Service (SNS) and receive real-time delivery status callbacks directly from SNS. We will configure AWS SNS to push status updates to a dedicated SNS topic, which will then notify a secure HTTPS endpoint in our NestJS application.
Project Goals:
- Send SMS: Implement a service in NestJS to send SMS messages using the AWS SDK v3 for SNS.
- Configure SNS for Callbacks: Set up AWS SNS SMS preferences to publish delivery status notifications (successes and failures) to a designated SNS topic.
- Receive Callbacks: Create a secure NestJS controller endpoint (the ""callback endpoint"") to receive HTTPS notifications from the SNS topic.
- Process Status Updates: Validate incoming SNS messages, parse the delivery status, and update the application's state (e.g., log the status or update a database).
- Production Readiness: Incorporate essential practices like configuration management, error handling, security (signature verification), logging, and deployment considerations.
Technologies Used:
- Node.js: JavaScript runtime environment.
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications.
- TypeScript: Superset of JavaScript adding static types.
- AWS SNS: Managed messaging service for sending SMS and managing notifications.
- AWS SDK for JavaScript v3: To interact with AWS services.
- AWS IAM: For managing permissions required by SNS.
- (Optional) Database: Such as PostgreSQL or MongoDB via TypeORM/Prisma to persist message status.
- (Optional) Docker: For containerization and deployment consistency.
System Architecture:
sequenceDiagram
participant Client
participant NestJS API
participant AWS SNS
participant SMS Recipient
participant Status SNS Topic
participant (Optional) Database
Client->>+NestJS API: POST /sms/send (phone, message)
NestJS API->>+AWS SNS: PublishCommand(PhoneNumber, Message)
AWS SNS-->>-NestJS API: { MessageId: '...' }
NestJS API-->>Client: { success: true, messageId: '...' }
Note over NestJS API, (Optional) Database: Record message details (messageId, status='SUBMITTED')
AWS SNS->>+SMS Recipient: Deliver SMS
SMS Recipient-->>-AWS SNS: Delivery Acknowledgment/Failure
AWS SNS->>+Status SNS Topic: Publish Delivery Status Notification
Status SNS Topic->>+NestJS API: POST /sms/status/callback (SNS Notification)
NestJS API->>NestJS API: Verify SNS Message Signature
NestJS API->>NestJS API: Parse Status (DELIVERED/FAILED)
Note over NestJS API, (Optional) Database: Update message status (messageId, newStatus)
NestJS API-->>-Status SNS Topic: HTTP 200 OK
Note: Ensure the Mermaid diagram renders correctly on your publishing platform. Verification may be required.
Prerequisites:
- An AWS account with IAM user credentials configured locally (via AWS profiles or environment variables) or via instance roles if deploying to EC2/ECS.
- Node.js (LTS version recommended) and npm/yarn installed.
- NestJS CLI installed (
npm install -g @nestjs/cli
). - Basic familiarity with NestJS concepts (modules, controllers, services).
- Access to the AWS Management Console or AWS CLI for setup.
- A publicly accessible HTTPS endpoint for the callback receiver. Why? AWS SNS requires a public HTTPS URL to send notifications (callbacks) to. During development, Ngrok is a useful tool to create a secure tunnel from a public URL to your local machine. Important: Ngrok's free tier provides temporary URLs and has limitations; it is suitable only for development and testing, not for production environments. For production, you'll need a deployed application with a stable public HTTPS domain.
By the end of this guide, you'll have a robust NestJS application capable of sending SMS messages and reliably tracking their delivery status through direct callbacks from AWS SNS.
1. Setting up the NestJS Project
Let's initialize a new NestJS project and install the necessary dependencies.
1. Create NestJS Project:
Open your terminal and run:
nest new nestjs-sns-callbacks
cd nestjs-sns-callbacks
Choose your preferred package manager (npm or yarn) when prompted.
2. Install Dependencies:
We need the AWS SDK v3 for SNS, configuration management, validation, and the SNS message validator.
# Using npm
npm install @aws-sdk/client-sns @nestjs/config class-validator class-transformer sns-validator
npm install --save-dev @types/sns-validator
# Or using yarn
yarn add @aws-sdk/client-sns @nestjs/config class-validator class-transformer sns-validator
yarn add --dev @types/sns-validator
@aws-sdk/client-sns
: AWS SDK v3 client for SNS.@nestjs/config
: For managing environment variables.class-validator
,class-transformer
: For request payload validation (DTOs).sns-validator
: To verify the authenticity of incoming SNS messages (crucial for security).
3. Configure Environment Variables:
Create a .env
file in the project root:
# .env
# AWS Credentials & Region (Ensure these have necessary SNS permissions)
AWS_REGION=us-east-1 # Change to your desired AWS region
# AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID # See note below
# AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY # See note below
# ARN of the SNS Topic that will receive delivery status updates from SNS SMS
# We will create this topic in Step 4.
DELIVERY_STATUS_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:sms-delivery-status-topic
# (Optional) Database URL if you plan to persist status
# DATABASE_URL=postgresql://user:password@host:port/database
# The base URL where your NestJS app is publicly accessible (for SNS subscription)
# Use your Ngrok URL during development, or your production domain later.
APP_BASE_URL=https://your-public-domain-or-ngrok-url.com
# (Optional) Port for the application
# PORT=3000
Security Best Practice - AWS Credentials:
While listing AWS_ACCESS_KEY_ID
and AWS_SECRET_ACCESS_KEY
directly in .env
works for local development, it's strongly discouraged for security reasons, especially if the file could be accidentally committed. Never commit files containing credentials to version control. Add .env
to your .gitignore
immediately.
Preferred Alternatives:
- AWS Profiles: Configure named profiles in
~/.aws/credentials
and~/.aws/config
. The SDK can use these automatically (setAWS_PROFILE
environment variable). - IAM Roles (Recommended for Production): When deployed on AWS services like EC2, ECS, or Lambda, assign an IAM role to the resource. The SDK automatically retrieves temporary credentials from the instance metadata service. This avoids storing long-lived keys entirely.
- Secure Secret Management: Use services like AWS Secrets Manager or HashiCorp Vault, especially in production.
4. Setup Configuration Module:
Modify src/app.module.ts
to load the environment variables using @nestjs/config
.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { SmsModule } from './sms/sms.module'; // We will create this next
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Make ConfigModule available globally
envFilePath: '.env', // Load the .env file
}),
SmsModule, // Import the SMS module
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
5. Project Structure:
We'll organize our SMS-related logic into a dedicated sms
module.
src/
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├── main.ts
├── sms/
│ ├── dto/
│ │ └── send-sms.dto.ts # DTO for sending SMS requests
│ ├── entities/ # (Optional) For database entities
│ │ └── sms-message.entity.ts
│ ├── interfaces/
│ │ └── sns-notification.interface.ts # Type definition for SNS notifications
│ ├── sms.controller.ts # Handles incoming API requests (send & callback)
│ ├── sms.module.ts # SMS module definition
│ └── sms.service.ts # Contains business logic (sending SMS, processing callbacks)
└── config/ # (Optional) Configuration setup if needed beyond basic .env
Create the sms
directory and its subdirectories. We will populate the files in the following steps.
2. Implementing Core Functionality: Sending SMS
Let's create the service responsible for interacting with AWS SNS to send messages.
1. Create AWS SNS Client Provider (Optional but Recommended):
For better organization and testability, we can create a provider for the SNS client.
// src/sms/sms.module.ts
import { Module, Provider } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { SNSClient } from '@aws-sdk/client-sns';
import { SmsController } from './sms.controller';
import { SmsService } from './sms.service';
// Define a provider token for dependency injection
export const AWS_SNS_CLIENT = Symbol('AWS_SNS_CLIENT');
const snsClientProvider: Provider = {
provide: AWS_SNS_CLIENT,
useFactory: (configService: ConfigService): SNSClient => {
const region = configService.get<string>('AWS_REGION');
// AWS SDK v3 automatically uses credentials from environment variables
// (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY), AWS profiles, or IAM roles.
// Ensure they are set correctly in your environment or IAM configuration.
return new SNSClient({ region });
},
inject: [ConfigService], // Inject ConfigService to read region from .env
};
@Module({
imports: [ConfigModule], // Ensure ConfigModule is available
controllers: [SmsController],
providers: [snsClientProvider, SmsService],
exports: [SmsService], // Export if needed by other modules
})
export class SmsModule {}
2. Create SmsService
:
This service will encapsulate the logic for sending SMS messages and processing status updates.
// src/sms/sms.service.ts
import { Injectable, Inject, Logger, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
SNSClient,
PublishCommand,
PublishCommandInput,
PublishCommandOutput,
SetSMSAttributesCommand, // Import for setting attributes later
SetSMSAttributesCommandInput,
} from '@aws-sdk/client-sns';
import { AWS_SNS_CLIENT } from './sms.module'; // Import the provider token
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
constructor(
@Inject(AWS_SNS_CLIENT) private readonly snsClient: SNSClient,
private readonly configService: ConfigService,
) {}
/**
* Sends an SMS message using AWS SNS.
* @param phoneNumber - The recipient's phone number in E.164 format (e.g., +12223334444).
* @param message - The text message content.
* @returns The MessageId from SNS upon successful request initiation.
* @throws BadRequestException if phone number format is invalid.
* @throws Error if sending fails for other reasons.
*/
async sendSms(phoneNumber: string, message: string): Promise<string> {
// Basic validation (more robust validation in DTO/Controller layer)
if (!phoneNumber || !message) {
// This check is somewhat redundant if using DTOs, but good defense.
throw new BadRequestException('Phone number and message are required.');
}
// Validate E.164 format. Sending to invalid numbers fails and incurs cost.
if (!/^\+[1-9]\d{1,14}$/.test(phoneNumber)) {
// Throw an error instead of just logging. Consider making this configurable if non-E.164 support is needed (though not recommended).
throw new BadRequestException(`Invalid phone number format: ${phoneNumber}. Must use E.164 format (e.g., +12223334444).`);
}
const params: PublishCommandInput = {
PhoneNumber: phoneNumber,
Message: message,
// Optional: Add MessageAttributes for SenderID, MaxPrice, SMSType etc.
// See AWS SNS documentation for available attributes.
// MessageAttributes: {
// 'AWS.SNS.SMS.SenderID': {
// DataType: 'String',
// StringValue: 'MySenderID' // Requires pre-registration in some countries
// },
// 'AWS.SNS.SMS.SMSType': {
// DataType: 'String',
// StringValue: 'Transactional' // Or 'Promotional' - affects delivery path/cost
// }
// }
};
try {
this.logger.log(`Attempting to send SMS to ${phoneNumber}`);
const command = new PublishCommand(params);
const result: PublishCommandOutput = await this.snsClient.send(command);
if (!result.MessageId) {
throw new Error('SNS did not return a MessageId.');
}
this.logger.log(`SMS initiated successfully to ${phoneNumber}. MessageId: ${result.MessageId}`);
// Optional: Persist message details with MessageId and initial status ('SUBMITTED') here (See Section 6)
// await this.persistInitialMessageRecord(result.MessageId, phoneNumber, message);
return result.MessageId;
} catch (error) {
this.logger.error(`Failed to send SMS to ${phoneNumber}: ${error.message}`, error.stack);
// Consider more specific error handling based on AWS SDK error types
if (error instanceof BadRequestException) throw error; // Re-throw validation errors
throw new Error(`Failed to send SMS via SNS: ${error.message}`);
}
}
/**
* Placeholder method for processing incoming delivery status notifications.
* This will be refined significantly when the database layer is added (Section 6)
* to handle the specific payload structure and update records.
* @param payload - The raw parsed JSON payload from the SNS notification's 'Message' field.
*/
async processDeliveryStatus(payload: any): Promise<void> {
this.logger.log(`Received raw delivery status payload: ${JSON.stringify(payload)}`);
// Initial Placeholder Logic: Just log the received payload.
// Detailed processing (extracting messageId, status, destination, updating DB)
// will be implemented in Section 6 when the SmsMessage entity is available.
// Example extraction (adapt based on actual payload structure):
// const messageId = payload.messageId || payload.notification?.messageId;
// const status = payload.status;
// const destination = payload.delivery?.destination;
// this.logger.log(`Processing status update for MessageId ${messageId}: ${status}`);
// await this.updatePersistedStatus(messageId, status, payload); // Call DB update logic
}
/**
* Configures AWS SNS account-level SMS attributes to enable delivery status notifications.
* Sends status updates to the SNS topic specified by DELIVERY_STATUS_TOPIC_ARN.
* Requires sns:SetSMSAttributes IAM permission. Run once during setup or via a dedicated command.
*/
async configureSmsDeliveryStatusAttributes(): Promise<void> {
const deliveryStatusTopicArn = this.configService.get<string>('DELIVERY_STATUS_TOPIC_ARN');
if (!deliveryStatusTopicArn) {
this.logger.error('DELIVERY_STATUS_TOPIC_ARN is not configured in .env. Cannot set SMS attributes.');
return;
}
// **IMPORTANT CLARIFICATION ON 'RoleArn' PARAMETERS:**
// AWS uses confusing parameter names here (`SuccessfulFeedbackRoleArn`, `FailureFeedbackRoleArn`).
// When sending delivery status notifications *to an SNS Topic* (as we are doing),
// you provide the **ARN of the target SNS Topic** as the value for these parameters.
// You do NOT need to create or provide a separate IAM Role ARN here for SNS-to-Topic delivery.
// SNS uses its service principal permissions (which you grant on the Topic's policy) to publish.
// An IAM Role ARN *is* required if you were logging statuses directly to CloudWatch Logs instead of an SNS Topic.
const params: SetSMSAttributesCommandInput = {
attributes: {
// Send 100% of successful delivery statuses to the specified SNS topic
'DeliveryStatusSuccessSamplingRate': '100', // Percentage as a string ""0""-""100""
// ARN of the SNS topic to notify for successful deliveries
'SuccessfulFeedbackRoleArn': deliveryStatusTopicArn, // Use Topic ARN here
// 'SuccessfulFeedbackSampleRate': '100', // Seems redundant with DeliveryStatusSuccessSamplingRate, but some docs show it. Included for safety.
// ARN of the SNS topic to notify for failed deliveries
'FailureFeedbackRoleArn': deliveryStatusTopicArn, // Use Topic ARN here
// Optional: Default Sender ID (must be registered if used)
// 'DefaultSenderID': 'MySenderID',
// Optional: Default SMS Type ('Transactional' for higher reliability, 'Promotional' for lower cost)
'DefaultSMSType': 'Transactional',
// Optional: Monthly spend limit in USD (as a string)
// 'MonthlySpendLimit': '1',
},
};
try {
this.logger.log(`Attempting to set SMS delivery status attributes to publish to Topic: ${deliveryStatusTopicArn}`);
const command = new SetSMSAttributesCommand(params);
await this.snsClient.send(command);
this.logger.log(`Successfully set SMS delivery status attributes.`);
} catch (error) {
this.logger.error(`Failed to set SMS attributes: ${error.message}`, error.stack);
// This might fail due to permissions (ensure IAM user/role has sns:SetSMSAttributes)
// or invalid ARN / parameters.
throw new Error(`Failed to configure SNS SMS attributes: ${error.message}`);
}
}
}
Explanation:
- Injection: Injects
SNSClient
andConfigService
. sendSms
Method:- Takes
phoneNumber
andmessage
. - Validates E.164 format strictly using regex and throws
BadRequestException
if invalid. - Creates
PublishCommandInput
. - Calls
this.snsClient.send()
. - Logs success/error. Returns
MessageId
. Includes placeholder for DB persistence.
- Takes
processDeliveryStatus
Method: A preliminary placeholder. It currently just logs the raw payload received. This method will be significantly updated in Section 6 (Database Integration) to parse the specific fields (messageId
,status
, etc.) and interact with the database repository.configureSmsDeliveryStatusAttributes
Method: Utility to set SNS attributes for delivery status. RequiresDELIVERY_STATUS_TOPIC_ARN
from.env
. Sets sampling rate and specifies the target Topic ARN for success/failure notifications using theSuccessfulFeedbackRoleArn
andFailureFeedbackRoleArn
parameters (despite their confusing names). Requiressns:SetSMSAttributes
permission.
3. Building the API Layer (Sending SMS & Receiving Callbacks)
Now, let's create the controller to expose an endpoint for sending SMS and another for receiving the status callbacks from SNS.
1. Create SendSmsDto
:
Define a Data Transfer Object (DTO) for validating the incoming request payload when sending an SMS.
// src/sms/dto/send-sms.dto.ts
import { IsNotEmpty, IsString, IsPhoneNumber, MaxLength } from 'class-validator';
export class SendSmsDto {
@IsNotEmpty()
@IsPhoneNumber(null, { message: 'Phone number must be a valid E.164 format phone number (e.g., +12223334444).' }) // null region uses default validation
phoneNumber: string;
@IsNotEmpty()
@IsString()
@MaxLength(1600) // SNS SMS message length limits (check current AWS docs for specifics, including character encoding impact)
message: string;
}
2. Define SNS Notification Interface:
Create an interface for better type safety when handling incoming SNS notifications. The structure can be complex; this covers the key parts. Refer to AWS SNS documentation for the definitive structure. Note the ambiguity in messageId
location within the Message
field, which the code needs to handle.
// src/sms/interfaces/sns-notification.interface.ts
// Represents the overall structure of the POST request body from SNS
export interface SnsNotificationRequest {
Type: 'SubscriptionConfirmation' | 'Notification' | 'UnsubscribeConfirmation';
MessageId: string; // Message ID of the notification itself
Token?: string; // Only for SubscriptionConfirmation & UnsubscribeConfirmation
TopicArn: string;
Subject?: string;
Message: string; // This is a JSON string that needs parsing for 'Notification' type
Timestamp: string; // ISO 8601 timestamp
SignatureVersion: string;
Signature: string;
SigningCertURL: string;
SubscribeURL?: string; // Only for SubscriptionConfirmation
UnsubscribeURL?: string; // Only for UnsubscribeConfirmation
}
// Represents the structure of the 'Message' field after JSON parsing for a delivery status notification
// Note: The exact location of the original messageId can vary slightly.
export interface SnsDeliveryStatusMessage {
notification?: { // Optional outer layer sometimes present
messageId: string; // UUID generated by SNS when sending the original SMS
timestamp: string; // ISO 8601 timestamp
};
delivery: {
phoneCarrier: string; // e.g., Verizon Wireless, AT&T, etc.
mnc: number; // Mobile Network Code
destination: string; // The recipient phone number (E.164 format)
priceInUSD: number; // Cost of the message segment
smsType: 'Promotional' | 'Transactional';
mcc: number; // Mobile Country Code
providerResponse: string; // Information from the downstream provider (can be vague or detailed)
dwellTimeMs: number; // Time spent in the downstream provider network
dwellTimeMsUntilDeviceAck?: number; // Time until device acknowledgment (if available)
};
status: 'SUCCESS' | 'FAILURE' | string; // The delivery status. 'SUCCESS' corresponds to delivered to handset. Other strings possible for failures.
// The original messageId is often outside the 'delivery' object in direct SNS status messages,
// but sometimes nested under 'notification'. Code must check both.
messageId?: string; // UUID generated by SNS when sending the original SMS (sometimes here)
}
3. Create SmsController
:
This controller will handle both sending SMS messages and receiving status callbacks.
// src/sms/sms.controller.ts
import {
Controller,
Post,
Body,
HttpCode,
HttpStatus,
UsePipes,
ValidationPipe,
Headers,
Logger,
RawBodyRequest, // Import this
Req, // Import this
Inject, // Import Inject if not already present
} from '@nestjs/common';
import { Request } from 'express'; // Import this
import { SmsService } from './sms.service';
import { SendSmsDto } from './dto/send-sms.dto';
import { ConfigService } from '@nestjs/config';
import MessageValidator = require('sns-validator'); // Use require syntax for CommonJS module
import { SnsNotificationRequest, SnsDeliveryStatusMessage } from './interfaces/sns-notification.interface';
@Controller('sms')
export class SmsController {
private readonly logger = new Logger(SmsController.name);
private readonly validator = new MessageValidator();
private readonly deliveryStatusTopicArn: string;
constructor(
private readonly smsService: SmsService,
private readonly configService: ConfigService,
) {
this.deliveryStatusTopicArn = this.configService.get<string>('DELIVERY_STATUS_TOPIC_ARN');
if (!this.deliveryStatusTopicArn) {
this.logger.warn('DELIVERY_STATUS_TOPIC_ARN is not set in environment variables. Callback Topic ARN validation cannot be performed.');
}
}
/**
* Endpoint to send an SMS message.
*/
@Post('send')
@HttpCode(HttpStatus.ACCEPTED) // Use 202 Accepted as sending is async
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async sendSms(@Body() sendSmsDto: SendSmsDto): Promise<{ messageId: string; status: string }> {
this.logger.log(`Received request to send SMS to ${sendSmsDto.phoneNumber}`);
const messageId = await this.smsService.sendSms(sendSmsDto.phoneNumber, sendSmsDto.message);
return { messageId, status: 'Message submission accepted by SNS.' };
}
/**
* Callback endpoint to receive delivery status notifications from AWS SNS.
* IMPORTANT: This endpoint MUST be publicly accessible via HTTPS.
* Uses `RawBodyRequest` to get the raw body for signature verification.
*/
@Post('status/callback')
@HttpCode(HttpStatus.OK) // Respond OK quickly to SNS to acknowledge receipt
async handleSnsCallback(@Req() req: RawBodyRequest<Request>, @Headers('x-amz-sns-message-type') messageType: string): Promise<string> {
// **Crucial:** Get the raw body. Ensure `json` body parser in main.ts is configured with `rawBody: true`.
const rawBody = req.rawBody?.toString('utf8'); // Get raw body as UTF-8 string
if (!rawBody) {
this.logger.error('Raw body is missing from the request. Ensure rawBody:true is set for NestFactory.create in main.ts.');
// Do not acknowledge SNS without raw body for validation. Returning non-200 might cause retries.
// Consider returning a specific error message, but avoid overly detailed internal errors.
return 'Error: Internal configuration error (missing raw body).';
}
let parsedBody: SnsNotificationRequest;
try {
parsedBody = JSON.parse(rawBody);
} catch (e) {
this.logger.error(`Failed to parse incoming SNS raw body: ${e.message}`, rawBody);
return 'Error: Invalid JSON format in request body.';
}
this.logger.log(`Received SNS callback of type: ${messageType || parsedBody.Type}`); // Use header first, fallback to body
this.logger.debug(`Parsed SNS callback body: ${JSON.stringify(parsedBody)}`); // Log parsed body for debug
// 1. Validate the Message Signature (Security Critical!)
try {
const valid = await new Promise<boolean>((resolve, reject) => {
// Pass the parsed body object to the validator
this.validator.validate(parsedBody, (err) => {
if (err) {
// Log the detailed validation error internally
this.logger.error('SNS Message Validation Error:', err);
return reject(err); // Reject the promise on error
}
this.logger.log('SNS message signature validated successfully.');
resolve(true); // Resolve promise indicating validation success
});
});
if (!valid) {
// This path should theoretically not be reached if validation fails because the promise rejects.
throw new Error('SNS Message validation failed silently (unexpected state).');
}
// Optional but Recommended: Verify Topic ARN matches expectation
if (this.deliveryStatusTopicArn && parsedBody.TopicArn !== this.deliveryStatusTopicArn) {
this.logger.warn(`Received message from unexpected Topic ARN: ${parsedBody.TopicArn}. Expected: ${this.deliveryStatusTopicArn}. Processing anyway (configurable behavior).`);
// Decide whether to reject or just log based on security posture.
// For now, we log a warning and proceed. Could return an error:
// return 'Error: Invalid Topic ARN';
}
} catch (error) {
this.logger.error(`SNS Message Signature Validation Failed: ${error.message}`, error.stack);
// Do NOT process the message if validation fails.
// **Why return 200 OK here?** Returning a 4xx/5xx error might cause SNS to retry sending the *same invalid message* repeatedly.
// By returning 200 OK but logging the error, we acknowledge receipt to prevent retries for permanently invalid messages (like bad signatures).
// Legitimate transient issues (network, temporary app failure) *should* return non-200 to trigger SNS retries.
return 'Error: Invalid SNS message signature.'; // Return a generic error externally.
}
// 2. Handle Different SNS Message Types
switch (parsedBody.Type) {
case 'SubscriptionConfirmation':
this.logger.log(`Received SubscriptionConfirmation from SNS for Topic: ${parsedBody.TopicArn}.`);
// **ACTION REQUIRED:** You MUST visit the SubscribeURL to activate the subscription.
// This proves you control the endpoint. You can do this manually during setup,
// or add code here to programmatically fetch the URL (requires an HTTP client like axios/fetch).
// For security, ensure you validate the SubscribeURL domain before fetching.
this.logger.warn(`**ACTION REQUIRED:** You MUST visit this URL to confirm the SNS subscription: ${parsedBody.SubscribeURL}`);
return 'SubscriptionConfirmation received. Please visit the SubscribeURL logged by the server to confirm.';
case 'Notification':
this.logger.log(`Received Notification from SNS. SNS MessageId: ${parsedBody.MessageId}`);
try {
// The 'Message' field is a JSON string containing the actual delivery status
const deliveryStatus: SnsDeliveryStatusMessage = JSON.parse(parsedBody.Message);
this.logger.debug(`Parsed Notification Message content: ${JSON.stringify(deliveryStatus)}`);
// Extract the key information
// Note: The original MessageId might be in `deliveryStatus.messageId` or `deliveryStatus.notification.messageId`
const originalMessageId = deliveryStatus.messageId || deliveryStatus.notification?.messageId;
const status = deliveryStatus.status;
const destination = deliveryStatus.delivery?.destination;
if (!originalMessageId || !status) {
this.logger.warn('Notification message content is incomplete (missing original messageId or status).', deliveryStatus);
// Still return OK to avoid retries for potentially malformed (but validly signed) messages.
return 'Warning: Incomplete notification data received.';
}
// Pass the relevant payload to the service for processing/persistence
// This structure matches the expected input for the updated `processDeliveryStatus` in Section 6.
await this.smsService.processDeliveryStatus({
messageId: originalMessageId,
status: status.toUpperCase(), // Normalize status (e.g., 'SUCCESS', 'FAILURE')
destination: destination,
providerResponse: deliveryStatus.delivery?.providerResponse,
fullPayload: deliveryStatus, // Pass the full parsed message for detailed logging/storage
});
return 'Notification processed successfully.';
} catch (parseError) {
this.logger.error(`Error parsing SNS Notification 'Message' field: ${parseError.message}`, parseError.stack);
return 'Error: Could not parse notification message content.';
}
case 'UnsubscribeConfirmation':
this.logger.log(`Received UnsubscribeConfirmation for TopicArn: ${parsedBody.TopicArn}. MessageId: ${parsedBody.MessageId}`);
// Handle cleanup if necessary (e.g., update internal state if tracking subscriptions)
return 'UnsubscribeConfirmation received.';
default:
this.logger.warn(`Received unknown SNS message type: '${parsedBody.Type}'. Body: ${JSON.stringify(parsedBody)}`);
return 'Unknown message type received.';
}
}
}
4. Enable Raw Body Parsing:
The sns-validator
requires the raw, unparsed request body to verify the signature. Modify src/main.ts
to enable this globally.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe, Logger } from '@nestjs/common'; // Import Logger
import { ConfigService } from '@nestjs/config';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
// Enable raw body parsing globally. This makes `req.rawBody` available in controllers.
rawBody: true,
});
const configService = app.get(ConfigService);
const port = configService.get<number>('PORT') || 3000;
const logger = new Logger('Bootstrap'); // Create a logger instance
// Enable global validation pipes
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Strip properties not defined in DTOs
transform: true, // Automatically transform payloads to DTO instances
}));
await app.listen(port);
logger.log(`Application listening on port ${port}`);
logger.log(`Callback endpoint expected at: /sms/status/callback`);
}
bootstrap();