This guide provides a step-by-step walkthrough for building a robust system capable of receiving and processing inbound SMS messages using NestJS, AWS Simple Notification Service (SNS), AWS Simple Queue Service (SQS), AWS Lambda, and Amazon Pinpoint (specifically its SMS and Voice capabilities).
We will construct a NestJS application triggered by incoming SMS messages, allowing you to build interactive experiences, automate responses, or process SMS data effectively.
Project Goals:
- Receive inbound SMS messages sent to a dedicated AWS phone number.
- Route these messages reliably via SNS and SQS to a Lambda function.
- Process the incoming messages using a NestJS application running within the Lambda function.
- Enable the NestJS application to potentially send outbound replies.
- Establish a scalable, decoupled, and production-ready architecture.
Core Technologies:
- NestJS: A progressive Node.js framework for building efficient, reliable server-side applications. Chosen for its modularity, testability, and microservice capabilities.
- Amazon Pinpoint (SMS and Voice): The AWS service for acquiring phone numbers and managing SMS/MMS communications, including two-way messaging configuration via its ""SMS and Voice"" console section.
- AWS SNS (Simple Notification Service): A fully managed messaging service. Used here to receive notifications when an SMS is sent to the AWS phone number.
- AWS SQS (Simple Queue Service): A fully managed message queuing service. Used to decouple the SNS notification from the processing logic, providing resilience and buffering.
- AWS Lambda: A serverless compute service. Used to host and execute the NestJS application code in response to messages arriving in the SQS queue.
- AWS SDK for JavaScript v3: Used within the NestJS application for potential interactions with AWS services (e.g., sending replies via the Pinpoint SMS/Voice v2 API).
- Serverless Framework (or AWS CDK): Infrastructure-as-Code tools recommended for deploying and managing the required AWS resources (SNS, SQS, Lambda, IAM Roles).
System Architecture:
graph LR
subgraph User
A[Mobile Phone]
end
subgraph AWS Cloud
B(Amazon Pinpoint SMS/Voice) -- Inbound SMS --> C(SNS Topic);
C -- Publishes --> D(SQS Queue);
D -- Triggers --> E(Lambda Function);
subgraph Lambda Function / NestJS App
E -- Executes --> F{NestJS App};
F -- Processes --> G[Message Handler Logic];
G -- Optional Reply --> H(AWS SDK);
end
H -- Send SMS API Call --> B;
end
A -- Sends SMS --> B;
B -- Sends Reply --> A;
style F fill:#f9f,stroke:#333,stroke-width:2px
Prerequisites:
- An AWS account with appropriate permissions to create/manage SNS, SQS, Lambda, IAM, and Amazon Pinpoint (SMS/Voice) resources.
- Node.js (LTS version recommended) and npm/yarn installed locally.
- NestJS CLI installed globally:
npm install -g @nestjs/cli
- AWS CLI installed and configured locally.
- A provisioned phone number within Amazon Pinpoint capable of two-way SMS in your desired region(s). Refer to SMS and MMS country capabilities and limitations.
- Basic understanding of TypeScript and NestJS concepts.
- (Optional but Recommended) Familiarity with the Serverless Framework or AWS CDK.
Final Outcome:
By the end of this guide, you will have a deployed AWS Lambda function running a NestJS application. This application will automatically process incoming SMS messages sent to your configured AWS phone number, with logs visible in CloudWatch and a clear structure for adding custom business logic and replies.
1. Setting Up the Local NestJS Project
Let's initialize our NestJS project and install necessary dependencies.
-
Create NestJS Project: Open your terminal and run:
nest new nestjs-sms-handler cd nestjs-sms-handler
Choose your preferred package manager (npm or yarn) when prompted.
-
Project Structure: NestJS creates a standard project structure:
/ ├── src/ │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ └── main.ts # Standard entry point (we'll adapt this for Lambda) ├── test/ ├── node_modules/ ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── nest-cli.json ├── package.json ├── README.md ├── tsconfig.build.json └── tsconfig.json
This structure promotes modularity and separation of concerns.
-
Install AWS SDK v3: We need the AWS SDK to interact with AWS services, specifically for potentially sending replies via the Pinpoint SMS/Voice v2 API.
npm install @aws-sdk/client-pinpoint-sms-voice-v2 # Add others if needed (e.g., SQS, DynamoDB) # or yarn add @aws-sdk/client-pinpoint-sms-voice-v2
Why SDK v3? It offers modularity (smaller bundles) and improved TypeScript support compared to v2.
-
Environment Variables: Create a
.env
file in the project root for local development configuration. Important: Never commit.env
files containing secrets (like AWS keys) to your source code repository. Use a.env.example
file to track required variables without their values. Use secure methods like CI/CD secrets or AWS Secrets Manager for production deployments.# .env AWS_REGION=us-east-1 # Your AWS region AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY_ID # For local testing only - use IAM Roles in Lambda AWS_SECRET_ACCESS_KEY=YOUR_SECRET_ACCESS_KEY # For local testing only SNS_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:YourSmsTopic # ARN of your SNS topic SQS_QUEUE_URL=https://sqs.us-east-1.amazonaws.com/123456789012/YourSmsQueue # URL of your SQS queue PINPOINT_PHONE_NUMBER=+1XXXXXXXXXX # Your AWS provisioned phone number PINPOINT_APP_ID=YOUR_PINPOINT_PROJECT_ID # Optional: If using Pinpoint project features # Add any other config needed by your app LOG_LEVEL=debug
- Obtaining Values:
AWS_REGION
: The AWS region where you'll deploy resources (e.g.,us-east-1
,eu-west-2
).AWS_ACCESS_KEY_ID
/AWS_SECRET_ACCESS_KEY
: Generate these from the AWS IAM console for a dedicated user only for local development. In production Lambda, rely on execution roles for permissions.SNS_TOPIC_ARN
,SQS_QUEUE_URL
: You will create these in the next step. Note their ARNs/URLs.PINPOINT_PHONE_NUMBER
: The number obtained from Amazon Pinpoint (SMS and Voice section).PINPOINT_APP_ID
: If you created an Amazon Pinpoint project for analytics or campaigns, find its ID in the Pinpoint console. Often not strictly required just for basic two-way SMS configuration.
- Obtaining Values:
-
Configuration Module (Optional but Recommended): Use NestJS's
@nestjs/config
module for managing environment variables.npm install @nestjs/config # or yarn add @nestjs/config
Update
src/app.module.ts
:// 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 your future modules here (e.g., SmsModule) @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Makes ConfigService available globally envFilePath: '.env', // Specify your env file }), // Add other modules like SmsModule here ], controllers: [AppController], // Modify as needed providers: [AppService], // Modify as needed }) export class AppModule {}
2. Setting Up AWS Resources (SNS, SQS, Two-Way SMS)
Now, configure the necessary AWS infrastructure to route SMS messages to where our future Lambda function can process them. We recommend using Infrastructure as Code (IaC) like the Serverless Framework or AWS CDK for managing these resources in production, but we'll outline the manual console steps here for clarity.
-
Create an SNS Topic: This topic will receive notifications when an SMS arrives at your AWS phone number.
- Navigate to the SNS service in the AWS Management Console.
- Go to Topics and click Create topic.
- Choose Standard type (FIFO is not typically needed for SMS).
- Enter a Name (e.g.,
InboundSmsNotifications
). - Keep default settings for Access policy, Encryption, etc., unless you have specific requirements.
- Click Create topic.
- Note down the ARN (Amazon Resource Name) of the created topic (e.g.,
arn:aws:sns:us-east-1:123456789012:InboundSmsNotifications
). You'll need this for.env
and the phone number configuration.
-
Create an SQS Queue: This queue will subscribe to the SNS topic and hold messages until the Lambda function processes them. This provides decoupling and automatic retries.
- Navigate to the SQS service in the AWS Management Console.
- Click Create queue.
- Choose Standard queue type.
- Enter a Name (e.g.,
InboundSmsProcessingQueue
). - Configure Access policy:
- Choose Advanced.
- You need to grant the SNS topic permission to send messages to this queue. Replace
YOUR_REGION
,YOUR_ACCOUNT_ID
,YOUR_SNS_TOPIC_ARN
, andYOUR_SQS_QUEUE_ARN
with your actual values.
{ ""Version"": ""2012-10-17"", ""Statement"": [ { ""Effect"": ""Allow"", ""Principal"": { ""Service"": ""sns.amazonaws.com"" }, ""Action"": ""sqs:SendMessage"", ""Resource"": ""YOUR_SQS_QUEUE_ARN"", ""Condition"": { ""ArnEquals"": { ""aws:SourceArn"": ""YOUR_SNS_TOPIC_ARN"" } } } ] }
- Dead-letter queue (Recommended): Configure a DLQ to capture messages that fail processing repeatedly. Create another standard queue (e.g.,
InboundSmsDeadLetterQueue
) and configure it here with aMaximum receives
count (e.g., 3). - Click Create queue.
- Note down the URL and ARN of the created queue.
-
Subscribe SQS Queue to SNS Topic:
- Go back to the SNS service and select the topic you created (
InboundSmsNotifications
). - Go to the Subscriptions tab and click Create subscription.
- Protocol: Select
Amazon SQS
. - Endpoint: Paste the ARN of the SQS queue (
InboundSmsProcessingQueue
) you created. - Enable raw message delivery (Recommended): Check this box. It prevents SNS from adding extra metadata, simplifying parsing in your Lambda function. If unchecked, the original SMS payload will be nested inside an SNS message structure within the SQS message body. Your Lambda parsing logic must match this setting.
- Click Create subscription.
- Go back to the SNS service and select the topic you created (
-
Configure Two-Way SMS for Your Phone Number: Link your AWS-provisioned phone number to the SNS topic.
- Navigate to the Amazon Pinpoint service in the AWS Management Console.
- In the navigation pane, find the SMS and Voice section (this might be top-level or nested under Settings/Channels).
- Under Number settings (or similar, e.g., ""Phone numbers""), choose Phone numbers.
- Select the phone number you want to use for inbound messages.
- Go to the Two-way SMS tab (or section) and choose Edit (or ""Manage"").
- Enable two-way SMS: Check the box.
- Incoming messages destination: Select
SNS topic
. - SNS topic: Choose the SNS topic (
InboundSmsNotifications
) you created from the dropdown. - IAM role for SNS publishing: This defines permissions for the Amazon Pinpoint service to publish to your SNS topic.
- You can let AWS manage this via Use Amazon SNS topic policies (simplest if your topic policy allows
sms-voice.amazonaws.com
or is broadly permissive). - Alternatively, choose Choose an existing IAM role and select/create an IAM role with a policy like this (replace
YOUR_SNS_TOPIC_ARN
):The role's trust policy must allow the{ ""Version"": ""2012-10-17"", ""Statement"": [ { ""Effect"": ""Allow"", ""Action"": ""sns:Publish"", ""Resource"": ""YOUR_SNS_TOPIC_ARN"" } ] }
sms-voice.amazonaws.com
service principal.
- You can let AWS manage this via Use Amazon SNS topic policies (simplest if your topic policy allows
- Click Save changes.
Message Flow Recap:
SMS Sent to AWS Number -> Amazon Pinpoint Service -> SNS Topic (InboundSmsNotifications
) -> SQS Queue (InboundSmsProcessingQueue
) -> (Next Step: Lambda Trigger)
3. Implementing Core Functionality in NestJS
Now we adapt the NestJS application to run in Lambda and process messages from the SQS queue. We'll use a custom strategy pattern similar to the one described in the research for handling microservice-like events triggered by Lambda.
-
Create a Custom Transport Strategy: This strategy won't actively listen like traditional NestJS microservice transports; instead, it provides a way for our Lambda handler to find the correct NestJS method based on the incoming message content.
// src/sns-sqs.strategy.ts import { CustomTransportStrategy, Server, Transport } from '@nestjs/microservices'; import { Logger } from '@nestjs/common'; export class SnsSqsStrategy extends Server implements CustomTransportStrategy { private readonly logger = new Logger(SnsSqsStrategy.name); transportId?: Transport; // Optional: Add if needed based on NestJS version/usage // No active listening needed as Lambda triggers the processing listen(callback: () => void) { this.logger.log('SNS/SQS Strategy initialized. Waiting for Lambda trigger.'); callback(); } // Close method for cleanup on shutdown close() { this.logger.log('SNS/SQS Strategy closing.'); } /** * Method to retrieve a message handler based on a pattern. * The 'pattern' could be derived from the message body (e.g., a keyword) * or a predefined pattern for all SMS messages. * This method is crucial for routing the incoming SQS message to the correct @EventPattern. */ public getHandlerByPattern(pattern: string) { // The `messageHandlers` property is inherited from the Server class // and populated by NestJS based on @EventPattern decorators. return this.messageHandlers.get(pattern); } }
-
Adapt
main.ts
for Lambda: Modify the main entry point to bootstrap NestJS once per Lambda container instance and handle incomingSQSEvent
records. We useReplaySubject
from RxJS to manage the bootstrapping process efficiently, reducing cold start impact on subsequent invocations within the same container.// src/main.ts import { NestFactory } from '@nestjs/core'; import { Logger } from '@nestjs/common'; import { MicroserviceOptions } from '@nestjs/microservices'; import { Context, Handler, SQSEvent, SQSRecord } from 'aws-lambda'; import { ReplaySubject, firstValueFrom } from 'rxjs'; import { AppModule } from './app.module'; import { SnsSqsStrategy } from './sns-sqs.strategy'; // Import the custom strategy const logger = new Logger('LambdaHandler'); // Keep the NestJS app instance warm between invocations const strategySubject = new ReplaySubject<SnsSqsStrategy>(); // Bootstrap function to initialize NestJS async function bootstrap(): Promise<SnsSqsStrategy> { logger.log('COLD START: Initializing NestJS Microservice...'); const strategy = new SnsSqsStrategy(); // Use our custom strategy const app = await NestFactory.createMicroservice<MicroserviceOptions>( AppModule, { strategy: strategy, // Pass the strategy instance // Disable NestJS logger for cleaner CloudWatch logs if desired // logger: false, }, ); // Use the built-in NestJS logger instance after bootstrap if not disabled app.useLogger(app.get(Logger)); await app.init(); // Initialize the microservice // No app.listen() needed as we are not listening on a port/socket logger.log('NestJS Microservice Initialized.'); return strategy; } // Initialize NestJS outside the handler; this runs during Lambda init phase bootstrap().then((strategy) => strategySubject.next(strategy)); // Define the actual Lambda handler function export const handler: Handler = async ( event: SQSEvent, context: Context, ): Promise<void> => { logger.log(`Received event: ${JSON.stringify(event)}`); // Get the initialized strategy instance (waits if bootstrap isn't finished) const strategy = await firstValueFrom(strategySubject); if (!strategy) { logger.error('FATAL: Strategy not initialized!'); return; // Or throw error to indicate failure } // Process each SQS record for (const record of event.Records) { try { await processRecord(record, strategy, context); } catch (error) { logger.error(`Error processing record ${record.messageId}: ${error}`, error.stack); // Depending on SQS/DLQ setup, Lambda might retry automatically. // Throwing the error here will cause Lambda to report an invocation error. // Consider adding specific error handling or selective re-throwing. // throw error; // Uncomment if you want failed records to trigger Lambda retries/DLQ } } logger.log('Finished processing batch.'); }; // Function to process a single SQS record async function processRecord( record: SQSRecord, strategy: SnsSqsStrategy, context: Context, // Pass context if needed by handlers ): Promise<void> { logger.log(`Processing SQS message ID: ${record.messageId}`); // --- IMPORTANT: Message Body Parsing --- // The structure of `record.body` depends *critically* on whether // "raw message delivery" is ENABLED or DISABLED on the SNS->SQS subscription. // **Scenario 1: Raw Message Delivery ENABLED (Recommended)** // record.body is a JSON *string* containing the direct SMS payload from Amazon Pinpoint/SMSVoice. // Example: '{"originationNumber":"+1...","destinationNumber":"+1...","messageBody":"Hello",...}' // **Scenario 2: Raw Message Delivery DISABLED** // record.body is a JSON *string* representing the standard SNS message structure. // The actual SMS payload (itself a JSON string) is nested inside the `Message` property. // Example: '{"Type":"Notification","MessageId":"...","TopicArn":"...","Message":"{\"originationNumber\":\"+1...\", ... }", ...}' // This code assumes Scenario 1 (Raw Delivery Enabled) for simplicity. // If you disable raw delivery, you MUST adjust the parsing logic below. let smsPayload: { originationNumber: string; destinationNumber: string; messageKeyword: string; messageBody: string; previousPublishedMessageId?: string; // Present if it's a reply to an outbound message inboundMessageId: string; }; try { // Assuming raw delivery enabled: Parse the record body directly smsPayload = JSON.parse(record.body); // *** IF RAW DELIVERY IS DISABLED, USE THIS INSTEAD: *** // const snsMessage = JSON.parse(record.body); // smsPayload = JSON.parse(snsMessage.Message); // **************************************************** // --- Basic Validation --- if ( !smsPayload || !smsPayload.messageBody || !smsPayload.originationNumber ) { logger.warn( `Skipping message: Missing essential fields in payload: ${JSON.stringify(smsPayload)}`, ); return; } } catch (error) { logger.error(`Failed to parse SMS payload from SQS record body. Raw Delivery Enabled? Body: ${record.body}`, error.stack); return; // Skip malformed messages } logger.log(`Processing SMS from ${smsPayload.originationNumber}: "${smsPayload.messageBody}"`); // --- Routing Logic --- // Decide which handler to call based on the message. // Simple approach: Use a fixed pattern for all SMS messages. // Complex approach: Parse smsPayload.messageBody for keywords or use smsPayload.messageKeyword. const messagePattern = 'process_incoming_sms'; // Example: Fixed pattern const handler = strategy.getHandlerByPattern(messagePattern); if (!handler) { logger.warn(`No handler found for pattern: ${messagePattern}`); return; } // --- Execute Handler --- logger.debug(`Invoking handler for pattern: ${messagePattern}`); // The handler function expects the data payload. // You might want to pass the full smsPayload or a subset. // You can also pass the Lambda context if handlers need it. await handler(smsPayload, context); // Pass payload and context logger.debug(`Handler executed successfully for message ID: ${record.messageId}`); }
Key Points:
bootstrap()
initializes NestJS and returns the strategy instance.strategySubject.next(strategy)
stores the instance in a ReplaySubject.- The
handler
function retrieves the strategy usingfirstValueFrom(strategySubject)
, ensuring Nest is ready. - It iterates through
SQSEvent.Records
. processRecord
parses therecord.body
. Crucially, this logic assumes SNS raw message delivery is enabled. A prominent comment explains how to adjust if it's disabled.- It determines a
messagePattern
(e.g., based on keywords in themessageBody
, or a default pattern). strategy.getHandlerByPattern(messagePattern)
retrieves the corresponding NestJS handler function.handler(smsPayload, context)
executes the NestJS function.
-
Create an SMS Handling Module and Controller: Organize the SMS processing logic within a dedicated NestJS module.
nest generate module sms nest generate controller sms --no-spec nest generate service sms --no-spec
Update
src/sms/sms.module.ts
:// src/sms/sms.module.ts import { Module, Logger } from '@nestjs/common'; import { SmsController } from './sms.controller'; import { SmsService } from './sms.service'; import { ConfigModule } from '@nestjs/config'; // Import if needed @Module({ imports: [ConfigModule], // Import ConfigModule if you use ConfigService here controllers: [SmsController], providers: [SmsService, Logger], // Provide Logger if used directly }) export class SmsModule {}
Import
SmsModule
intosrc/app.module.ts
:// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { SmsModule } from './sms/sms.module'; // Import the new module @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env', }), SmsModule, // Add SmsModule here ], // Remove AppController/AppService if AppModule is just for imports controllers: [], providers: [], }) export class AppModule {}
Implement the controller with an
@EventPattern
decorator matching the pattern used inmain.ts
.// src/sms/sms.controller.ts import { Controller, Logger, Inject } from '@nestjs/common'; import { EventPattern, Payload, Ctx } from '@nestjs/microservices'; import { Context } from 'aws-lambda'; // Import Lambda context type import { SmsService } from './sms.service'; // Define an interface for the expected payload structure interface InboundSmsPayload { originationNumber: string; destinationNumber: string; messageKeyword: string; messageBody: string; previousPublishedMessageId?: string; inboundMessageId: string; } @Controller() // No route prefix needed for event handlers export class SmsController { // Inject Logger and SmsService constructor( private readonly logger: Logger, private readonly smsService: SmsService, ) {} // This pattern MUST match the one used in main.ts to find the handler @EventPattern('process_incoming_sms') async handleIncomingSms( @Payload() data: InboundSmsPayload, @Ctx() context?: Context, // Optionally receive the Lambda context ): Promise<void> { this.logger.log( `Handling incoming SMS via EventPattern: ${JSON.stringify(data)}`, SmsController.name, // Optional context string for logger ); // Add optional context logging if needed if (context) { this.logger.debug(`Lambda Context: RequestId: ${context.awsRequestId}`, SmsController.name); } // --- Your Business Logic Here --- // 1. Parse the message body for keywords or commands const messageBodyLower = data.messageBody.toLowerCase().trim(); // 2. Perform actions based on content if (messageBodyLower === 'help') { await this.smsService.sendReply( data.originationNumber, // Send reply back to sender 'Available commands: INFO, STATUS. For help, text HELP.', ); } else if (messageBodyLower === 'stop') { // Handle opt-out - AWS might handle this automatically depending on settings/region this.logger.log(`Received STOP request from ${data.originationNumber}`); // Potentially update a database to mark the number as opted-out await this.smsService.handleOptOut(data.originationNumber); } else { // Default processing or reply this.logger.log(`Processing default message from ${data.originationNumber}`); // Example: Store the message, trigger another workflow, etc. await this.smsService.storeMessage(data); // Example: Send a confirmation reply await this.smsService.sendReply( data.originationNumber, `Received your message: "${data.messageBody.substring(0, 20)}..."`, ); } this.logger.log(`Finished processing SMS from ${data.originationNumber}`, SmsController.name); // No return value needed for event patterns typically } // Add more @EventPattern methods here for different routing patterns if needed // e.g., @EventPattern('handle_keyword_subscribe') }
-
Implement the SMS Service (Optional Reply Logic): Create methods for business logic, like sending replies using the AWS SDK.
// src/sms/sms.service.ts import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PinpointSMSVoiceV2Client, SendTextMessageCommand, SendTextMessageCommandInput, } from '@aws-sdk/client-pinpoint-sms-voice-v2'; // Correct SDK V3 client @Injectable() export class SmsService { private readonly logger = new Logger(SmsService.name); private readonly awsSmsClient: PinpointSMSVoiceV2Client; private readonly senderPhoneNumber: string; // Your AWS provisioned number constructor(private configService: ConfigService) { this.awsSmsClient = new PinpointSMSVoiceV2Client({ region: this.configService.get<string>('AWS_REGION'), // Credentials will be automatically picked up from the Lambda execution role // or from environment variables/AWS config file locally. }); this.senderPhoneNumber = this.configService.get<string>('PINPOINT_PHONE_NUMBER'); if (!this.senderPhoneNumber) { this.logger.error('PINPOINT_PHONE_NUMBER environment variable is not set!'); } } async sendReply(recipientNumber: string, message: string): Promise<void> { if (!this.senderPhoneNumber) { this.logger.error('Cannot send reply: Sender phone number is not configured.'); return; } this.logger.log(`Attempting to send reply to ${recipientNumber}: "${message}"`); const params: SendTextMessageCommandInput = { DestinationPhoneNumber: recipientNumber, // OriginationIdentity can be your provisioned phone number, // a configured Sender ID, or an associated Pool ID/ARN. // Check AWS docs/limits for your region/use case. OriginationIdentity: this.senderPhoneNumber, MessageBody: message, // MessageType: 'TRANSACTIONAL', // Or 'PROMOTIONAL' - Default is TRANSACTIONAL // You can add ConfigurationSetName, Context, DryRun etc. if needed }; try { const command = new SendTextMessageCommand(params); const result = await this.awsSmsClient.send(command); this.logger.log(`Successfully sent message. Message ID: ${result.MessageId}`); } catch (error) { this.logger.error(`Failed to send SMS to ${recipientNumber}: ${error}`, error.stack); // Handle error appropriately (e.g., retry, notify admin) } } async storeMessage(payload: any): Promise<void> { // Placeholder: Implement logic to store the message in a database (DynamoDB, RDS, etc.) this.logger.log(`Storing message from ${payload.originationNumber}: ${JSON.stringify(payload)}`); // Example: await this.databaseService.saveSms(payload); } async handleOptOut(phoneNumber: string): Promise<void> { // Placeholder: Implement logic to handle opt-out requests // This might involve updating a database or calling an AWS API if available this.logger.log(`Processing opt-out for ${phoneNumber}`); // Example: await this.databaseService.markOptedOut(phoneNumber); // Optionally send a confirmation if required by regulations // await this.sendReply(phoneNumber, "You have been unsubscribed. Reply START to resubscribe."); } }
4. Troubleshooting and Caveats
- Permissions: The most common issue. Ensure:
- The IAM role/policy used in the Pinpoint phone number's two-way configuration can
sns:Publish
to your SNS topic. - SNS topic policy allows the Pinpoint SMS/Voice service (
sms-voice.amazonaws.com
) or the specific role ARN. - SQS queue policy allows
sqs:SendMessage
from the SNS topic ARN (aws:SourceArn
condition). - Lambda Execution Role has permissions for:
sqs:ReceiveMessage
,sqs:DeleteMessage
,sqs:GetQueueAttributes
for the SQS queue.logs:CreateLogGroup
,logs:CreateLogStream
,logs:PutLogEvents
for CloudWatch Logs.cloudwatch:PutMetricData
(often included in basic execution role).pinpoint-sms-voice:SendTextMessage
(or broadersms-voice:*
) if sending replies, using thepinpoint-sms-voice-v2
API.- Any database permissions (DynamoDB, RDS).
- The IAM role/policy used in the Pinpoint phone number's two-way configuration can
- Message Parsing (Raw Delivery): Your Lambda parsing logic must match the ""Enable raw message delivery"" setting on the SNS->SQS subscription. This guide's code assumes it's enabled. If disabled, you need to parse the outer SNS structure first (
JSON.parse(record.body).Message
). Log the rawrecord.body
in CloudWatch to verify the structure you receive. - Region Consistency: Keep your Phone Number, SNS Topic, SQS Queue, and Lambda Function in the same AWS Region for simplicity and lower latency, unless you have specific cross-region requirements.
- Delays: There can sometimes be minor delays in the AWS pipeline (SMS -> Pinpoint -> SNS -> SQS -> Lambda). Design your application to be tolerant of potential latency.
- Error Handling & Retries: Configure SQS Dead Letter Queues (DLQs) to handle messages that consistently fail processing. Implement robust error handling within your Lambda function and decide whether failed messages should be retried (by throwing an error from the Lambda handler) or discarded/logged.
- Concurrency: Understand Lambda concurrency limits and how they interact with SQS batching and processing time. Adjust Lambda reserved concurrency if needed.
- Cost: Be mindful of the costs associated with each AWS service (Pinpoint phone number rental/usage, SNS messages, SQS requests, Lambda execution time, CloudWatch logs).
- Opt-Out Handling: Ensure compliance with regulations regarding SMS opt-out requests (e.g., STOP keyword). AWS Pinpoint might handle some standard keywords automatically depending on the region and number type, but you may need custom logic to update your own systems.
- SDK Initialization: The AWS SDK v3 clients are initialized in the service constructor (
SmsService
). This means the client is created once per Lambda container instance, which is efficient. Ensure the Lambda execution role provides the necessary credentials.