code examples
code examples
Developer Guide: Implementing RedwoodJS AWS SNS Delivery Status Callbacks Using Node.js
A guide on integrating AWS SNS delivery status notifications into a RedwoodJS application using Node.js for real-time SMS updates.
Get real-time updates on your SMS messages by integrating AWS Simple Notification Service (SNS) delivery status notifications directly into your RedwoodJS application.
This guide provides a step-by-step walkthrough for setting up a robust system to send SMS messages via AWS SNS and receive delivery status callbacks (e.g., ""delivered,"" ""failed"") at a dedicated endpoint within your RedwoodJS application. This enables you to track message success rates, trigger follow-up actions, and provide better feedback to your users.
We will build a system where:
- Your RedwoodJS application sends an SMS using the AWS SDK.
- AWS SNS attempts to deliver the message.
- SNS sends a status update (success or failure) via HTTPS to a specified endpoint in your RedwoodJS API.
- Your RedwoodJS API endpoint verifies the incoming notification, parses the status, and updates your database accordingly.
Project Overview and Goals
Goal: To create a reliable mechanism within a RedwoodJS application to send SMS messages through AWS SNS and receive real-time delivery status updates via HTTPS callbacks.
Problem Solved: Standard SNS publish calls for SMS only confirm that the request was accepted by AWS, not whether the message reached the end user's device. This solution provides crucial visibility into the actual delivery outcome.
Technologies Used:
- RedwoodJS: Full-stack JavaScript framework for building web applications. We'll use its API side for the callback endpoint and services.
- Node.js: The runtime environment for RedwoodJS's API side.
- AWS SNS (Simple Notification Service): Managed messaging service for sending SMS, push notifications, and more.
- AWS IAM (Identity and Access Management): To securely grant permissions for SNS actions.
- Prisma: Database toolkit used by RedwoodJS for schema management and database access.
- (Optional) PostgreSQL/SQLite: Database to store message status.
Architecture:
graph LR
A[RedwoodJS App UI/Service] -- 1. Send SMS Request --> B(RedwoodJS SMS Service);
B -- 2. Publish SMS via SDK --> C(AWS SNS);
C -- 3. Delivers SMS --> D(End User Device);
C -- 4. Sends Status Update (HTTPS POST) --> E(RedwoodJS Callback Function /snsCallback);
E -- 5. Verify SNS Signature --> E;
E -- 6. Parse Status & Update DB --> F(Database);
subgraph AWS Cloud
C
end
subgraph RedwoodJS API
B
E
F
end
subgraph User Interaction
A
D
endPrerequisites:
- Node.js (>=18.x recommended) and Yarn (>=1.22.x) installed.
- RedwoodJS CLI installed (
yarn global add redwoodjs/cli). - An AWS account with permissions to manage SNS and IAM.
- AWS Access Key ID and Secret Access Key configured locally (e.g., via environment variables, AWS credentials file, or IAM role).
- A publicly accessible URL for your RedwoodJS API (for receiving callbacks). Use
ngrokduring development or deploy to a platform like Render, Vercel, or Fly.io.
1. Setting up the AWS Environment
Before writing code, we need to configure AWS SNS to send delivery status notifications.
Step 1: Create an IAM Policy for SNS Delivery Status
SNS needs permission to publish logs or notifications related to delivery status. While CloudWatch Logs are an option, we'll configure direct HTTPS notifications. However, setting up basic logging permissions is still good practice and sometimes required by AWS console setup wizards.
-
Navigate to the IAM service in your AWS Console.
-
Go to Policies and click Create policy.
-
Switch to the JSON tab and paste the following policy. This allows SNS to write logs to CloudWatch, which is often a prerequisite step in the console wizards even if we primarily use HTTPS callbacks later.
json{ ""Version"": ""2012-10-17"", ""Statement"": [ { ""Effect"": ""Allow"", ""Action"": [ ""logs:CreateLogGroup"", ""logs:CreateLogStream"", ""logs:PutLogEvents"", ""logs:PutMetricFilter"", ""logs:PutRetentionPolicy"" ], ""Resource"": [ ""*"" ] } ] }<Callout type=""warn"" title=""Security Warning""> The
""Resource"": [""*""]in this policy grants permissions to all CloudWatch Logs resources. For production environments, it is strongly recommended to scope this down to only the specific log group(s) that SNS needs access to, following the principle of least privilege. You might need to create the log group first to get its ARN. </Callout> -
Click Next: Tags, Next: Review.
-
Give the policy a name, e.g.,
SNSSMSDeliveryStatusPolicy. -
Click Create policy.
Step 2: Create an IAM Role for SNS
SNS will assume this role to gain the permissions defined in the policy.
- In IAM, go to Roles and click Create role.
- Select AWS service as the trusted entity type.
- Choose SNS from the ""Use cases for other AWS services"" dropdown.
- Click Next.
- Search for and select the
SNSSMSDeliveryStatusPolicyyou just created. - Click Next.
- Give the role a name, e.g.,
SNSSMSDeliveryStatusRole. - Click Create role. Note the ARN (Amazon Resource Name) of this role; you'll need it shortly.
Step 3: Configure SNS Text Messaging (SMS) Preferences
This is where you tell SNS how to report delivery statuses. The primary method for this guide is via an SNS Topic triggering an HTTPS endpoint, but configuring CloudWatch logging is also shown.
-
Navigate to the Simple Notification Service (SNS) in your AWS Console.
-
In the left navigation pane, click Text messaging (SMS).
-
Click the Edit button in the ""Text messaging preferences"" section.
-
Delivery status logging (Optional - for CloudWatch Logs):
- If you want delivery status sent to CloudWatch Logs in addition to HTTPS callbacks, check the box Enable delivery status logging.
- Enter the IAM role ARN for Success sample rate (%) from the role created in Step 2 (
SNSSMSDeliveryStatusRole). - Enter the IAM role ARN for Failure sample rate (%) (same role).
- Set the sample rates (e.g., 100% for both during development/testing, potentially lower in production to manage CloudWatch costs). Note: This section configures logging to CloudWatch. The crucial part for HTTPS callbacks follows.
-
Crucial: Create an SNS Topic for Delivery Status Updates
- This Topic will receive the delivery status events from SNS, which will then trigger our HTTPS endpoint.
- Go to Topics in the SNS console (you might need to open this in a new tab).
- Click Create topic.
- Choose Standard.
- Give it a name, e.g.,
sms-delivery-status-topic. - Leave other settings as default and click Create topic.
- Important: Copy the ARN of this newly created topic. You'll need it below and in your application's environment variables.
-
Configure Delivery status notifications (Recommended for HTTPS Callbacks):
- Go back to the Text messaging (SMS) preferences tab/page and ensure you are still in Edit mode.
- Scroll down to the Delivery status notifications section.
- For Amazon SNS topic for successes, paste or select the ARN of the topic you just created (e.g.,
arn:aws:sns:us-east-1:123456789012:sms-delivery-status-topic). - For Amazon SNS topic for failures, paste or select the same topic ARN. This ensures both success and failure events are sent to your callback handler via this topic.
-
Click Save changes at the bottom of the Text messaging preferences page.
Step 4: Obtain AWS Credentials
Ensure your AWS Access Key ID and Secret Access Key are available to your application. The recommended way is via environment variables:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_REGION(e.g.,us-east-1)
You can set these in your RedwoodJS project's .env file.
2. Setting up the RedwoodJS Project
Step 1: Create a New RedwoodJS App (if needed)
yarn create redwood-app ./redwood-sns-callbacks
cd redwood-sns-callbacksStep 2: Install Dependencies
We need the AWS SDK v3 for SNS and a validator for incoming SNS messages.
yarn workspace api add @aws-sdk/client-sns aws-sns-message-validatorStep 3: Configure Environment Variables
Add your AWS credentials and the SNS Topic ARN to your .env file (create it if it doesn't exist). Never commit .env files to version control.
# .env
AWS_ACCESS_KEY_ID="YOUR_ACCESS_KEY_ID"
AWS_SECRET_ACCESS_KEY="YOUR_SECRET_ACCESS_KEY"
AWS_REGION="us-east-1" # Or your preferred region
SNS_DELIVERY_STATUS_TOPIC_ARN="arn:aws:sns:us-east-1:123456789012:sms-delivery-status-topic" # Replace with your ACTUAL topic ARN- Important:
- You must replace
"YOUR_ACCESS_KEY_ID"and"YOUR_SECRET_ACCESS_KEY"with your actual AWS credentials. - You must replace the entire example
"arn:aws:sns:us-east-1:123456789012:sms-delivery-status-topic"with the actual ARN of the SNS topic you created in Step 1.3. Ensure the region (us-east-1in the example) and account ID (123456789012in the example) match your setup.
- You must replace
AWS_REGION: The AWS region where you configured SNS and the topic.
3. Implementing the Database Schema
We need a way to store the messages we send and track their status.
Step 1: Define the Prisma Schema
Open api/db/schema.prisma and add a model to store SMS details:
// api/db/schema.prisma
datasource db {
provider = ""postgresql"" // Or ""sqlite""
url = env(""DATABASE_URL"")
}
generator client {
provider = ""prisma-client-js""
binaryTargets = ""native""
}
// Add this model
model SmsMessage {
id String @id @default(cuid()) // Using CUID for IDs
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
toPhoneNumber String
messageBody String
awsMessageId String? @unique // The MessageId returned by SNS publish
deliveryStatus String? // e.g., 'PENDING', 'DELIVERED', 'FAILED'
statusPayload Json? // Store the raw status payload from SNS
sentSuccessfully Boolean @default(false) // Track if the initial publish call succeeded
}awsMessageId: Stores the unique ID returned by SNS when the message is initially published. This is crucial for correlating callbacks.deliveryStatus: Stores the final status received from the callback.statusPayload: Stores the full JSON payload from the SNS notification for debugging or additional info.sentSuccessfully: Indicates if thesns.publishcall itself was successful.
Step 2: Apply Migrations
Generate and apply the database migration:
yarn rw prisma migrate dev --name add_sms_message4. Implementing the SMS Sending Service
Encapsulate the logic for sending SMS messages within a RedwoodJS service.
Step 1: Generate the Service
yarn rw g service smsStep 2: Implement the Sending Logic
Replace the contents of api/src/services/sms/sms.ts with the following:
// api/src/services/sms/sms.ts
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';
import type { PublishCommandInput } from '@aws-sdk/client-sns';
import { db } from 'src/lib/db'; // Import Redwood's Prisma client
import { logger } from 'src/lib/logger'; // Import Redwood's logger
const region = process.env.AWS_REGION;
const snsClient = new SNSClient({ region });
interface SendSmsInput {
toPhoneNumber: string; // E.164 format (e.g., +15551234567)
messageBody: string;
}
export const sendSms = async ({ toPhoneNumber, messageBody }: SendSmsInput) => {
logger.info(
`Attempting to send SMS to ${toPhoneNumber} via AWS SNS`
);
// 1. Create initial record in the database
let smsRecord;
try {
smsRecord = await db.smsMessage.create({
data: {
toPhoneNumber,
messageBody,
deliveryStatus: 'PENDING', // Initial status
},
});
logger.debug({ custom: { smsRecordId: smsRecord.id } }, 'SMS record created');
} catch (dbError) {
logger.error({ custom: { dbError } }, 'Failed to create SMS record in DB');
// Depending on requirements, you might want to throw or return an error here
// to prevent attempting the SNS publish if DB write fails.
throw new Error('Database error creating SMS record.');
}
// 2. Prepare the SNS Publish command
const params: PublishCommandInput = {
Message: messageBody,
PhoneNumber: toPhoneNumber,
MessageAttributes: {
// Optional: You can set a default Sender ID here if configured in SNS
// 'AWS.SNS.SMS.SenderID': {
// DataType: 'String',
// StringValue: 'MySenderID'
// },
'AWS.SNS.SMS.SMSType': {
DataType: 'String',
StringValue: 'Transactional', // Or 'Promotional'
},
},
};
// 3. Send the message via SNS
try {
const command = new PublishCommand(params);
const data = await snsClient.send(command);
logger.info(
{ custom: { awsMessageId: data.MessageId, smsRecordId: smsRecord.id } },
'SMS successfully published to SNS'
);
// 4. Update the DB record with the AWS Message ID and success status
const updatedRecord = await db.smsMessage.update({
where: { id: smsRecord.id },
data: {
awsMessageId: data.MessageId,
sentSuccessfully: true, // Mark the publish call as successful
},
});
return {
success: true,
message: 'SMS submitted to SNS successfully.',
databaseId: updatedRecord.id,
awsMessageId: data.MessageId,
};
} catch (error) {
logger.error(
{ custom: { error, smsRecordId: smsRecord.id } },
'Failed to publish SMS via SNS'
);
// Optionally update the DB record to reflect the publish failure
await db.smsMessage.update({
where: { id: smsRecord.id },
data: {
deliveryStatus: 'PUBLISH_FAILED',
sentSuccessfully: false,
},
}).catch(updateError => {
logger.error({ custom: { updateError, smsRecordId: smsRecord.id }}, 'Failed to update SMS record after SNS publish failure');
});
return {
success: false,
message: 'Failed to submit SMS to SNS.',
error: error.message,
databaseId: smsRecord.id,
};
}
};
// Optional: Add SDL for GraphQL API if needed
// api/src/graphql/sms.sdl.ts
export const schema = gql`
type SmsSendResponse {
success: Boolean!
message: String!
databaseId: String
awsMessageId: String
error: String
}
type Mutation {
sendSms(toPhoneNumber: String!, messageBody: String!): SmsSendResponse! @requireAuth
}
`;
// Optional: Add service resolvers if using GraphQL
// api/src/services/sms/sms.ts (add this to the existing file)
/*
export const Mutation = {
sendSms: (_parent, { toPhoneNumber, messageBody }: SendSmsInput, _context) => {
// Add authorization/validation checks here if needed
return sendSms({ toPhoneNumber, messageBody });
},
};
*/- Database Interaction: Creates a record before sending and updates it with the
awsMessageIdon success or failure. - Error Handling: Logs errors and updates the database status if the SNS publish fails.
- AWS SDK v3: Uses the modular SDK (
@aws-sdk/client-sns). - SMSType: Set to 'Transactional' (for non-marketing) or 'Promotional'.
5. Implementing the Callback Handler Function
This is the core endpoint that AWS SNS will call with delivery status updates.
Step 1: Generate the Redwood Function
yarn rw g function snsCallbackThis creates api/src/functions/snsCallback.ts.
Step 2: Implement the Handler Logic
Replace the contents of api/src/functions/snsCallback.ts with the following:
// api/src/functions/snsCallback.ts
import type { APIGatewayEvent, Context } from 'aws-lambda';
import { MessageValidator } from 'aws-sns-message-validator';
import { db } from 'src/lib/db';
import { logger } from 'src/lib/logger';
// Instantiate the validator
const validator = new MessageValidator();
// Define expected structure of the SNS message body (adjust based on actual payloads)
interface SnsNotificationPayload {
Type: 'SubscriptionConfirmation' | 'Notification' | 'UnsubscribeConfirmation';
MessageId: string;
Token?: string; // Only for SubscriptionConfirmation
TopicArn: string;
Subject?: string;
Message: string; // This will be a JSON string for delivery status
SubscribeURL?: string; // Only for SubscriptionConfirmation
Timestamp: string;
SignatureVersion: string;
Signature: string;
SigningCertURL: string;
}
interface DeliveryStatusMessage {
notification: {
messageId: string; // Corresponds to the original SNS publish MessageId
timestamp: string;
};
delivery: {
phoneCarrier: string;
mnc: number;
destination: string; // The recipient phone number
priceInUSD: number;
smsType: string;
mcc: number;
providerResponse: string; // Provider-specific info, can be useful for debugging
dwellTimeMs: number;
dwellTimeMsUntilDeviceAck?: number; // May be present for successful deliveries
};
status: 'SUCCESS' | 'FAILURE'; // The actual delivery status
}
export const handler = async (event: APIGatewayEvent, _context: Context) => {
logger.debug({ custom: event.headers }, 'Received request on /snsCallback');
if (!event.body) {
logger.error('Request body is missing');
return { statusCode: 400, body: 'Bad Request: Missing body' };
}
let snsPayload: SnsNotificationPayload;
try {
// AWS often sends the payload with escaped quotes, requires careful parsing
snsPayload = JSON.parse(event.body);
logger.debug({ custom: snsPayload }, 'Parsed SNS Payload');
} catch (error) {
logger.error({ custom: { body: event.body, error } }, 'Failed to parse request body as JSON');
return { statusCode: 400, body: 'Bad Request: Invalid JSON' };
}
// 1. Validate the incoming message signature (CRITICAL for security)
try {
await new Promise<void>((resolve, reject) => {
validator.validate(snsPayload, (err, message) => {
if (err) {
logger.error({ custom: { error: err, snsPayload } }, 'SNS Message validation failed');
return reject(err);
}
logger.info({ custom: { messageId: message.MessageId } }, 'SNS Message signature validated successfully');
resolve();
});
});
} catch (validationError) {
return { statusCode: 403, body: 'Forbidden: Invalid SNS message signature' };
}
// 2. Handle Subscription Confirmation
if (snsPayload.Type === 'SubscriptionConfirmation') {
logger.info(
{ custom: { subscribeUrl: snsPayload.SubscribeURL } },
'Received SNS Subscription Confirmation'
);
try {
// IMPORTANT: You MUST visit the SubscribeURL to confirm the subscription.
// In a real app, you might make an HTTP GET request here programmatically.
// For initial setup, you can copy/paste the URL from the logs into your browser.
const response = await fetch(snsPayload.SubscribeURL);
if (!response.ok) {
throw new Error(`Failed to confirm subscription: ${response.statusText}`);
}
logger.info('Successfully confirmed SNS subscription via SubscribeURL');
return { statusCode: 200, body: 'Subscription confirmed' };
} catch (error) {
logger.error({ custom: { error } }, 'Failed to automatically confirm SNS subscription');
// Return 200 anyway so SNS doesn't retry indefinitely, but log the error.
// Manual confirmation via console/logs might be needed.
return { statusCode: 200, body: 'Subscription confirmation received, but auto-confirm failed. Check logs.' };
}
}
// 3. Handle Delivery Status Notification
if (snsPayload.Type === 'Notification') {
logger.info({ custom: { messageId: snsPayload.MessageId } }, 'Received SNS Notification');
let deliveryStatus: DeliveryStatusMessage;
try {
deliveryStatus = JSON.parse(snsPayload.Message);
logger.debug({ custom: deliveryStatus }, 'Parsed Delivery Status Message');
} catch (error) {
logger.error(
{ custom: { message: snsPayload.Message, error } },
'Failed to parse SNS Message content as JSON (delivery status)'
);
return { statusCode: 400, body: 'Bad Request: Invalid Message JSON' };
}
const awsMessageId = deliveryStatus.notification.messageId;
const status = deliveryStatus.status === 'SUCCESS' ? 'DELIVERED' : 'FAILED';
// 4. Update the corresponding database record
try {
const updatedRecord = await db.smsMessage.update({
where: { awsMessageId: awsMessageId },
data: {
deliveryStatus: status,
statusPayload: deliveryStatus as any, // Store the full payload as JSON
updatedAt: new Date(), // Explicitly set update time
},
});
logger.info(
{ custom: { awsMessageId, newStatus: status, dbId: updatedRecord.id } },
'Successfully updated SMS message status in database'
);
return { statusCode: 200, body: 'Notification processed successfully' };
} catch (error) {
// Handle case where messageId might not be found (e.g., race condition, data issue)
if (error.code === 'P2025') { // Prisma code for record not found
logger.warn({ custom: { awsMessageId } }, 'Could not find matching SMS record for update. Maybe already processed or invalid ID.');
// Return 200 so SNS doesn't retry, as we can't fix this by retrying.
return { statusCode: 200, body: 'Notification acknowledged, but no matching record found.' };
}
logger.error(
{ custom: { awsMessageId, error } },
'Failed to update SMS message status in database'
);
// Return 500 to potentially trigger SNS retries if it was a transient DB error
return { statusCode: 500, body: 'Internal Server Error: Database update failed' };
}
}
// Handle other types like UnsubscribeConfirmation if necessary
logger.warn({ custom: { type: snsPayload.Type } }, 'Received unhandled SNS message type');
return { statusCode: 200, body: 'Unhandled message type received' };
};- Message Validation: Uses
aws-sns-message-validatorto verify theSignaturefield. This is essential to ensure the request genuinely came from AWS SNS. - Subscription Confirmation: Handles the initial handshake from SNS. When you first create the subscription, SNS sends a
SubscriptionConfirmationmessage. You must confirm this, either by visiting theSubscribeURL(logged by the function) or by making an HTTP GET request to it from the handler. - Notification Handling: Parses the
Messagefield (which contains the actual delivery status JSON) when theTypeisNotification. - Database Update: Finds the
SmsMessagerecord using theawsMessageIdfrom the notification and updates itsdeliveryStatusandstatusPayload. - Error Handling: Includes checks for missing body, JSON parsing errors, validation failures, and database update errors (including handling cases where the record isn't found). Returns appropriate HTTP status codes (200 for success/non-retryable errors, 4xx for bad requests/auth issues, 500 for potential retryable server errors).
6. Connecting SNS to the Callback Endpoint
Now, link the SNS Topic (which receives delivery status) to your RedwoodJS callback function.
Step 1: Get Your Public Callback URL
- Development: Use a tool like
ngrokto expose your local development server:Copy thebashngrok http 8911 # 8911 is Redwood's default API porthttpsforwarding URL provided by ngrok (e.g.,https://<unique-id>.ngrok.io). Your full callback URL will behttps://<unique-id>.ngrok.io/snsCallback. - Production: Deploy your RedwoodJS application to a hosting provider (Render, Vercel, Fly.io, Netlify, etc.). Get the public URL of your deployed API. Your callback URL will be
https://<your-deployed-api-url>/snsCallback.
Step 2: Create an SNS Subscription
- Navigate to SNS in the AWS Console.
- Go to Topics and select the topic you created for delivery status (e.g.,
sms-delivery-status-topic). - Click the Create subscription button.
- Protocol: Select HTTPS.
- Endpoint: Paste your public callback URL (from Step 1).
- Enable raw message delivery: Keep this unchecked. We want the full SNS JSON structure, including metadata needed for validation.
- Click Create subscription.
Step 3: Confirm the Subscription
- Check the logs of your running RedwoodJS API (or your ngrok console). You should see log entries indicating a
SubscriptionConfirmationmessage was received, including theSubscribeURL. - Copy the
SubscribeURLfrom the logs and paste it into your web browser. You should see an XML confirmation from AWS. - Alternatively, if you implemented the automatic
fetchin the handler, it might confirm automatically (check logs for success/failure). - Go back to the SNS subscription list in the AWS console. The status should change from "Pending confirmation" to "Confirmed".
7. Security Considerations
- Signature Validation: This is paramount. Always validate the signature of incoming SNS messages using
aws-sns-message-validatoror a similar library. This prevents attackers from spoofing delivery status updates. - HTTPS: Always use HTTPS for your callback endpoint URL to encrypt data in transit.
ngrokprovides HTTPS URLs, and production deployment platforms should be configured for HTTPS. - Environment Variables: Keep AWS credentials and other secrets out of your codebase. Use
.envlocally and configure environment variables securely in your deployment environment. Do not commit.env. - Least Privilege: The IAM role (
SNSSMSDeliveryStatusRole) created earlier has broad CloudWatch permissions (*). In a production scenario, scope this down to the specific log groups SNS needs to write to, if you rely heavily on CloudWatch logging alongside callbacks, as warned in Step 1.1. For the callback mechanism itself (sending notifications to the SNS Topic), the critical permissions are implicitly handled by SNS when you configure the delivery status topic in the SNS service settings. - Input Validation: While SNS provides the data, robust applications should still treat the parsed
deliveryStatuspayload cautiously, although the primary security concern is the signature validation.
8. Testing the Implementation
Step 1: Unit Testing the Service
Mock the AWS SDK to test your sendSms service logic without actually sending messages or hitting AWS.
// api/src/services/sms/sms.test.ts
import { mock } from 'jest-mock-extended';
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';
import { sendSms } from './sms';
import { db } from 'src/lib/db'; // Import db for potential mocking/spying if needed
// Mock the AWS SDK v3 client
jest.mock('@aws-sdk/client-sns', () => {
const originalModule = jest.requireActual('@aws-sdk/client-sns');
return {
...originalModule,
SNSClient: jest.fn(() => ({ // Mock the constructor
send: jest.fn(), // Mock the send method
})),
PublishCommand: jest.fn((input) => ({ // Mock the command constructor
input, // Store input for inspection if needed
})),
};
});
// Mock the db client (optional, depending on test granularity)
// jest.mock('src/lib/db', () => ({
// db: {
// smsMessage: {
// create: jest.fn(),
// update: jest.fn(),
// },
// },
// }))
describe('sms service', () => {
const mockSnsClient = new SNSClient({}); // Instance of the mocked client
const mockSend = mockSnsClient.send as jest.Mock; // Get the mocked send method
beforeEach(() => {
jest.clearAllMocks(); // Clear mocks between tests
// Mock successful DB operations if db is mocked
// (db.smsMessage.create as jest.Mock).mockResolvedValue({ id: 'sms-cuid-123' });
// (db.smsMessage.update as jest.Mock).mockResolvedValue({ id: 'sms-cuid-123', awsMessageId: 'mock-aws-id-xyz' });
});
it('sendSms successfully publishes to SNS and updates DB', async () => {
const toPhoneNumber = '+15550001111';
const messageBody = 'Test message';
const mockAwsMessageId = 'mock-aws-id-xyz';
// Mock successful SNS response
mockSend.mockResolvedValueOnce({ MessageId: mockAwsMessageId });
// Mock DB interactions (if not using real test DB)
const mockDbCreate = jest.spyOn(db.smsMessage, 'create').mockResolvedValueOnce({
id: 'sms-cuid-123', createdAt: new Date(), updatedAt: new Date(),
toPhoneNumber, messageBody, awsMessageId: null, deliveryStatus: 'PENDING',
statusPayload: null, sentSuccessfully: false
});
const mockDbUpdate = jest.spyOn(db.smsMessage, 'update').mockResolvedValueOnce({
id: 'sms-cuid-123', createdAt: new Date(), updatedAt: new Date(),
toPhoneNumber, messageBody, awsMessageId: mockAwsMessageId, deliveryStatus: 'PENDING',
statusPayload: null, sentSuccessfully: true
});
const result = await sendSms({ toPhoneNumber, messageBody });
expect(result.success).toBe(true);
expect(result.awsMessageId).toBe(mockAwsMessageId);
expect(result.databaseId).toBe('sms-cuid-123'); // Comes from the DB mock
expect(mockSend).toHaveBeenCalledTimes(1);
// Optionally inspect the input passed to PublishCommand
// expect(PublishCommand).toHaveBeenCalledWith({ Message: messageBody, PhoneNumber: toPhoneNumber, ... });
expect(mockDbCreate).toHaveBeenCalledWith({ data: { toPhoneNumber, messageBody, deliveryStatus: 'PENDING' } });
expect(mockDbUpdate).toHaveBeenCalledWith({ where: { id: 'sms-cuid-123' }, data: { awsMessageId: mockAwsMessageId, sentSuccessfully: true } });
});
it('sendSms handles SNS publish failure', async () => {
const toPhoneNumber = '+15552223333';
const messageBody = 'Failure test';
const errorMessage = 'SNS publish failed miserably';
// Mock failed SNS response
mockSend.mockRejectedValueOnce(new Error(errorMessage));
// Mock DB interactions
const mockDbCreate = jest.spyOn(db.smsMessage, 'create').mockResolvedValueOnce({
id: 'sms-fail-456', createdAt: new Date(), updatedAt: new Date(),
toPhoneNumber, messageBody, awsMessageId: null, deliveryStatus: 'PENDING',
statusPayload: null, sentSuccessfully: false
});
// Mock the update that happens on failure
const mockDbUpdateOnFail = jest.spyOn(db.smsMessage, 'update').mockResolvedValueOnce({
id: 'sms-fail-456', createdAt: new Date(), updatedAt: new Date(),
toPhoneNumber, messageBody, awsMessageId: null, deliveryStatus: 'PUBLISH_FAILED',
statusPayload: null, sentSuccessfully: false
});
const result = await sendSms({ toPhoneNumber, messageBody });
expect(result.success).toBe(false);
expect(result.error).toBe(errorMessage);
expect(result.databaseId).toBe('sms-fail-456');
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockDbCreate).toHaveBeenCalledTimes(1);
expect(mockDbUpdateOnFail).toHaveBeenCalledWith({
where: { id: 'sms-fail-456' },
data: { deliveryStatus: 'PUBLISH_FAILED', sentSuccessfully: false }
});
});
// Add more tests: DB create failure, etc.
});Step 2: Unit Testing the Callback Handler
Mock incoming HTTP events representing SNS messages.
// api/src/functions/snsCallback.test.ts
import { mockHttpEvent } from '@redwoodjs/testing/api';
import { handler } from './snsCallback';
import { MessageValidator } from 'aws-sns-message-validator';
import { db } from 'src/lib/db';
import { logger } from 'src/lib/logger'; // Import to potentially spy on logs
// Mock the validator
jest.mock('aws-sns-message-validator', () => ({
MessageValidator: jest.fn().mockImplementation(() => ({
validate: jest.fn((payload, callback) => {
// Default mock: Successful validation
// You can override this in specific tests if needed
if (payload?.Signature === 'INVALID_SIGNATURE_FOR_TEST') {
// Simulate validation failure
callback(new Error('Invalid signature'));
} else if (payload?.Type === 'SubscriptionConfirmation' && payload?.SubscribeURL) {
// Simulate successful validation for subscription confirmation
callback(null, payload);
} else if (payload?.Type === 'Notification' && payload?.MessageId) {
// Simulate successful validation for notification
callback(null, payload);
} else {
// Default success case or handle other scenarios
callback(null, payload);
}
}),
})),
}));
// Mock the db client
jest.mock('src/lib/db', () => ({
db: {
smsMessage: {
update: jest.fn(),
},
},
}));
// Mock fetch for subscription confirmation
global.fetch = jest.fn();
// Mock logger (optional, to suppress or check logs)
jest.mock('src/lib/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
describe('snsCallback handler', () => {
const mockDbUpdate = db.smsMessage.update as jest.Mock;
const mockFetch = global.fetch as jest.Mock;
const mockValidatorInstance = new MessageValidator({});
const mockValidate = mockValidatorInstance.validate as jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
// Reset fetch mock to default successful response for subscription confirmation
mockFetch.mockResolvedValue({ ok: true, statusText: 'OK' });
});
it('returns 400 if body is missing', async () => {
const event = mockHttpEvent({}); // No body
const response = await handler(event, null);
expect(response.statusCode).toBe(400);
expect(response.body).toContain('Missing body');
});
it('returns 400 if body is invalid JSON', async () => {
const event = mockHttpEvent({ body: '{invalid json' });
const response = await handler(event, null);
expect(response.statusCode).toBe(400);
expect(response.body).toContain('Invalid JSON');
});
it('returns 403 if SNS signature validation fails', async () => {
const invalidPayload = {
Type: 'Notification',
MessageId: 'test-msg-id',
TopicArn: 'arn:aws:sns:us-east-1:123456789012:test-topic',
Message: '{}',
Timestamp: new Date().toISOString(),
SignatureVersion: '1',
Signature: 'INVALID_SIGNATURE_FOR_TEST', // Trigger failure in mock
SigningCertURL: 'https://example.com/cert.pem',
};
const event = mockHttpEvent({ body: JSON.stringify(invalidPayload) });
const response = await handler(event, null);
expect(mockValidate).toHaveBeenCalled();
expect(response.statusCode).toBe(403);
expect(response.body).toContain('Invalid SNS message signature');
});
it('handles SubscriptionConfirmation successfully', async () => {
const subscribeUrl = 'https://example.com/subscribe?token=123';
const subscriptionPayload = {
Type: 'SubscriptionConfirmation',
MessageId: 'sub-conf-id',
Token: '123',
TopicArn: 'arn:aws:sns:us-east-1:123456789012:test-topic',
Message: 'You have chosen to subscribe to the topic...',
SubscribeURL: subscribeUrl,
Timestamp: new Date().toISOString(),
SignatureVersion: '1',
Signature: 'VALID_SIGNATURE',
SigningCertURL: 'https://example.com/cert.pem',
};
const event = mockHttpEvent({ body: JSON.stringify(subscriptionPayload) });
const response = await handler(event, null);
expect(mockValidate).toHaveBeenCalledWith(subscriptionPayload, expect.any(Function));
expect(mockFetch).toHaveBeenCalledWith(subscribeUrl);
expect(response.statusCode).toBe(200);
expect(response.body).toContain('Subscription confirmed');
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Successfully confirmed SNS subscription'));
});
it('handles SubscriptionConfirmation failure during fetch', async () => {
const subscribeUrl = 'https://example.com/subscribe?token=456';
const subscriptionPayload = { /* ... same structure as above ... */
Type: 'SubscriptionConfirmation', MessageId: 'sub-conf-id-fail', Token: '456',
TopicArn: 'arn:aws:sns:us-east-1:123456789012:test-topic', Message: 'Sub message',
SubscribeURL: subscribeUrl, Timestamp: new Date().toISOString(), SignatureVersion: '1',
Signature: 'VALID_SIGNATURE', SigningCertURL: 'https://example.com/cert.pem',
};
const event = mockHttpEvent({ body: JSON.stringify(subscriptionPayload) });
// Mock fetch to fail
mockFetch.mockResolvedValueOnce({ ok: false, statusText: 'Not Found' });
const response = await handler(event, null);
expect(mockValidate).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(subscribeUrl);
expect(response.statusCode).toBe(200); // Still 200 to avoid SNS retries
expect(response.body).toContain('auto-confirm failed');
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ custom: { error: expect.any(Error) } }), expect.stringContaining('Failed to automatically confirm'));
});
it('handles Notification for SUCCESS status and updates DB', async () => {
const awsMessageId = 'sns-publish-id-123';
const deliveryStatusMessage: DeliveryStatusMessage = {
notification: { messageId: awsMessageId, timestamp: new Date().toISOString() },
delivery: { destination: '+15559998888', smsType: 'Transactional', /* other fields */ } as any,
status: 'SUCCESS',
};
const notificationPayload = {
Type: 'Notification',
MessageId: 'sns-callback-id-abc',
TopicArn: 'arn:aws:sns:us-east-1:123456789012:sms-delivery-status-topic',
Message: JSON.stringify(deliveryStatusMessage),
Timestamp: new Date().toISOString(),
SignatureVersion: '1',
Signature: 'VALID_SIGNATURE',
SigningCertURL: 'https://example.com/cert.pem',
};
const event = mockHttpEvent({ body: JSON.stringify(notificationPayload) });
// Mock successful DB update
mockDbUpdate.mockResolvedValueOnce({ id: 'db-record-id-xyz', awsMessageId });
const response = await handler(event, null);
expect(mockValidate).toHaveBeenCalled();
expect(mockDbUpdate).toHaveBeenCalledWith({
where: { awsMessageId: awsMessageId },
data: {
deliveryStatus: 'DELIVERED',
statusPayload: deliveryStatusMessage,
updatedAt: expect.any(Date),
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('Notification processed successfully');
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ custom: { awsMessageId, newStatus: 'DELIVERED' } }), expect.stringContaining('Successfully updated SMS message status'));
});
it('handles Notification for FAILURE status and updates DB', async () => {
const awsMessageId = 'sns-publish-id-456';
const deliveryStatusMessage: DeliveryStatusMessage = {
notification: { messageId: awsMessageId, timestamp: new Date().toISOString() },
delivery: { destination: '+15557776666', smsType: 'Transactional', /* other fields */ } as any,
status: 'FAILURE',
};
const notificationPayload = { /* ... same structure as success ... */
Type: 'Notification', MessageId: 'sns-callback-id-def', TopicArn: '...',
Message: JSON.stringify(deliveryStatusMessage), Timestamp: '...', SignatureVersion: '1',
Signature: 'VALID_SIGNATURE', SigningCertURL: '...',
};
const event = mockHttpEvent({ body: JSON.stringify(notificationPayload) });
mockDbUpdate.mockResolvedValueOnce({ id: 'db-record-id-abc', awsMessageId });
const response = await handler(event, null);
expect(mockValidate).toHaveBeenCalled();
expect(mockDbUpdate).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ deliveryStatus: 'FAILED' })
}));
expect(response.statusCode).toBe(200);
expect(response.body).toContain('Notification processed successfully');
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ custom: { awsMessageId, newStatus: 'FAILED' } }), expect.stringContaining('Successfully updated SMS message status'));
});
it('returns 400 if Notification Message is invalid JSON', async () => {
const notificationPayload = {
Type: 'Notification', MessageId: 'sns-callback-id-ghi', TopicArn: '...',
Message: '{invalid json inside message', // Invalid JSON in Message
Timestamp: '...', SignatureVersion: '1', Signature: 'VALID_SIGNATURE', SigningCertURL: '...',
};
const event = mockHttpEvent({ body: JSON.stringify(notificationPayload) });
const response = await handler(event, null);
expect(mockValidate).toHaveBeenCalled();
expect(response.statusCode).toBe(400);
expect(response.body).toContain('Invalid Message JSON');
expect(logger.error).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('Failed to parse SNS Message content'));
});
it('returns 200 if DB update fails with record not found (P2025)', async () => {
const awsMessageId = 'sns-publish-id-789-notfound';
const deliveryStatusMessage: DeliveryStatusMessage = { /* ... */ } as any;
deliveryStatusMessage.notification = { messageId: awsMessageId, timestamp: '...' };
deliveryStatusMessage.status = 'SUCCESS';
const notificationPayload = { /* ... */ } as any;
notificationPayload.Type = 'Notification';
notificationPayload.Message = JSON.stringify(deliveryStatusMessage);
notificationPayload.Signature = 'VALID_SIGNATURE'; // Ensure validation passes
const event = mockHttpEvent({ body: JSON.stringify(notificationPayload) });
// Mock DB update to throw Prisma "Record not found" error
const notFoundError = new Error('Record to update not found.');
notFoundError.code = 'P2025';
mockDbUpdate.mockRejectedValueOnce(notFoundError);
const response = await handler(event, null);
expect(mockValidate).toHaveBeenCalled();
expect(mockDbUpdate).toHaveBeenCalled();
expect(response.statusCode).toBe(200); // Acknowledge to SNS, don't retry
expect(response.body).toContain('no matching record found');
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ custom: { awsMessageId } }), expect.stringContaining('Could not find matching SMS record'));
});
it('returns 500 if DB update fails with other error', async () => {
const awsMessageId = 'sns-publish-id-101-dberror';
const deliveryStatusMessage: DeliveryStatusMessage = { /* ... */ } as any;
deliveryStatusMessage.notification = { messageId: awsMessageId, timestamp: '...' };
deliveryStatusMessage.status = 'FAILURE';
const notificationPayload = { /* ... */ } as any;
notificationPayload.Type = 'Notification';
notificationPayload.Message = JSON.stringify(deliveryStatusMessage);
notificationPayload.Signature = 'VALID_SIGNATURE';
const event = mockHttpEvent({ body: JSON.stringify(notificationPayload) });
// Mock DB update to throw a generic error
const dbError = new Error('Database connection lost');
mockDbUpdate.mockRejectedValueOnce(dbError);
const response = await handler(event, null);
expect(mockValidate).toHaveBeenCalled();
expect(mockDbUpdate).toHaveBeenCalled();
expect(response.statusCode).toBe(500); // Signal potential retry to SNS
expect(response.body).toContain('Database update failed');
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ custom: { awsMessageId, error: dbError } }), expect.stringContaining('Failed to update SMS message status'));
});
it('handles unhandled SNS message types gracefully', async () => {
const unhandledPayload = {
Type: 'UnsubscribeConfirmation', // Or any other type not explicitly handled
MessageId: 'unsub-id', TopicArn: '...', Message: '...', Timestamp: '...',
SignatureVersion: '1', Signature: 'VALID_SIGNATURE', SigningCertURL: '...',
};
const event = mockHttpEvent({ body: JSON.stringify(unhandledPayload) });
const response = await handler(event, null);
expect(mockValidate).toHaveBeenCalled();
expect(response.statusCode).toBe(200);
expect(response.body).toContain('Unhandled message type received');
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ custom: { type: 'UnsubscribeConfirmation' } }), expect.stringContaining('Received unhandled SNS message type'));
});
});Frequently Asked Questions
How to set up AWS SNS delivery status callbacks in RedwoodJS?
Configure AWS SNS to send delivery status notifications to a dedicated RedwoodJS endpoint. Create an IAM policy and role for SNS, then create an SNS Topic. In RedwoodJS, install necessary dependencies, configure environment variables with AWS credentials and Topic ARN, implement your database schema and SMS service to send messages, and implement your callback handler in a RedwoodJS function. Finally, connect SNS to the callback endpoint by creating a subscription and confirming it. This completes the setup for real-time delivery status updates in your application.
What is the purpose of delivery status callbacks with AWS SNS?
Delivery status callbacks provide real-time updates on the delivery status of your SMS messages. This is crucial for tracking message success rates, triggering follow-up actions, and providing better feedback to users. Unlike standard SNS publish calls, which only confirm that AWS accepted the request, callbacks provide insights into whether the message reached the end user's device.
Why does standard SNS publish not confirm end-user delivery?
The standard `publish` call in AWS SNS only confirms that the message request was successfully received and queued by the SNS service. It doesn't track the actual delivery to the user's device. This is why delivery status callbacks are essential for confirming message delivery outcomes and identifying any delivery failures.
When should I use 'Transactional' vs 'Promotional' SMSType in AWS SNS?
Use 'Transactional' for SMS messages related to application functionality, such as one-time passwords (OTPs), purchase confirmations, and delivery notifications. Use 'Promotional' for marketing-related messages, special offers, or other non-critical communications. The distinction helps manage costs and ensures compliance with regulations.
Can I receive AWS SNS delivery updates without CloudWatch Logs?
Yes, you can receive delivery status updates directly via HTTPS endpoints without using CloudWatch Logs. While CloudWatch Logs offer a monitoring option, this guide focuses on setting up direct HTTPS callbacks to a RedwoodJS function for real-time status updates. Configuring basic logging permissions might still be a prerequisite during AWS console setup, however.
How to create an IAM policy for SNS delivery status in AWS?
In the AWS IAM service, go to Policies, click 'Create Policy', and define a policy allowing SNS to manage CloudWatch Logs. This policy is often required when initially configuring SMS preferences. Remember to apply the principle of least privilege by limiting resources and actions, if CloudWatch is the core of your monitoring, as excessive permissions can present security risks.
How to secure SNS callbacks in RedwoodJS?
Implement robust security measures like signature validation, using HTTPS, and protecting AWS credentials. Use the `aws-sns-message-validator` package to verify signatures of all incoming messages. Always use an HTTPS endpoint for your callbacks, and store sensitive information like AWS keys securely via environment variables that are never committed to version control. Also follow the principle of least privilege by creating restrictive IAM roles.
How to test RedwoodJS services that interact with AWS SNS?
Mock the AWS SDK using Jest. This isolates your service logic and enables testing different scenarios like successful and failed publishing without actually sending messages or interacting with AWS. You can check that the PublishCommand is called with the correct parameters and that database records are appropriately updated based on the success or failure of the mocked SNS responses.
How to confirm SNS subscription for callbacks in RedwoodJS?
Upon creating an SNS subscription, AWS sends a `SubscriptionConfirmation` message to your callback URL with a unique `SubscribeURL`. For manual confirmation, copy this `SubscribeURL` from your RedwoodJS logs and paste it into a browser. For automatic confirmation, implement a `fetch` call to the `SubscribeURL` in your callback handler function.
What does 'awsMessageId' signify in delivery status callbacks?
The `awsMessageId` is a unique identifier returned by AWS SNS when you initially publish an SMS message. It is crucial for linking the delivery status callback to the specific message sent. The callback notification includes this ID, enabling you to update the status of the corresponding message in your database.
What to do if SNS message signature validation fails in RedwoodJS?
If signature validation fails, reject the request immediately with a 403 Forbidden response. This protects your application from potentially malicious or spoofed notifications. Ensure you're using `aws-sns-message-validator` or a similar library correctly, and verify that the incoming message is indeed being sent by AWS SNS.