code examples

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

Implementing AWS SNS Delivery Status Callbacks in Next.js

A guide on building a Next.js application to send SMS via AWS SNS and track delivery status using CloudWatch Logs, Lambda, and DynamoDB.

This guide provides a step-by-step walkthrough for building a system within a Next.js application to send SMS messages via Amazon Simple Notification Service (SNS) and track their delivery status using AWS CloudWatch Logs and AWS Lambda.

Project Overview and Goals

We will build a Next.js application that enables users to send an SMS message to a specified phone number. After sending, the application will track the delivery status (e.g., SUCCESS, FAILURE) of that message.

  • Problem Solved: AWS SNS does not natively provide direct HTTP callbacks to your application with delivery status updates for SMS messages. This guide implements a robust mechanism to capture this status information indirectly.
  • Core Functionality:
    1. A Next.js frontend to input a phone number and message.
    2. A Next.js API route to trigger the SNS message sending.
    3. An SNS Topic configured to send SMS and log delivery status to CloudWatch.
    4. A DynamoDB table to store message details and track status.
    5. An AWS Lambda function triggered by CloudWatch Logs containing SNS status updates.
    6. The Lambda function parses the log data, looks up the message using a Global Secondary Index (GSI), and updates the corresponding message status in DynamoDB.
  • Technologies Used:
    • Next.js: React framework for frontend and API routes.
    • AWS SNS: Managed messaging service for sending SMS.
    • AWS CloudWatch Logs: Service for monitoring and logging AWS resources; used here to capture SNS delivery status.
    • AWS Lambda: Serverless compute service to process CloudWatch Logs.
    • AWS DynamoDB: NoSQL database for storing message status.
    • AWS IAM: Identity and Access Management for permissions.
    • AWS SDK for JavaScript v3: For interacting with AWS services from Next.js and Lambda.
  • Prerequisites:
    • Node.js and npm/yarn installed.
    • An AWS account with appropriate permissions to create SNS topics, Lambda functions, DynamoDB tables, CloudWatch Log Groups, and IAM roles.
    • AWS CLI configured locally (aws configure).
    • Basic understanding of Next.js, React, and AWS concepts.
  • Final Outcome: A functional Next.js application capable of sending SMS messages and reliably updating the delivery status associated with each message in a database.

System Architecture Diagram

mermaid
graph TD
    A[User Browser] -- 1. Submit Form --> B(Next.js Frontend);
    B -- 2. POST /api/send-sms --> C(Next.js API Route);
    C -- 3. Store Initial Record (internalMessageId, PENDING) --> D[(DynamoDB)];
    C -- 4. Publish SMS (snsMessageId returned) --> E(AWS SNS Topic);
    E -- 5. Send SMS --> F([Recipient Phone]);
    E -- 6. Log Delivery Status --> G(CloudWatch Logs);
    G -- 7. Trigger on Log Event --> H(AWS Lambda Function);
    H -- 8. Parse Log & Query GSI (using snsMessageId) --> D;
    H -- 9. Update Status (using internalMessageId) --> D;

1. Setting up the Project

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

  1. Create Next.js App: Open your terminal and run:

    bash
    npx create-next-app@latest sns-status-tracker --typescript
    cd sns-status-tracker

    Choose the defaults when prompted.

  2. Install AWS SDK v3: We need the AWS SDK clients for SNS and DynamoDB.

    bash
    npm install @aws-sdk/client-sns @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
    • @aws-sdk/client-sns: For sending messages via SNS.
    • @aws-sdk/client-dynamodb: Base client for DynamoDB.
    • @aws-sdk/lib-dynamodb: Utility library simplifying DynamoDB interactions.
  3. Environment Variables: Create a file named .env.local in the project root. This file stores sensitive credentials and configuration. Never commit this file to version control. Replace the placeholder values with your actual configuration.

    plaintext
    # .env.local
    
    # AWS Credentials (Use IAM Roles for production deployments)
    # For local development, provide your IAM User keys here.
    # For production (Vercel, AWS Amplify, EC2, ECS), leave these blank
    # and configure IAM Roles or platform-specific secret management.
    AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID
    AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY
    AWS_REGION=us-east-1 # Replace with your preferred AWS region
    
    # AWS Service Configuration (Replace with your resource names/ARNs after creation)
    SNS_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:YourSnsTopicName # Usually not needed for direct SMS, but good practice if using topics
    DYNAMODB_TABLE_NAME=SnsMessageStatus # The name of your DynamoDB table
    LAMBDA_FUNCTION_NAME=SnsStatusUpdater # The name of your Lambda function
    
    # Optional: For Lambda function ARN if needed elsewhere
    # LAMBDA_FUNCTION_ARN=arn:aws:lambda:us-east-1:123456789012:function:SnsStatusUpdater
    • Obtaining Credentials: For local development, you can create an IAM user with programmatic access. Go to AWS Console -> IAM -> Users -> Create user. Attach appropriate policies (e.g., AmazonSNSFullAccess, AmazonDynamoDBFullAccess, AWSLambda_FullAccess, CloudWatchLogsFullAccess - restrict these tightly in production). Download the access key and secret key. For production, always prefer IAM roles assigned to your compute environment (e.g., Vercel environment variables, EC2 instance profile, ECS task role).
    • Region: Choose the AWS region where you'll create your resources.
    • SNS_TOPIC_ARN, DYNAMODB_TABLE_NAME, LAMBDA_FUNCTION_NAME: We will create these resources in later steps and update these values. The SNS_TOPIC_ARN is often not directly used when sending SMS via PhoneNumber, but might be relevant for other configurations.
  4. Project Structure: Your src/ directory (if using /src) will eventually contain:

    • pages/: Frontend pages and API routes.
    • components/: Reusable React components.
    • lib/: Utility functions (like AWS client setup).
    • lambda/: Directory for Lambda function code (created later).
  5. AWS Client Configuration (Optional Utility): Create src/lib/awsClients.ts to centralize SDK client initialization.

    typescript
    // src/lib/awsClients.ts
    import { SNSClient } from ""@aws-sdk/client-sns"";
    import { DynamoDBClient } from ""@aws-sdk/client-dynamodb"";
    import { DynamoDBDocumentClient } from ""@aws-sdk/lib-dynamodb"";
    
    const region = process.env.AWS_REGION!;
    const credentials = {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    };
    
    // Basic validation or configuration selection
    let clientConfig: { region: string; credentials?: typeof credentials } = { region };
    
    if (process.env.NODE_ENV === 'development' && credentials.accessKeyId && credentials.secretAccessKey) {
      // Use explicit credentials only in development if provided
      clientConfig.credentials = credentials;
      console.log(""Using explicit AWS credentials from .env.local for development."");
    } else if (process.env.NODE_ENV === 'development') {
       console.warn(""AWS credentials not found in .env.local for development. SDK will fallback to shared config/credentials or environment variables."");
    }
    // In production or if keys are missing in dev, rely on SDK's default credential chain (IAM roles, env vars, etc.)
    // No explicit 'credentials' object is passed in this case.
    
    if (!region) {
        throw new Error(""AWS_REGION environment variable is not set."");
    }
    
    export const snsClient = new SNSClient(clientConfig);
    
    const ddbClient = new DynamoDBClient(clientConfig);
    
    // Helper for simplified DynamoDB operations
    const marshallOptions = {
        convertEmptyValues: false, // Default false
        removeUndefinedValues: true, // Recommended practice
        convertClassInstanceToMap: false, // Default false
    };
    const unmarshallOptions = {
        wrapNumbers: false, // Default false
    };
    const translateConfig = { marshallOptions, unmarshallOptions };
    
    export const ddbDocClient = DynamoDBDocumentClient.from(ddbClient, translateConfig);
    • Why this utility? Centralizes client creation, making it easier to manage configuration and apply consistent settings. Reads credentials and region from environment variables. Uses DynamoDBDocumentClient for easier interaction with DynamoDB using plain JavaScript objects.
    • Production Credentials: The code prioritizes IAM roles or standard environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN) in production by not passing the credentials object unless specifically configured for development in .env.local. The SDK automatically detects IAM roles in environments like Lambda, EC2, ECS, or Vercel/Amplify if configured correctly.

2. Implementing Core Functionality (Sending SMS)

Let's create the frontend form and the API route to send the SMS.

  1. Frontend Component: Create src/components/SmsForm.tsx:

    typescript
    // src/components/SmsForm.tsx
    import React, { useState } from 'react';
    
    export default function SmsForm() {
      const [phoneNumber, setPhoneNumber] = useState('');
      const [message, setMessage] = useState('');
      const [status, setStatus] = useState(''); // To display feedback
      const [loading, setLoading] = useState(false);
    
      const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        setLoading(true);
        setStatus('Sending...');
    
        try {
          const response = await fetch('/api/send-sms', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({ phoneNumber, message }),
          });
    
          const data = await response.json();
    
          if (!response.ok) {
            throw new Error(data.error || `HTTP error! status: ${response.status}`);
          }
    
          setStatus(`Message submitted! Internal ID: ${data.internalMessageId}. Status is PENDING. Check logs/DB for updates.`);
          // Optionally clear form:
          // setPhoneNumber('');
          // setMessage('');
        } catch (error: any) {
          console.error(""Failed to send SMS:"", error);
          setStatus(`Error: ${error.message}`);
        } finally {
          setLoading(false);
        }
      };
    
      return (
        <form onSubmit={handleSubmit}>
          <h2>Send SMS</h2>
          <div>
            <label htmlFor=""phoneNumber"">Phone Number (E.164 format, e.g., +14155552671):</label>
            <input
              type=""tel""
              id=""phoneNumber""
              value={phoneNumber}
              onChange={(e) => setPhoneNumber(e.target.value)}
              required
              pattern=""\+[1-9]\d{1,14}"" // Basic E.164 pattern
            />
          </div>
          <div>
            <label htmlFor=""message"">Message:</label>
            <textarea
              id=""message""
              value={message}
              onChange={(e) => setMessage(e.target.value)}
              required
              maxLength={160} // SMS length limit reminder
            />
          </div>
          <button type=""submit"" disabled={loading}>
            {loading ? 'Sending...' : 'Send Message'}
          </button>
          {status && <p>Status: {status}</p>}
        </form>
      );
    }
  2. Add Component to Page: Modify src/pages/index.tsx:

    typescript
    // src/pages/index.tsx
    import Head from 'next/head';
    import SmsForm from '@/components/SmsForm'; // Adjust path if needed
    
    export default function Home() {
      return (
        <div>
          <Head>
            <title>SNS SMS Status Tracker</title>
            <meta name=""description"" content=""Send SMS via SNS and track status"" />
            <link rel=""icon"" href=""/favicon.ico"" />
          </Head>
    
          <main>
            <h1>SNS SMS Status Tracker</h1>
            <SmsForm />
          </main>
        </div>
      );
    }

3. Building the API Layer (Send SMS)

Create the Next.js API route that handles sending the message via SNS and creating the initial status record in DynamoDB.

  1. Create API Route: Create src/pages/api/send-sms.ts:
    typescript
    // src/pages/api/send-sms.ts
    import type { NextApiRequest, NextApiResponse } from 'next';
    import { PublishCommand, PublishCommandInput } from '@aws-sdk/client-sns';
    import { PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; // Import UpdateCommand
    import { snsClient, ddbDocClient } from '@/lib/awsClients'; // Adjust path if needed
    import { randomUUID } from 'crypto'; // Use crypto for unique IDs
    
    type RequestBody = {
      phoneNumber: string;
      message: string;
    };
    
    type ResponseData = {
      message?: string;
      error?: string;
      internalMessageId?: string; // Our internal tracking ID
      snsMessageId?: string; // ID returned by SNS
    };
    
    // Basic E.164 format validation (improve as needed)
    function isValidE164(phoneNumber: string): boolean {
        return /^\+[1-9]\d{1,14}$/.test(phoneNumber);
    }
    
    export default async function handler(
      req: NextApiRequest,
      res: NextApiResponse<ResponseData>
    ) {
      if (req.method !== 'POST') {
        res.setHeader('Allow', ['POST']);
        return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
      }
    
      const { phoneNumber, message }: RequestBody = req.body;
    
      // --- Input Validation ---
      if (!phoneNumber || !message) {
        return res.status(400).json({ error: 'phoneNumber and message are required.' });
      }
      if (!isValidE164(phoneNumber)) {
        return res.status(400).json({ error: 'Invalid phone number format. Use E.164 (e.g., +14155552671).' });
      }
      if (message.length > 160) { // Basic length check
          return res.status(400).json({ error: 'Message exceeds 160 characters.' });
      }
    
      const tableName = process.env.DYNAMODB_TABLE_NAME;
    
      if (!tableName) {
        console.error(""DYNAMODB_TABLE_NAME not set in environment variables."");
        return res.status(500).json({ error: 'Server configuration error.' });
      }
    
      const internalMessageId = randomUUID(); // Generate a unique ID for tracking
      const now = new Date().toISOString();
    
      try {
        // --- 1. Create initial record in DynamoDB ---
        const initialRecord = {
          TableName: tableName,
          Item: {
            internalMessageId: internalMessageId, // Partition Key
            phoneNumber: phoneNumber,
            messageBody: message, // Store the message content if needed
            status: 'PENDING', // Initial status
            snsMessageId: null, // Will be updated after successful publish
            providerResponse: null, // Store SNS delivery status response later
            createdAt: now,
            updatedAt: now,
          },
          // Optionally add ConditionExpression: ""attribute_not_exists(internalMessageId)""
          // to prevent accidental overwrites if UUID collision occurred (highly unlikely)
        };
        await ddbDocClient.send(new PutCommand(initialRecord));
        console.log(`Initial record created in DynamoDB: ${internalMessageId}`);
    
        // --- 2. Publish message to SNS ---
        // Note: For SMS, MessageAttributes can configure SenderID, SMSType etc.
        // See: https://docs.aws.amazon.com/sns/latest/dg/sms_publish-to-phone.html#sms_publish_worldwide
        const params: PublishCommandInput = {
          PhoneNumber: phoneNumber, // Directly publish to a phone number for SMS
          Message: message,
          // Example MessageAttributes for SMS (Optional)
          // MessageAttributes: {
          //   'AWS.SNS.SMS.SenderID': { DataType: 'String', StringValue: 'MySenderID' },
          //   'AWS.SNS.SMS.SMSType': { DataType: 'String', StringValue: 'Transactional' } // or 'Promotional'
          // }
        };
    
        console.log(`Publishing SMS via SNS to: ${phoneNumber}`);
        const command = new PublishCommand(params);
        const snsResponse = await snsClient.send(command);
        console.log(`SNS Publish Response:`, snsResponse);
    
        if (!snsResponse.MessageId) {
            // This is unusual but handle it
            console.error(""SNS did not return a MessageId."");
            // Attempt to update the status to FAILED_TO_SEND
            try {
                 await ddbDocClient.send(new UpdateCommand({
                    TableName: tableName,
                    Key: { internalMessageId: internalMessageId },
                    UpdateExpression: ""set #st = :s, providerResponse = :pr, updatedAt = :ua"",
                    ExpressionAttributeNames: { ""#st"": ""status"" },
                    ExpressionAttributeValues: {
                        "":s"": ""FAILED_TO_SEND"",
                        "":pr"": ""SNS did not return a MessageId."",
                        "":ua"": new Date().toISOString()
                    }
                 }));
            } catch (dbUpdateError) {
                 console.error(""Failed to update status to FAILED_TO_SEND after missing SNS MessageId:"", dbUpdateError);
            }
            return res.status(500).json({ error: 'Failed to send message via SNS (no MessageId returned).', internalMessageId });
        }
    
        // --- 3. Update DynamoDB record with SNS Message ID using UpdateCommand ---
        const updateParams = {
            TableName: tableName,
            Key: { internalMessageId: internalMessageId },
            UpdateExpression: ""set snsMessageId = :sid, updatedAt = :ua"",
            ExpressionAttributeValues: {
                "":sid"": snsResponse.MessageId,
                "":ua"": new Date().toISOString()
            },
            ReturnValues: ""UPDATED_NEW"", // Optional: Get updated attributes back
        };
        await ddbDocClient.send(new UpdateCommand(updateParams)); // Use UpdateCommand
    
        console.log(`DynamoDB record updated with SNS Message ID: ${snsResponse.MessageId}`);
    
        // --- Success Response ---
        return res.status(200).json({
          message: 'Message submitted successfully.',
          internalMessageId: internalMessageId,
          snsMessageId: snsResponse.MessageId,
        });
    
      } catch (error: any) {
        console.error(""Error in /api/send-sms:"", error);
    
         // Attempt to update status to FAILED_TO_SEND if an error occurred
         try {
             await ddbDocClient.send(new UpdateCommand({
                TableName: tableName,
                Key: { internalMessageId: internalMessageId }, // Use the ID generated earlier
                UpdateExpression: ""set #st = :s, providerResponse = :pr, updatedAt = :ua"",
                ExpressionAttributeNames: { ""#st"": ""status"" },
                ExpressionAttributeValues: {
                    "":s"": ""FAILED_TO_SEND"",
                    "":pr"": `Error during send/update: ${error.message}`,
                    "":ua"": new Date().toISOString()
                },
                // ConditionExpression: ""attribute_exists(internalMessageId)"" // Optional: only update if record exists
             }));
             console.log(`Updated DynamoDB status to FAILED_TO_SEND for ${internalMessageId}`);
         } catch (dbError) {
             console.error(`Failed to update DynamoDB status to FAILED_TO_SEND for ${internalMessageId}:`, dbError);
             // Log this failure, but usually proceed to return the original error to the client
         }
    
        return res.status(500).json({ error: `Failed to process message: ${error.message}`, internalMessageId });
      }
    }
    • Validation: Includes basic checks for required fields and phone number format.
    • Unique ID: Generates a randomUUID (internalMessageId) to track the message within our system before getting the snsMessageId.
    • DynamoDB Interaction:
      • Creates an initial record with status: 'PENDING' using PutCommand.
      • Correctly updates the record with the snsMessageId using UpdateCommand upon successful SNS publication.
    • SNS Publish: Uses PublishCommand to send the SMS directly to the phone number.
    • Error Handling: Includes try...catch blocks and attempts to update the DynamoDB status to FAILED_TO_SEND using UpdateCommand if errors occur during SNS publishing or database updates.

4. Integrating with AWS Services (SNS, DynamoDB, IAM)

Now, let's create the necessary AWS resources. We'll primarily use the AWS Management Console for clarity, but provide equivalent AWS CLI commands where feasible. Remember to replace placeholders like YOUR_REGION and YOUR_ACCOUNT_ID with your actual values.

  1. Create DynamoDB Table and GSI:

    • Console:
      1. Navigate to DynamoDB in the AWS Console.
      2. Click ""Create table"".
      3. Table name: SnsMessageStatus (or your chosen name from .env.local).
      4. Partition key: internalMessageId, Type: String.
      5. Leave other settings as default (e.g., Provisioned capacity mode, or choose On-Demand).
      6. Click ""Create table"".
      7. Once the table is Active, select it.
      8. Go to the ""Indexes"" tab.
      9. Click ""Create index"".
      10. Partition key: snsMessageId, Type: String.
      11. Index name: SnsMessageIdIndex (this name will be used in the Lambda code).
      12. Projected attributes: Select ""Include"" and add internalMessageId to the projected attributes. This makes the lookup efficient.
      13. Leave capacity settings as default or adjust as needed (match table mode if possible).
      14. Click ""Create index"". The index will take some time to build.
    • CLI:
      bash
      # Create Table (On-Demand is often recommended)
      aws dynamodb create-table \
          --table-name SnsMessageStatus \
          --attribute-definitions AttributeName=internalMessageId,AttributeType=S \
          --key-schema AttributeName=internalMessageId,KeyType=HASH \
          --billing-mode PAY_PER_REQUEST \
          --region YOUR_REGION
      
      # Add GSI (Wait for table to be ACTIVE before running this)
      aws dynamodb update-table \
          --table-name SnsMessageStatus \
          --attribute-definitions AttributeName=snsMessageId,AttributeType=S \
          --global-secondary-index-updates \
              ""[{\""Create\"":{\""IndexName\"": \""SnsMessageIdIndex\"",\""KeySchema\"":[{\""AttributeName\"":\""snsMessageId\"",\""KeyType\"":\""HASH\""}], \
              \""Projection\"":{\""ProjectionType\"":\""INCLUDE\"", \""NonKeyAttributes\"":[\""internalMessageId\""]}}}]"" \
          --region YOUR_REGION
      # Note: If using Provisioned capacity for the table, you must specify it for the GSI too:
      # Add '\""ProvisionedThroughput\"": {\""ReadCapacityUnits\"": 1, \""WriteCapacityUnits\"": 1}' inside the Create object for the GSI.
    • Update .env.local: Ensure DYNAMODB_TABLE_NAME matches the exact name you used (SnsMessageStatus).
  2. Configure SNS SMS Delivery Status Logging: This tells SNS to log delivery attempts to CloudWatch Logs.

    • Console:

      1. Navigate to SNS in the AWS Console.
      2. In the left navigation pane, click ""Text messaging (SMS)"".
      3. Scroll down to ""Delivery status logging"". Click ""Edit"".
      4. Success sample rate (%): Enter 100 (to log all successes for debugging; reduce in production if cost is a concern).
      5. IAM role for successes: Click ""Create new service role"" -> ""Create role"". This opens IAM. Accept defaults and create the role (e.g., SNSSuccessFeedback). AWS automatically creates a role (SNSSuccessFeedback) with a policy allowing logs:CreateLogStream and logs:PutLogEvents to CloudWatch Logs.
      6. Select the newly created role (you might need to refresh).
      7. IAM role for failures: Click ""Create new service role"" -> ""Create role"". Create another role (e.g., SNSFailureFeedback) similarly.
      8. Select the newly created failure role.
      9. Click ""Save changes"".
    • What this does: SNS will now attempt to write log entries for SMS delivery attempts to a CloudWatch Log Group, typically named sns/YOUR_REGION/YOUR_ACCOUNT_ID/DirectPublishToPhoneNumber. Verify this exact name after sending your first message, as you'll need it for the Lambda trigger.

  3. Create IAM Role for Lambda: Our Lambda function needs permission to be invoked by CloudWatch Logs, read those logs, query the DynamoDB GSI, and update the DynamoDB table.

    • Console:
      1. Navigate to IAM in the AWS Console.
      2. Go to ""Roles"" -> ""Create role"".
      3. Trusted entity type: Select ""AWS service"".
      4. Use case: Select ""Lambda"". Click ""Next"".
      5. Add permissions: Search for and select the managed policy AWSLambdaBasicExecutionRole (for basic Lambda logging).
      6. Click ""Next"".
      7. Role name: SnsStatusUpdaterRole (or your choice).
      8. Click ""Create role"".
      9. Add Inline Policy for Specific Permissions: Find the created role, go to the ""Permissions"" tab, click ""Add permissions"" -> ""Create inline policy"".
      10. Select the ""JSON"" tab and paste the following policy, replacing placeholders:
        json
        {
            ""Version"": ""2012-10-17"",
            ""Statement"": [
                {
                    ""Sid"": ""DynamoDBPermissions"",
                    ""Effect"": ""Allow"",
                    ""Action"": [
                        ""dynamodb:Query"", // To query the GSI
                        ""dynamodb:UpdateItem"" // To update the status
                    ],
                    ""Resource"": [
                        ""arn:aws:dynamodb:YOUR_REGION:YOUR_ACCOUNT_ID:table/SnsMessageStatus"", // Allow UpdateItem on table
                        ""arn:aws:dynamodb:YOUR_REGION:YOUR_ACCOUNT_ID:table/SnsMessageStatus/index/SnsMessageIdIndex"" // Allow Query on GSI
                    ]
                },
                {
                    ""Sid"": ""CloudWatchLogsReadAccessSNS"",
                    ""Effect"": ""Allow"",
                    ""Action"": [
                        ""logs:GetLogEvents"",
                        ""logs:FilterLogEvents"" // Needed for CloudWatch Logs trigger processing
                    ],
                    ""Resource"": ""arn:aws:logs:YOUR_REGION:YOUR_ACCOUNT_ID:log-group:sns/YOUR_REGION/YOUR_ACCOUNT_ID/DirectPublishToPhoneNumber:*"" // Verify exact log group name pattern
                }
            ]
        }
      11. Click ""Review policy"".
      12. Name: SnsStatusUpdaterServicePermissions.
      13. Click ""Create policy"".

5. Implementing the Lambda Function (with GSI Lookup)

This function is triggered by CloudWatch Logs events from SNS delivery status logging. It parses the event, uses the snsMessageId to query the GSI for the internalMessageId, and updates the correct DynamoDB record.

  1. Create Lambda Function Directory: In your project root, create lambda/sns-status-updater/.

  2. Lambda Handler Code (with GSI Lookup): Create lambda/sns-status-updater/index.mjs (using .mjs for native ES Modules support in Node.js Lambda runtime):

    javascript
    // lambda/sns-status-updater/index.mjs
    import { DynamoDBClient } from ""@aws-sdk/client-dynamodb"";
    import { DynamoDBDocumentClient, UpdateCommand, QueryCommand } from ""@aws-sdk/lib-dynamodb"";
    import { gunzipSync } from 'zlib';
    
    const region = process.env.AWS_REGION;
    const tableName = process.env.DYNAMODB_TABLE_NAME;
    const gsiName = ""SnsMessageIdIndex""; // Name of the GSI created earlier
    
    if (!region || !tableName) {
        console.error(""Missing required environment variables: AWS_REGION, DYNAMODB_TABLE_NAME"");
        // Throwing error might cause retries; consider logging and exiting gracefully depending on retry strategy
        throw new Error(""Missing required environment variables."");
    }
    
    const ddbClient = new DynamoDBClient({ region });
    const ddbDocClient = DynamoDBDocumentClient.from(ddbClient);
    
    export const handler = async (event) => {
        console.log(""Received CloudWatch Logs event:"", JSON.stringify(event, null, 2));
    
        // CloudWatch Logs data is base64 encoded and gzipped
        const payload = Buffer.from(event.awslogs.data, 'base64');
        let logData;
        try {
            const decompressed = gunzipSync(payload);
            logData = JSON.parse(decompressed.toString('utf8'));
        } catch (err) {
            console.error(""Error decompressing or parsing CloudWatch log data:"", err);
            return ""Error processing logs: decompression/parsing failed.""; // Fail gracefully
        }
    
        console.log(""Decompressed Log Data:"", JSON.stringify(logData, null, 2));
    
        if (logData.messageType !== ""LOG_GROUP"") {
             console.log(""Not a LOG_GROUP message, skipping."");
             return `Skipped: Not a LOG_GROUP message`;
        }
    
        let updatesProcessed = 0;
        let errorsEncountered = 0;
    
        // Process each log event within the payload
        for (const logEvent of logData.logEvents) {
            try {
                const message = JSON.parse(logEvent.message);
                console.log(""Parsed Log Event Message:"", JSON.stringify(message, null, 2));
    
                // --- Extract relevant fields ---
                // Adjust these fields based on the actual structure logged by SNS. Inspect your logs!
                const snsMessageId = message.notification?.messageId; // The ID returned by SNS when published
                const deliveryStatus = message.status; // e.g., SUCCESS, FAILURE
                const providerResponse = message.providerResponse || ""N/A""; // Reason for failure, etc.
                // Use log event timestamp as a fallback for updatedAt
                const timestamp = message.notification?.timestamp || new Date(logEvent.timestamp).toISOString();
    
                if (!snsMessageId || !deliveryStatus) {
                    console.warn(""Skipping log event - missing snsMessageId or status:"", logEvent.message);
                    continue; // Skip if essential info is missing
                }
    
                // --- Find internalMessageId using snsMessageId via GSI ---
                // This is the crucial step: SNS logs don't contain our internal ID.
                // We query the index we created (SnsMessageIdIndex) which maps snsMessageId back to internalMessageId.

Frequently Asked Questions

How to track AWS SNS SMS delivery status in Next.js?

Implement a system using CloudWatch Logs and Lambda. SNS logs delivery attempts to CloudWatch, which triggers a Lambda function. This function parses the logs, queries a DynamoDB table using a Global Secondary Index (GSI) to find the message's internal ID, and updates its status.

What is the purpose of a Global Secondary Index (GSI) in this architecture?

The GSI allows efficient lookup of the internal message ID using the SNS message ID. Since SNS logs don't contain our internal ID, the GSI bridges this gap by indexing `snsMessageId` and projecting `internalMessageId`, enabling quick retrieval of the corresponding record in DynamoDB.

Why does AWS SNS not provide direct delivery status callbacks for SMS?

AWS SNS doesn't natively offer HTTP callbacks for SMS delivery status. This guide's architecture provides a workaround using CloudWatch Logs and Lambda to indirectly capture and process status updates.

When should I use IAM roles instead of access keys?

Always prefer IAM roles for production deployments. For local development, you can use access keys stored in `.env.local`. However, in production environments like Vercel, EC2, or ECS, IAM roles provide a more secure and manageable way to grant permissions to your application without exposing credentials.

How to send SMS messages with Next.js and AWS SNS?

Create a Next.js API route that uses the AWS SDK for JavaScript v3 to publish messages to an SNS topic. This API route should handle input validation, generate a unique internal message ID, store the initial message status in DynamoDB, and publish the SMS message via SNS.

What AWS services are used for the SNS delivery status tracking system?

The system uses Next.js for the frontend and API routes, AWS SNS for sending SMS, AWS CloudWatch Logs for capturing delivery status, AWS Lambda for processing logs, AWS DynamoDB for storing message status, AWS IAM for permissions, and the AWS SDK for JavaScript v3 for interacting with AWS services.

Can I customize the sender ID for SMS messages sent via SNS?

Yes, you can customize the sender ID by using `MessageAttributes` in the `PublishCommandInput` when sending the SMS message. Set `'AWS.SNS.SMS.SenderID'` to your desired alphanumeric sender ID (up to 11 characters).

What is the role of AWS Lambda in this SNS to DynamoDB flow?

Lambda acts as the processing engine triggered by CloudWatch Logs. It parses SNS delivery status logs, queries the DynamoDB GSI using the SNS message ID, retrieves the internal message ID, and updates the message status in DynamoDB.

How to set up the DynamoDB table for storing SMS message status?

Create a DynamoDB table with `internalMessageId` as the primary key. Add a Global Secondary Index (GSI) with `snsMessageId` as the partition key and project the `internalMessageId` attribute. This allows efficient lookup of the internal ID based on the SNS message ID logged by CloudWatch.

How do I create a new Next.js project for this tutorial?

Use the command `npx create-next-app@latest sns-status-tracker --typescript` in your terminal. This creates a new Next.js project with TypeScript. Then, navigate into the project directory using `cd sns-status-tracker`.

How do I install the required AWS SDK packages?

Run `npm install @aws-sdk/client-sns @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb` to install the necessary AWS SDK clients for interacting with SNS and DynamoDB.

Where do I configure AWS credentials for my Next.js application?

For local development, create a `.env.local` file in your project root. Store AWS credentials there. For production, use IAM roles or platform-specific secret management systems, keeping credentials out of your codebase.

What are the prerequisites for implementing this AWS SNS delivery status tracking system?

You'll need Node.js and npm/yarn, an AWS account, AWS CLI configured, and a basic understanding of Next.js, React, and AWS concepts. Also, ensure you have appropriate AWS permissions to create the required resources.

What data is logged by SNS to CloudWatch for delivery status?

SNS logs a JSON object containing details like message ID (`messageId`), delivery status (`status`), provider response (`providerResponse`), and timestamp. The Lambda function parses this data to update the message status in DynamoDB.