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.
Code examples15 min read
Frequently asked questions
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
One API. Every channel.
Contact Sales
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.aws configure).System Architecture Diagram
1. Setting up the Project
Let's initialize the Next.js project and install necessary dependencies.
Create Next.js App: Open your terminal and run:
npx create-next-app@latest sns-status-tracker --typescript cd sns-status-trackerChoose the defaults when prompted.
Install AWS SDK v3: We need the AWS SDK clients for SNS and 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.Environment Variables: Create a file named
.env.localin 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.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).SNS_TOPIC_ARN,DYNAMODB_TABLE_NAME,LAMBDA_FUNCTION_NAME: We will create these resources in later steps and update these values. TheSNS_TOPIC_ARNis often not directly used when sending SMS viaPhoneNumber, but might be relevant for other configurations.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).AWS Client Configuration (Optional Utility): Create
src/lib/awsClients.tsto centralize SDK client initialization.// 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);DynamoDBDocumentClientfor easier interaction with DynamoDB using plain JavaScript objects.AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_SESSION_TOKEN) in production by not passing thecredentialsobject 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.
Frontend Component: Create
src/components/SmsForm.tsx:// 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> ); }Add Component to Page: Modify
src/pages/index.tsx:// 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.
src/pages/api/send-sms.ts:// 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 }); } }randomUUID(internalMessageId) to track the message within our system before getting thesnsMessageId.status: 'PENDING'usingPutCommand.snsMessageIdusingUpdateCommandupon successful SNS publication.PublishCommandto send the SMS directly to the phone number.try...catchblocks and attempts to update the DynamoDB status toFAILED_TO_SENDusingUpdateCommandif 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_REGIONandYOUR_ACCOUNT_IDwith your actual values.Create DynamoDB Table and GSI:
SnsMessageStatus(or your chosen name from.env.local).internalMessageId, Type:String.Active, select it.snsMessageId, Type:String.SnsMessageIdIndex(this name will be used in the Lambda code).internalMessageIdto the projected attributes. This makes the lookup efficient.# 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..env.local: EnsureDYNAMODB_TABLE_NAMEmatches the exact name you used (SnsMessageStatus).Configure SNS SMS Delivery Status Logging: This tells SNS to log delivery attempts to CloudWatch Logs.
Console:
100(to log all successes for debugging; reduce in production if cost is a concern).SNSSuccessFeedback). AWS automatically creates a role (SNSSuccessFeedback) with a policy allowinglogs:CreateLogStreamandlogs:PutLogEventsto CloudWatch Logs.SNSFailureFeedback) similarly.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.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.
AWSLambdaBasicExecutionRole(for basic Lambda logging).SnsStatusUpdaterRole(or your choice).{ ""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 } ] }SnsStatusUpdaterServicePermissions.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
snsMessageIdto query the GSI for theinternalMessageId, and updates the correct DynamoDB record.Create Lambda Function Directory: In your project root, create
lambda/sns-status-updater/.Lambda Handler Code (with GSI Lookup): Create
lambda/sns-status-updater/index.mjs(using.mjsfor native ES Modules support in Node.js Lambda runtime):// 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.