code examples

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

Developer Guide: Implementing Two-Way SMS with Fastify, Node.js, AWS Pinpoint, and SNS

A comprehensive guide to building a serverless two-way SMS system using Fastify, Node.js, AWS Pinpoint, SNS, Lambda, and API Gateway.

This guide provides a complete walkthrough for building a production-ready two-way SMS messaging system using Node.js with the Fastify framework, AWS Pinpoint for sending messages, and AWS Simple Notification Service (SNS) for receiving incoming replies. We will build a Fastify application deployable as an AWS Lambda function, triggered by API Gateway, capable of sending SMS messages via an API endpoint and automatically handling replies received via SNS.

By the end of this tutorial, you will have a scalable serverless application that can:

  • Send outbound SMS messages programmatically via AWS Pinpoint.
  • Receive inbound SMS messages sent to your Pinpoint phone number.
  • Process inbound messages using a Fastify application running on AWS Lambda.
  • Implement basic auto-reply functionality.

Project Overview and Goals

Problem: Businesses often need to send notifications, alerts, or marketing messages via SMS (Application-to-Person, A2P) and handle replies from users (two-way communication). Building a reliable and scalable system for this requires integrating messaging providers and handling asynchronous incoming messages.

Solution: We will leverage AWS services for robust messaging capabilities and Fastify for a high-performance Node.js backend, deployed serverlessly for scalability and cost-efficiency.

Technologies Used:

  • Node.js: Runtime environment for our backend application.
  • Fastify: A fast and low-overhead web framework for Node.js, suitable for building APIs and serverless functions.
  • AWS Pinpoint: Used for sending outbound SMS messages and providing the dedicated phone number.
  • AWS SNS (Simple Notification Service): Used to receive notifications when an SMS is sent to our Pinpoint number.
  • AWS Lambda: Serverless compute service to run our Fastify application code without managing servers.
  • AWS API Gateway (HTTP API): Creates an HTTP endpoint that triggers our Lambda function, enabling SNS to send inbound message data to our application.
  • AWS SDK for JavaScript (v2): Used within our Node.js application to interact with Pinpoint and SNS APIs. (See note in Section 1.3 regarding v3).
  • dotenv: Module to manage environment variables securely.
  • @fastify/aws-lambda: Fastify plugin to adapt our application for AWS Lambda execution.

System Architecture:

text
+-------------+       +-----------------+       +-----------+       +-----------------+       +-------------+
| API Client  | ----> | API Gateway     | ----> | AWS Lambda| ----> | AWS Pinpoint API| ----> | User's Phone| (Outbound)
| (e.g. curl)|       | (POST /send)    |       | (Fastify) |       | (sendMessages)  |       +-------------+
+-------------+       +-----------------+       +-----------+       +-----------------+
                                                    ^  |
                                                    |  | (Process & Auto-Reply)
                                                    |  v
+-------------+       +-----------------+       +-----+-----+       +-----------------+       +-----------------+
| User's Phone| ----> | AWS Pinpoint    | ----> | AWS SNS   | ----> | API Gateway     | ----> | AWS Lambda      | (Inbound)
| (Sends SMS) |       | (Receives SMS)  |       | (Topic)   |       | (POST /webhook) |       | (Fastify App)   |
+-------------+       +-----------------+       +-----------+       +-----------------+       +-----------------+

Prerequisites:

  • An AWS account with permissions to manage IAM, Pinpoint, SNS, Lambda, and API Gateway.
  • Node.js (v18 or later recommended) and npm installed locally.
  • AWS CLI installed and configured locally (for deployment steps, optional but recommended).
  • Basic understanding of Node.js, APIs, and AWS concepts.
  • A text editor or IDE (like VS Code).
  • A tool for testing API endpoints (like curl or Postman).

1. Setting up the Project

Let's initialize our Node.js project and install the necessary dependencies.

  1. Create Project Directory:

    bash
    mkdir fastify-sms-two-way
    cd fastify-sms-two-way
  2. Initialize Node.js Project:

    bash
    npm init -y
  3. Install Dependencies:

    bash
    npm install fastify aws-sdk dotenv @fastify/aws-lambda
    • fastify: The core web framework.
    • aws-sdk: AWS SDK for JavaScript (v2) to interact with Pinpoint and SNS.
    • dotenv: To load environment variables from a .env file for local development.
    • @fastify/aws-lambda: Adapter to run Fastify on AWS Lambda.

    Note: This guide uses AWS SDK v2. For new projects, AWS SDK v3 is generally recommended due to its modularity and modern features like middleware support. Using v3 would require changes to the AWS client initialization and API call syntax (e.g., importing specific client commands instead of the full SDK).

  4. Create Project Structure:

    text
    fastify-sms-two-way/
    ├── .env.example         # Example environment variables
    ├── .gitignore           # Git ignore file
    ├── node_modules/        # Installed dependencies (managed by npm)
    ├── package.json
    ├── package-lock.json
    ├── src/
    │   ├── app.js           # Fastify application setup and routes
    │   ├── lambda.js        # AWS Lambda handler entry point
    │   └── utils/
    │       └── aws.js       # AWS SDK client initialization
    └── README.md            # Project description (optional)

    Create the src and src/utils directories.

  5. Create .gitignore: Create a .gitignore file in the project root to prevent committing sensitive files and dependencies:

    text
    # Environment variables
    .env
    
    # Node dependencies
    node_modules/
    
    # Development dependencies (if separate)
    node_modules_dev/
    
    # Build artifacts (if any)
    dist/
    build/
    deployment_package.zip
    
    # Log files
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    
    # OS generated files
    .DS_Store
    Thumbs.db
  6. Create .env.example: Create a .env.example file in the project root. This serves as a template. We will populate the actual .env file later with credentials obtained from AWS.

    dotenv
    # AWS Credentials & Region (Primarily for LOCAL development)
    AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID
    AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY
    AWS_REGION=us-east-1 # e.g., us-east-1, eu-west-1
    
    # AWS Pinpoint Configuration
    PINPOINT_APP_ID=YOUR_PINPOINT_APPLICATION_ID
    PINPOINT_ORIGINATION_NUMBER=+1XXXXXXXXXX # Your purchased Pinpoint phone number in E.164 format
    
    # AWS SNS Configuration (For inbound message handling)
    SNS_TOPIC_ARN=arn:aws:sns:YOUR_REGION:ACCOUNT_ID:YOUR_TOPIC_NAME # ARN of the SNS topic

    Security Note: The .env file containing actual secrets should never be committed to version control.

2. AWS Configuration

Before writing code, we need to configure the necessary AWS resources.

  1. Configure IAM Permissions (User for Local Dev, Role for Lambda):

    • IAM User (for Local Development/CLI): While an IAM user with access keys is needed for local development and interacting with AWS via the CLI or SDKs from your machine, the primary and recommended way for the deployed Lambda function to obtain permissions is through an IAM Execution Role (see Section 5.2). This avoids embedding long-lived credentials in the function's environment.

      • Go to the AWS IAM Console.
      • Create a new User. Give it a descriptive name (e.g., fastify-sms-dev-user).
      • Select ""Access key - Programmatic access"" as the credential type.
      • Attach Policies (Least Privilege): Instead of using broad FullAccess policies, it is strongly recommended to create custom IAM policies granting only the necessary permissions for local testing. Examples include:
        • pinpoint:SendMessages (to send SMS)
        • sns:Publish, sns:Subscribe, sns:ListTopics (if needed for local SNS interaction/setup)
        • Potentially iam:PassRole if your local setup involves assuming roles.
      • Important: Securely store the generated Access key ID and Secret access key. These will go into your local .env file.
    • IAM Role (for Lambda Execution): We will create or assign this role during Lambda deployment (Section 5.2). This role needs permissions like:

      • pinpoint:SendMessages (to send replies)
      • logs:CreateLogGroup, logs:CreateLogStream, logs:PutLogEvents (for CloudWatch logging)
      • Potentially sns:Publish if the Lambda needs to publish to other topics.
      • Note: The Lambda role does not typically need SNS Subscription permissions; the subscription is made to the Lambda's trigger (API Gateway).
  2. Set up AWS Pinpoint:

    • Go to the AWS Pinpoint Console.
    • If you don't have one, create a new Pinpoint project. Note the Project ID (also called Application ID).
    • Navigate to ""SMS and voice"" settings within your project.
    • Enable the SMS channel for the project if it's not already enabled.
    • Go to ""Phone numbers"" under SMS and voice settings.
    • Request a phone number. Choose a country and ensure the number supports SMS and Two-way SMS. This might require registration depending on the country (e.g., 10DLC for the US). Note the purchased Phone number in E.164 format (e.g., +12065550100).
    • Once you have the number, select it. In the configuration panel at the bottom, find the ""Two-way SMS"" section.
    • Enable Two-way SMS.
    • For ""Incoming message destination,"" select ""Choose an existing SNS topic"". We will create the SNS topic next and come back here to select it.
  3. Create an SNS Topic:

    • Go to the AWS SNS Console.
    • Click ""Topics"" -> ""Create topic"".
    • Choose ""Standard"" type.
    • Give it a descriptive name (e.g., twoWaySMSHandler).
    • Leave other settings as default and click ""Create topic"".
    • Note the Topic ARN (Amazon Resource Name). It looks like arn:aws:sns:us-east-1:123456789012:twoWaySMSHandler.
  4. Link Pinpoint to SNS Topic:

    • Go back to the AWS Pinpoint Console -> Phone numbers.
    • Select your phone number again.
    • In the ""Two-way SMS"" configuration, now select the SNS topic you just created (twoWaySMSHandler) from the dropdown.
    • Click Save changes. Now, any SMS message sent to your Pinpoint number will be published to this SNS topic.
  5. Update .env File (for Local Development): Create a .env file in your project root (copy .env.example) and populate it with the actual values you obtained for local testing:

    • AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION (from the IAM User created in Step 1).
    • PINPOINT_APP_ID (from Pinpoint Project).
    • PINPOINT_ORIGINATION_NUMBER (the Pinpoint phone number you acquired).
    • SNS_TOPIC_ARN (from the SNS Topic).

3. Implementing Core Functionality

Now let's write the code for our Fastify application.

  1. Initialize AWS SDK Clients (src/utils/aws.js): Create a utility file to centralize AWS SDK client initialization.

    javascript
    // src/utils/aws.js
    'use strict';
    
    const AWS = require('aws-sdk'); // Using AWS SDK v2
    
    // Load environment variables locally if not in Lambda
    if (!process.env.AWS_LAMBDA_FUNCTION_NAME) {
      require('dotenv').config();
      console.log('Loaded .env file for local development.');
    }
    
    // Basic validation for essential configuration
    const requiredConfig = {
      // Keys are env var names, values are descriptions
      AWS_REGION: 'AWS Region',
      PINPOINT_APP_ID: 'Pinpoint Application ID',
      PINPOINT_ORIGINATION_NUMBER: 'Pinpoint Origination Number',
      SNS_TOPIC_ARN: 'SNS Topic ARN for Inbound SMS'
    };
    
    // For local dev, also check for credentials
    if (!process.env.AWS_LAMBDA_FUNCTION_NAME) {
        requiredConfig.AWS_ACCESS_KEY_ID = 'AWS Access Key ID (local only)';
        requiredConfig.AWS_SECRET_ACCESS_KEY = 'AWS Secret Access Key (local only)';
    }
    
    let configValid = true;
    for (const varName in requiredConfig) {
      if (!process.env[varName]) {
        console.error(`Error: Missing required environment variable: ${varName} (${requiredConfig[varName]})`);
        configValid = false;
      }
    }
    
    if (!configValid) {
        throw new Error(""Missing required environment variables. Check logs."");
    }
    
    // Configure AWS SDK
    // In Lambda, credentials will be sourced from the execution role automatically if keys aren't set here.
    const awsConfig = {
      region: process.env.AWS_REGION,
    };
    if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
        awsConfig.accessKeyId = process.env.AWS_ACCESS_KEY_ID;
        awsConfig.secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
        console.log('Using AWS credentials from environment variables.');
    } else if (process.env.AWS_LAMBDA_FUNCTION_NAME) {
        console.log('Using AWS credentials from Lambda execution role.');
    } else {
        console.warn('Warning: AWS credentials not found in environment variables for local execution. SDK might try other sources (e.g., ~/.aws/credentials).');
    }
    
    AWS.config.update(awsConfig);
    
    // Create Pinpoint client
    const pinpoint = new AWS.Pinpoint();
    
    // Create SNS client (might be useful for other SNS operations, not strictly needed for receiving)
    const sns = new AWS.SNS({ apiVersion: '2010-03-31' });
    
    console.log(`AWS SDK configured for region: ${process.env.AWS_REGION}`);
    console.log(`Using Pinpoint App ID: ${process.env.PINPOINT_APP_ID}`);
    console.log(`Using Pinpoint Origination Number: ${process.env.PINPOINT_ORIGINATION_NUMBER}`);
    
    module.exports = {
      pinpoint,
      sns,
      pinpointAppId: process.env.PINPOINT_APP_ID,
      pinpointOriginationNumber: process.env.PINPOINT_ORIGINATION_NUMBER,
      snsTopicArn: process.env.SNS_TOPIC_ARN,
    };
    • Uses dotenv only locally.
    • Validates required environment variables.
    • Configures SDK: Uses keys from .env locally, relies on Lambda execution role when deployed.
  2. Create Fastify Application (src/app.js): This file sets up the Fastify instance, defines routes for sending and receiving SMS, and includes basic error handling.

    javascript
    // src/app.js
    'use strict';
    
    const Fastify = require('fastify');
    const { pinpoint, pinpointAppId, pinpointOriginationNumber } = require('./utils/aws');
    
    // --- Helper Function to Send SMS via Pinpoint ---
    async function sendSms(destinationNumber, message, messageType = 'TRANSACTIONAL') {
      if (!destinationNumber || !message) {
        throw new Error('Destination number and message are required.');
      }
      // Basic E.164 format check (can be improved)
      if (!/^\+[1-9]\d{1,14}$/.test(destinationNumber)) {
         throw new Error('Invalid destination phone number format. Use E.164 format (e.g., +12065550100).');
      }
      if (!pinpointOriginationNumber || !/^\+[1-9]\d{1,14}$/.test(pinpointOriginationNumber)) {
         console.error(`FATAL: Invalid or missing Pinpoint Origination Number configured: ${pinpointOriginationNumber}. Check environment variable PINPOINT_ORIGINATION_NUMBER.`);
         throw new Error('Server configuration error: Invalid origination number.');
      }
    
      const params = {
        ApplicationId: pinpointAppId,
        MessageRequest: {
          Addresses: {
            [destinationNumber]: {
              ChannelType: 'SMS'
            }
          },
          MessageConfiguration: {
            SMSMessage: {
              Body: message,
              MessageType: messageType, // TRANSACTIONAL (high reliability) or PROMOTIONAL (lower cost)
              OriginationNumber: pinpointOriginationNumber,
              // SenderId: 'MyBrand' // Optional: Custom Sender ID (requires registration in many countries)
              // Keyword: 'REGISTERED_KEYWORD' // Optional: Required by some carriers/regulations
            }
          }
        }
      };
    
      try {
        const result = await pinpoint.sendMessages(params).promise();
        const messageResult = result.MessageResponse.Result[destinationNumber];
        // Check for delivery status - Pinpoint returns 200 OK even for some failures
        if (messageResult.DeliveryStatus !== 'SUCCESSFUL') {
            console.warn(`Pinpoint reported non-successful delivery status | To: ${destinationNumber} | Status: ${messageResult.StatusCode} - ${messageResult.DeliveryStatus} | Message: ${messageResult.StatusMessage}`);
        } else {
             console.log(`SMS Sent via Pinpoint | To: ${destinationNumber} | Status: ${messageResult.StatusCode} - ${messageResult.DeliveryStatus} | MessageID: ${messageResult.MessageId}`);
        }
        return messageResult;
      } catch (err) {
        console.error(`Error calling Pinpoint sendMessages API for ${destinationNumber}:`, err);
        throw new Error(`Failed to send SMS. AWS Error: ${err.message}`); // Re-throw specific error
      }
    }
    
    // --- Fastify Application Initialization ---
    function build(opts = {}) {
      const app = Fastify(opts);
    
      // --- Route: Send Outbound SMS ---
      // POST /send
      // Body: { ""to"": ""+1XXXXXXXXXX"", ""message"": ""Your message content"" }
      app.post('/send', {
        schema: { // Basic request validation
          body: {
            type: 'object',
            required: ['to', 'message'],
            properties: {
              to: { type: 'string', description: 'Recipient phone number in E.164 format', pattern: '^\\+[1-9]\\d{1,14}' },
              message: { type: 'string', minLength: 1, description: 'SMS message content' }
            }
          },
          response: {
            200: {
              type: 'object',
              properties: {
                status: { type: 'string' },
                deliveryStatus: { type: 'string' },
                messageId: { type: 'string' },
                destination: { type: 'string'}
              }
            },
            // Add other response schemas for errors (400, 500) if desired
          }
        }
      }, async (request, reply) => {
        const { to, message } = request.body;
        request.log.info(`Received request to send SMS | To: ${to}`); // Use Fastify logger
    
        try {
          const result = await sendSms(to, message); // Use the helper function
          reply.code(200).send({
            status: result.StatusMessage, // Pinpoint's status message
            deliveryStatus: result.DeliveryStatus, // Actual delivery status
            messageId: result.MessageId,
            destination: to
          });
        } catch (error) {
          request.log.error({ err: error }, `Failed to send SMS to ${to}`);
          // Determine status code based on error type
          if (error.message.includes('Invalid destination phone number') || error.message.includes('required') || error.message.includes('pattern')) {
             reply.code(400).send({ error: 'Bad Request', message: error.message });
          } else if (error.message.includes('Server configuration error')) {
             reply.code(500).send({ error: 'Configuration Error', message: 'Internal server configuration problem.' });
          } else {
             reply.code(500).send({ error: 'Internal Server Error', message: 'Failed to send SMS.' });
          }
        }
      });
    
      // --- Route: Handle Inbound SMS from SNS ---
      // POST /webhook/sns
      // Body: SNS Notification containing the SMS message
      app.post('/webhook/sns', async (request, reply) => {
         request.log.info('Received webhook notification from SNS');
         const messageType = request.headers['x-amz-sns-message-type'];
    
         try {
            // --- 1. Handle SNS Subscription Confirmation ---
            if (messageType === 'SubscriptionConfirmation') {
                request.log.info(`Received SNS Subscription Confirmation request. URL: ${request.body.SubscribeURL}`);
                // IMPORTANT: **NEVER** automatically visit the `SubscribeURL` in a production environment
                // without proper validation (e.g., checking the request signature). Doing so creates a
                // Server-Side Request Forgery (SSRF) vulnerability.
                // For this guide, we log the URL and rely on manual confirmation via the AWS console or browser.
                // Production systems should implement SNS message signature validation using libraries like `sns-validator`.
                console.log('ACTION REQUIRED: Confirm SNS subscription via the AWS SNS console or by manually visiting the logged SubscribeURL.');
                reply.code(200).send({ status: 'Subscription confirmation required. Please confirm manually.' });
                return; // Stop processing here
            }
    
            // --- 2. Handle SNS Notification Message ---
            if (messageType === 'Notification') {
                request.log.info('Processing SNS Notification...');
                const snsMessage = request.body; // Already parsed by Fastify if Content-Type is application/json
    
                // The actual SMS data is within a JSON string inside snsMessage.Message
                if (typeof snsMessage.Message !== 'string') {
                  request.log.error({ receivedMessage: snsMessage.Message }, 'SNS Message format unexpected. snsMessage.Message is not a string. Is Raw Message Delivery enabled?');
                  throw new Error('SNS Message format unexpected. Message is not a string.');
                }
    
                let inboundSmsData;
                try {
                    inboundSmsData = JSON.parse(snsMessage.Message);
                } catch (parseError) {
                    request.log.error({ err: parseError, messageContent: snsMessage.Message }, 'Failed to parse JSON from snsMessage.Message');
                    throw new Error('Failed to parse inbound SMS data from SNS message.');
                }
    
                request.log.info({ inboundSmsData }, 'Parsed inbound SMS data');
    
                const senderNumber = inboundSmsData.originationNumber;
                const recipientNumber = inboundSmsData.destinationNumber; // Your Pinpoint number
                const messageBody = inboundSmsData.messageBody;
                // const messageId = inboundSmsData.messageId; // Available if needed
    
                // --- 3. Implement Business Logic (e.g., Auto-Reply) ---
                console.log(`Received SMS | From: ${senderNumber} | To: ${recipientNumber} | Message: ""${messageBody}""`);
    
                // Example: Simple Auto-Reply
                const replyMessage = `Thanks for your message! We received: ""${messageBody}"". We'll get back to you soon.`;
    
                try {
                    await sendSms(senderNumber, replyMessage); // Send reply back to the sender
                    console.log(`Auto-reply sent to ${senderNumber}`);
                } catch (replyError) {
                    request.log.error({ err: replyError }, `Failed to send auto-reply to ${senderNumber}`);
                    // Decide if the overall request should fail if auto-reply fails.
                    // For now, we log the error but still return 200 OK for the webhook receipt.
                }
    
                reply.code(200).send({ status: 'Inbound message processed successfully' });
    
            } else if (messageType === 'UnsubscribeConfirmation') {
                request.log.info(`Received SNS Unsubscribe Confirmation for Topic: ${request.body.TopicArn} | Token: ${request.body.Token}`);
                reply.code(200).send({ status: 'Unsubscribe notification received.'});
            }
            else {
                request.log.warn(`Received unhandled SNS message type: ${messageType}`);
                reply.code(400).send({ error: 'Unhandled message type', type: messageType });
            }
         } catch (error) {
             request.log.error({ err: error }, 'Error processing SNS webhook');
             // Ensure a response is always sent to SNS to prevent unnecessary retries
             reply.code(500).send({ error: 'Internal Server Error', message: 'Failed to process incoming message.' });
         }
      });
    
       // --- Route: Health Check ---
       app.get('/health', async (request, reply) => {
           // Could add checks here (e.g., AWS connectivity) if needed
           return { status: 'ok', timestamp: new Date().toISOString() };
       });
    
      return app;
    }
    
    module.exports = { build, sendSms }; // Export build for lambda.js and sendSms for potential direct use/testing
    • Includes stronger warnings about SNS subscription confirmation and SSRF.
    • Includes basic E.164 validation for to number in /send schema.
    • Adds logging and error handling for SNS message parsing.
    • Improves error handling in /send route based on error type.
  3. Create Lambda Handler (src/lambda.js): This file uses @fastify/aws-lambda to wrap our Fastify application instance.

    javascript
    // src/lambda.js
    'use strict';
    
    const awsLambdaFastify = require('@fastify/aws-lambda');
    const { build } = require('./app'); // Import the build function from app.js
    
    // Initialize the Fastify app instance
    // Pass logger: true for CloudWatch logging integration
    const app = build({
      logger: {
        level: process.env.LOG_LEVEL || 'info', // Control log level via env var
        // Pino default serializers handle common fields like err, req, res
      }
    });
    
    // Create the proxy function using @fastify/aws-lambda
    // This adapts API Gateway v1 (REST) or v2 (HTTP) events to Fastify requests
    const proxy = awsLambdaFastify(app);
    
    // Export the handler function for AWS Lambda
    exports.handler = async (event, context) => {
      // Log basic event info (avoid logging full event in production if it contains sensitive data)
      console.log(`Lambda handler invoked. Request ID: ${context.awsRequestId}, Event source: ${event.requestContext?.domainName || 'Unknown'}`);
    
      try {
        // Add any invocation-specific context if needed, e.g., from context object
        // app.decorateRequest('lambdaContext', context);
    
        const result = await proxy(event, context);
        return result;
      } catch (error) {
        // Log the error using Fastify's logger if available, otherwise console.error
        if (app.log) {
          app.log.error({ err: error, event }, 'Error in Lambda handler proxy execution');
        } else {
          console.error('Error in Lambda handler proxy execution:', error, 'Event:', event);
        }
    
        // Ensure a valid API Gateway response structure is returned on error
        return {
          statusCode: 500,
          body: JSON.stringify({ error: 'Internal Lambda Error', message: error.message || 'An unexpected error occurred.' }),
          headers: { 'Content-Type': 'application/json' },
        };
      }
    };
    • Initializes Fastify with logging enabled.
    • Uses @fastify/aws-lambda proxy.
    • Exports the standard Lambda handler, including basic logging and error handling.

4. Local Development and Testing

Before deploying, you can run the application locally.

  1. Add Start Script: Add a script to your package.json for local execution:

    json
    // package.json (add within ""scripts"")
    ""scripts"": {
      ""start:local"": ""node -r dotenv/config ./scripts/run-local.js"", // Create this script
      ""test"": ""echo \""Error: no test specified\"" && exit 1""
    },
    • -r dotenv/config: Preloads dotenv to load variables from .env.
  2. Install pino-pretty and Create Local Runner (scripts/run-local.js): First, install the development dependency for nice local logging:

    bash
    npm install --save-dev pino-pretty

    Now, create a scripts directory and add run-local.js. This script runs the Fastify app directly using its built-in server.

    javascript
    // scripts/run-local.js
    'use strict';
    
    // dotenv is loaded via -r flag in npm script
    
    const { build } = require('../src/app'); // Adjust path relative to project root
    
    const PORT = process.env.PORT || 3000;
    const HOST = '127.0.0.1'; // Listen only on localhost by default for security
    
    const start = async () => {
      const app = build({
        logger: {
          level: 'info',
          transport: { // Pretty print logs locally using pino-pretty
            target: 'pino-pretty',
            options: {
              translateTime: 'HH:MM:ss Z', // Human-readable time
              ignore: 'pid,hostname', // Ignore noisy fields
              colorize: true,
            },
          },
        }
      });
    
      try {
        await app.listen({ port: PORT, host: HOST });
        // Logger is available after listen/ready event, but log.info works here too
        console.log(`Server listening on http://${HOST}:${PORT}`);
        console.log('Local routes:');
        console.log(`  POST http://${HOST}:${PORT}/send`);
        console.log(`  POST http://${HOST}:${PORT}/webhook/sns`);
        console.log(`  GET  http://${HOST}:${PORT}/health`);
      } catch (err) {
        // Use console.error before logger might be fully initialized
        console.error('Error starting local server:', err);
        process.exit(1);
      }
    };
    
    start();
    • Uses pino-pretty for improved local log readability.
    • Uses app.listen for standard HTTP server behavior.
  3. Run Locally: Make sure your .env file is populated with your IAM User credentials and other config.

    bash
    npm run start:local

    The server should start, listening on port 3000.

  4. Test Sending SMS (Local): Use curl or Postman to send a POST request to http://localhost:3000/send:

    bash
    curl -X POST http://localhost:3000/send \
    -H ""Content-Type: application/json"" \
    -d '{
      ""to"": ""+1RECIPIENTNUMBER"",
      ""message"": ""Hello from local Fastify!""
    }'

    Replace +1RECIPIENTNUMBER with a valid test phone number. Check your terminal logs (nicely formatted by pino-pretty) and the recipient's phone for the message.

    Note on testing inbound: Testing the /webhook/sns route locally requires simulating an SNS notification. This is complex because AWS SNS needs a publicly accessible HTTPS endpoint to send notifications to. Tools like ngrok can expose your local server to the public internet with an HTTPS URL, which you could temporarily use in the SNS subscription. However, full end-to-end testing of the inbound flow is often easier after deployment.

5. Deployment to AWS Lambda and API Gateway

We'll deploy the application as a Lambda function triggered by API Gateway.

  1. Package the Application: Create a zip file containing your code and only production dependencies.

    • First, ensure development dependencies are pruned:
      bash
      npm prune --omit=dev
    • Create the zip file from the project root directory:
      bash
      # Ensure you are in the fastify-sms-two-way root directory
      zip -r deployment_package.zip src node_modules package.json package-lock.json

    This command packages the src directory (containing app.js, lambda.js, utils/aws.js), the production node_modules, and package files.

  2. Create the Lambda Function:

    • Go to the AWS Lambda Console.
    • Click ""Create function"".
    • Select ""Author from scratch"".
    • Function name: fastify-sms-handler (or similar).
    • Runtime: Select a recent Node.js version (e.g., Node.js 18.x or 20.x).
    • Architecture: x86_64 or arm64.
    • Permissions: This is critical.
      • Choose ""Create a new role with basic Lambda permissions"" OR select an existing role.
      • Edit the role: Go to the IAM console and find the role created/selected for the Lambda function (e.g., fastify-sms-handler-role-xxxx).
      • Attach policies (or create an inline policy) granting the necessary permissions:
        • pinpoint:SendMessages (to send replies from the webhook).
        • The basic execution role already includes CloudWatch Logs permissions (logs:CreateLogGroup, logs:CreateLogStream, logs:PutLogEvents).
        • Do not add SNS subscription permissions here.
    • Click ""Create function"".
  3. Upload Code:

    • In the Lambda function's configuration page, find the ""Code source"" section.
    • Click ""Upload from"" -> "".zip file"".
    • Upload the deployment_package.zip file you created.
    • Click ""Save"".
  4. Configure Handler and Environment Variables:

    • In the ""Runtime settings"" section, click ""Edit"".
    • Set the Handler to src/lambda.handler (pointing to exports.handler in src/lambda.js).
    • Click ""Save"".
    • Go to the ""Configuration"" tab -> ""Environment variables"".
    • Click ""Edit"".
    • Add the following environment variables (these are used by src/utils/aws.js when running in Lambda):
      • AWS_REGION: Your AWS region (e.g., us-east-1).
      • PINPOINT_APP_ID: Your Pinpoint Application ID.
      • PINPOINT_ORIGINATION_NUMBER: Your Pinpoint phone number (E.164 format).
      • SNS_TOPIC_ARN: The ARN of your SNS topic for inbound messages.
      • LOG_LEVEL (Optional): Set to debug, warn, error to control logging verbosity (defaults to info).
    • Important: Do not add AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY here. The Lambda function will use the permissions granted by its Execution Role.
    • Click ""Save"".
  5. Create API Gateway (HTTP API): We'll use an HTTP API for simplicity and cost-effectiveness.

    • Go to the AWS API Gateway Console.
    • Click ""Build"" under HTTP API.
    • Click ""Add integration"".
    • Integration type: Lambda.
    • AWS Region: Select the region where your Lambda function is.
    • Lambda function: Choose your fastify-sms-handler function.
    • API name: SMSTwoWayAPI (or similar).
    • Click ""Next"".
    • Configure routes:
      • Method: POST, Resource path: /send. Integration target: fastify-sms-handler.
      • Method: POST, Resource path: /webhook/sns. Integration target: fastify-sms-handler.
      • Method: GET, Resource path: /health. Integration target: fastify-sms-handler.
    • Click ""Next"".
    • Configure stages: Default stage $default is fine for now. Ensure ""Auto-deploy"" is enabled.
    • Click ""Next"".
    • Review and click ""Create"".
    • Note the Invoke URL provided after creation (e.g., https://abcdef123.execute-api.us-east-1.amazonaws.com).
  6. Subscribe SNS Topic to API Gateway Endpoint:

    • Go back to the AWS SNS Console.
    • Select your twoWaySMSHandler topic.
    • Go to the ""Subscriptions"" tab.
    • Click ""Create subscription"".
    • Protocol: HTTPS.
    • Endpoint: Enter the API Gateway Invoke URL specifically for the webhook route. It will be like: https://abcdef123.execute-api.us-east-1.amazonaws.com/webhook/sns.
    • Enable raw message delivery: Check this box. This sends the raw Pinpoint JSON message directly to your endpoint, which our code expects (JSON.parse(snsMessage.Message)). If unchecked, SNS wraps the message in its own JSON structure.
    • Click ""Create subscription"".
    • Confirmation: The subscription status will be ""Pending confirmation"". Check your Lambda function's CloudWatch logs (associated with the /webhook/sns route). You should see the log message containing the SubscribeURL from our Fastify app. Manually copy and paste this URL into your browser to confirm the subscription. Alternatively, you can confirm it within the SNS console if the endpoint responds correctly (which our code does by logging and returning 200). Once confirmed, the status will change to ""Confirmed"".

6. End-to-End Testing (Deployed)

  1. Test Outbound (/send): Use curl or Postman to send a POST request to your deployed API Gateway /send endpoint:

    bash
    curl -X POST https://<your-api-id>.execute-api.<region>.amazonaws.com/send \
    -H ""Content-Type: application/json"" \
    -d '{
      ""to"": ""+1RECIPIENTNUMBER"",
      ""message"": ""Hello from deployed Fastify Lambda!""
    }'

    Replace <your-api-id> and <region> with your API Gateway details, and +1RECIPIENTNUMBER with a test number. Verify the message is received. Check CloudWatch Logs for your Lambda function for details.

  2. Test Inbound (/webhook/sns and Auto-Reply):

    • Send an SMS message from your test phone to your AWS Pinpoint phone number.
    • Check CloudWatch Logs: Monitor the logs for your fastify-sms-handler Lambda function. You should see logs indicating:
      • Receipt of the SNS notification on /webhook/sns.
      • Parsing of the inbound SMS data.
      • The console.log showing the received message details.
      • Logs related to sending the auto-reply via Pinpoint.
    • Check Your Test Phone: You should receive the auto-reply message (""Thanks for your message!..."").
  3. Test Health Check (/health):

    bash
    curl https://<your-api-id>.execute-api.<region>.amazonaws.com/health

    You should receive a JSON response like: {""status"":""ok"",""timestamp"":""...""}.

Conclusion and Next Steps

You have successfully built and deployed a serverless two-way SMS application using Fastify, Node.js, and several AWS services. This provides a scalable foundation for handling SMS communication.

Potential Enhancements:

  • SNS Message Signature Validation: Implement robust validation for incoming SNS messages using libraries like sns-validator to prevent SSRF and ensure messages genuinely originate from your SNS topic.
  • More Sophisticated Routing/Logic: Instead of a simple auto-reply, route messages based on keywords, store conversation history in a database (like DynamoDB), or integrate with other business systems.
  • Error Handling and Retries: Implement more robust error handling, potentially using Dead Letter Queues (DLQs) for Lambda or SNS if processing fails.
  • Rate Limiting: Add rate limiting to your API Gateway endpoints to prevent abuse.
  • Monitoring and Alerting: Set up CloudWatch Alarms based on Lambda errors, invocation counts, or specific log messages.
  • AWS SDK v3: Migrate the code to use the modular AWS SDK v3 for JavaScript.
  • Infrastructure as Code (IaC): Define your AWS resources (Lambda, API Gateway, SNS, IAM Roles) using tools like AWS SAM, AWS CDK, Serverless Framework, or Terraform for repeatable deployments.
  • Cost Optimization: Monitor Pinpoint, Lambda, and API Gateway costs. Choose appropriate message types (Transactional vs. Promotional).

Frequently Asked Questions

How to send two-way SMS with AWS Pinpoint?

Use AWS Pinpoint to send outbound messages and configure a dedicated phone number. Incoming messages are routed through AWS SNS to a webhook on your application, enabling two-way communication. This setup allows your app to both send and receive SMS messages.

What is the role of Fastify in two-way SMS setup?

Fastify serves as a high-performance Node.js web framework for creating the backend application. Its speed and efficiency make it ideal for handling API requests and serverless functions within the two-way SMS architecture.

Why use AWS Lambda for a two-way SMS application?

AWS Lambda provides serverless compute, allowing you to run your Fastify application without managing servers. This offers scalability and cost-efficiency, as you only pay for the compute time used to process messages.

When should I use AWS SDK v3 for two-way SMS?

While this guide uses AWS SDK v2, AWS SDK v3 is recommended for new projects due to its modularity and modern features. Migrating to v3 requires adjustments to client initialization and API call syntax.

Can I test two-way SMS locally?

Yes, you can run the application locally and test outbound SMS sending using tools like curl or Postman and the /send endpoint. Testing inbound messages locally is more complex, requiring tools like ngrok to simulate SNS notifications, with full end-to-end testing often being easier after deployment.

What AWS services are required for two-way SMS?

The core AWS services are Pinpoint for sending, SNS for receiving, Lambda for running the application, API Gateway for the HTTP endpoint, and IAM for permissions. An AWS account with necessary permissions is a prerequisite.

How to handle inbound SMS messages?

Inbound SMS messages are routed from the user's phone to your Pinpoint number, which then triggers an SNS notification to your application's webhook. The application processes the message, enabling actions like auto-replies.

How to secure AWS credentials in two-way SMS project?

For local development, store credentials securely in a .env file (never commit to version control). For the deployed Lambda function, use an IAM Execution Role to grant the necessary permissions, avoiding the need to embed credentials directly in the function's environment.

What is the purpose of API Gateway in the two-way SMS architecture?

API Gateway creates an HTTP endpoint that serves as the entry point for sending outbound SMS messages (via the /send route) and receiving inbound messages via SNS (via the /webhook/sns route). This enables communication between external systems and your Lambda function.

How to configure SNS for two-way SMS with Pinpoint?

Create an SNS topic and link it as the incoming message destination in your Pinpoint phone number configuration. This ensures all messages sent to your Pinpoint number are published to this SNS topic, which will then forward them to your Fastify application.

What is the recommended Node.js version for two-way SMS project?

Node.js version 18 or later is recommended for this project. This ensures compatibility with the latest features and dependencies used in the tutorial.

How does the two-way SMS system handle auto-replies?

The Fastify application, triggered by an inbound SMS message via SNS, contains logic to process the message and generate an automatic reply. This is demonstrated with a simple example in the provided code.

Why is SNS message signature validation important?

SNS message signature validation is crucial for security. It prevents Server-Side Request Forgery (SSRF) attacks by verifying that incoming messages genuinely originate from SNS. Libraries like sns-validator are recommended for this purpose.

How to deploy the two-way SMS application to AWS Lambda?

Package the application code and dependencies into a zip file. Create a Lambda function, configure its execution role with necessary permissions, upload the zip file, set the handler to src/lambda.handler, and configure the required environment variables.