code examples
code examples
Fastify & AWS SNS: Implementing Message Delivery Status Callbacks
A guide on building a Fastify application to publish messages via AWS SNS and handle delivery status callbacks securely using HTTP/S.
This guide provides a step-by-step walkthrough for building a Fastify application that not only publishes messages using AWS Simple Notification Service (SNS) but also reliably receives and processes delivery status notifications from SNS via HTTP/S callbacks.
We'll cover everything from initial project setup and AWS configuration to building the callback endpoint, handling SNS message types (including subscription confirmation and notifications), ensuring security through signature verification, and best practices for logging, error handling, and deployment.
Project Goal:
To create a robust Fastify application capable of:
- Sending messages (e.g., SMS, push notifications) through an AWS SNS topic or directly.
- Receiving delivery status updates (e.g.,
DELIVERED,FAILED) for those messages via an HTTP/S endpoint exposed by the Fastify application. - Securely handling SNS subscription confirmation requests.
- Logging message statuses for tracking and debugging.
Problem Solved:
By default, when you publish a message to SNS, you get confirmation that SNS accepted the message, but not necessarily that the message reached the end recipient (like a specific phone number or mobile app). Implementing delivery status callbacks provides crucial visibility into the final delivery outcome, enabling applications to track message success rates, retry failed messages intelligently, or update internal records based on delivery status.
Technologies Used:
- Fastify: A high-performance, low-overhead Node.js web framework. Chosen for its speed, extensibility, and developer-friendly API.
- AWS SNS: A fully managed messaging service for both application-to-application (A2A) and application-to-person (A2P) communication. Essential for sending messages at scale.
- AWS SDK for JavaScript v3: The official AWS SDK for interacting with AWS services like SNS from Node.js applications.
@aws-sdk/client-sns: Specific AWS SDK v3 package for SNS.sns-validator: A utility library to validate the authenticity of incoming messages from AWS SNS, crucial for security.dotenv: Module to load environment variables from a.envfile intoprocess.env.- Node.js: The JavaScript runtime environment.
System Architecture:
graph LR
A[Client/Trigger] -- Sends Request --> B(Fastify App);
B -- 1. Publish Message (with Delivery Status Attributes) --> C(AWS SNS Topic/Direct);
C -- 2. Sends Message --> D(End Recipient e.g., SMS, Mobile App);
C -- 3. Sends Delivery Status Notification (HTTP/S POST) --> B;
B -- 4. Handles SNS Callback (Confirms Subscription / Processes Status) --> E{Callback Logic};
E -- (Optional) Update Status --> F[(Database)];
E -- Log Status --> G[/Logs/];
style B fill:#f9f,stroke:#333,stroke-width:2px
style C fill:#ff9,stroke:#333,stroke-width:2pxPrerequisites:
- Node.js (LTS version recommended) and npm/yarn installed.
- An AWS Account with permissions to manage SNS and IAM.
- Basic familiarity with Fastify and JavaScript/TypeScript.
- AWS Credentials (Access Key ID and Secret Access Key) configured for programmatic access.
- A publicly accessible URL for your Fastify application (required for SNS HTTP/S callbacks). Tools like
ngrokcan be used during development.
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
1.1. Create Project Directory & Initialize
Open your terminal and run the following commands:
mkdir fastify-sns-callbacks
cd fastify-sns-callbacks
npm init -y1.2. Install Dependencies
# Fastify core
npm install fastify
# AWS SDK v3 for SNS
npm install @aws-sdk/client-sns
# SNS message validator
npm install sns-validator
# Environment variable loader
npm install dotenv
# Optional: For pretty logging during development
npm install -D pino-pretty
# Optional: For making HTTP requests (like confirming subscription)
npm install node-fetch # Or use built-in undici in newer Node.js1.3. Project Structure
Create the following basic file structure:
fastify-sns-callbacks/
├── .env # Environment variables (DO NOT COMMIT)
├── .gitignore # Git ignore file
├── package.json
├── config.js # Application configuration
└── server.js # Main Fastify application entry point1.4. Configure .gitignore
Create a .gitignore file and add node_modules and .env to prevent committing sensitive information and dependencies:
# .gitignore
node_modules
.env
*.log1.5. Environment Variables (.env)
Create a .env file to store sensitive configuration and credentials.
# .env
# IMPORTANT: Fill in the placeholder values below with your actual AWS credentials and configuration.
# Do NOT commit this file to version control.
# --- PLACEHOLDERS - Replace with your actual values ---
# AWS Credentials & Configuration
AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID # Replace with your IAM User Access Key ID
AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY # Replace with your IAM User Secret Access Key
AWS_REGION=us-east-1 # Replace with your desired AWS region (e.g., us-west-2)
SNS_TOPIC_ARN=YOUR_SNS_TOPIC_ARN # Replace with the ARN of the SNS Topic you create (Section 4.2)
SNS_CALLBACK_ENDPOINT=YOUR_PUBLICLY_ACCESSIBLE_URL/sns-callbacks # Replace with your app's public URL + path (e.g., https://myapp.com/sns-callbacks or ngrok URL)
# Application Configuration
PORT=3000
LOG_LEVEL=info # e.g., trace, debug, info, warn, error, fatalExplanation:
- Storing credentials and environment-specific settings in
.envkeeps them separate from the codebase, enhancing security and portability. dotenvloads these variables intoprocess.envwhen the application starts.- Crucially, the placeholder values (
YOUR_...) must be replaced with your real configuration details obtained from AWS and your deployment environment.
1.6. Configuration File (config.js)
This file centralizes access to environment variables with potential defaults.
// config.js
require('dotenv').config();
const config = {
aws: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
region: process.env.AWS_REGION || 'us-east-1',
snsTopicArn: process.env.SNS_TOPIC_ARN,
snsCallbackEndpoint: process.env.SNS_CALLBACK_ENDPOINT,
},
server: {
port: parseInt(process.env.PORT, 10) || 3000,
logLevel: process.env.LOG_LEVEL || 'info',
},
};
// Basic validation to ensure required placeholders were filled
if (!config.aws.accessKeyId || config.aws.accessKeyId === 'YOUR_AWS_ACCESS_KEY_ID' ||
!config.aws.secretAccessKey || config.aws.secretAccessKey === 'YOUR_AWS_SECRET_ACCESS_KEY' ||
!config.aws.snsTopicArn || config.aws.snsTopicArn === 'YOUR_SNS_TOPIC_ARN' ||
!config.aws.snsCallbackEndpoint || config.aws.snsCallbackEndpoint === 'YOUR_PUBLICLY_ACCESSIBLE_URL/sns-callbacks') {
console.error('FATAL ERROR: Missing or placeholder AWS configuration in environment variables (.env). Please replace placeholders like YOUR_AWS_ACCESS_KEY_ID with actual values.');
process.exit(1);
}
module.exports = config;Explanation:
- We load
dotenvfirst. - We structure the configuration logically.
- We include validation to ensure critical AWS variables are set and are not still the default placeholder values, preventing runtime errors later.
2. Implementing Core Functionality: Sending & Receiving
Now, let's build the core Fastify server and implement the logic for sending messages and handling the incoming SNS callbacks.
2.1. Basic Fastify Server (server.js)
// server.js
const Fastify = require('fastify');
const config = require('./config');
const { SnsClient, PublishCommand } = require('@aws-sdk/client-sns');
const MessageValidator = require('sns-validator');
const { promisify } = require('util');
// Determine if running in production for logger transport
const isProduction = process.env.NODE_ENV === 'production';
const fastify = Fastify({
logger: {
level: config.server.logLevel,
// Use pino-pretty only in non-production environments for readability
transport: !isProduction ? {
target: 'pino-pretty',
options: {
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
} : undefined, // Use default JSON logger in production
},
});
// --- AWS SNS Client Setup ---
const snsClient = new SnsClient({
region: config.aws.region,
credentials: {
accessKeyId: config.aws.accessKeyId,
secretAccessKey: config.aws.secretAccessKey,
},
});
// --- SNS Validator Setup ---
const validator = new MessageValidator();
const validateSnsMessage = promisify(validator.validate).bind(validator);
// --- Placeholder for Routes (We will add these next) ---
// fastify.post('/send-sms', ...)
// fastify.post('/sns-callbacks', ...)
// --- Start Server ---
const start = async () => {
try {
await fastify.listen({ port: config.server.port, host: '0.0.0.0' }); // Listen on all interfaces
fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
fastify.log.info(`SNS Callback Endpoint configured at: ${config.aws.snsCallbackEndpoint}`);
fastify.log.info(`Ensure this endpoint is publicly accessible and matches the SNS Subscription endpoint.`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();Explanation:
- We initialize Fastify with logging configured based on
config.js.pino-prettyis conditionally used for better readability during development. Production uses standard JSON logging. - We create an instance of the AWS SDK v3
SnsClient, providing the region and credentials from our configuration. - We instantiate the
sns-validator. Since itsvalidatemethod uses callbacks, wepromisifyit for easier use withasync/await. - The
startfunction launches the server, listening on the configured port and0.0.0.0(important for containerized environments or VMs). Crucially, it logs the expected callback endpoint URL for verification.
2.2. Sending Messages with Delivery Status
To receive delivery status notifications, you must configure SNS to send them when publishing the message. This is done via MessageAttributes in the PublishCommand.
Add the following route to server.js (before the start() call):
// server.js (continued)
// --- Route to Send an SMS via SNS ---
fastify.post('/send-sms', async (request, reply) => {
// Basic validation (can be enhanced with schema - see Section 3)
const { phoneNumber, messageBody } = request.body || {};
if (!phoneNumber || !messageBody) {
reply.code(400);
return { error: 'Missing required fields: phoneNumber, messageBody' };
}
// E.164 format validation (basic example)
if (!/^\+[1-9]\d{1,14}$/.test(phoneNumber)) {
reply.code(400);
return { error: 'Invalid phoneNumber format. Use E.164 (e.g., +12125551234)' };
}
try {
fastify.log.info(`Attempting to send SMS to ${phoneNumber}`);
const command = new PublishCommand({
PhoneNumber: phoneNumber, // For direct SMS publishing
Message: messageBody,
MessageAttributes: {
// --- Crucial for enabling Delivery Status Logging for SMS ---
'AWS.SNS.SMS.SMSType': {
DataType: 'String',
StringValue: 'Transactional', // Or 'Promotional'. Required for SMS status.
},
// --- Optional: Set delivery status sampling rate ---
// 'AWS.SNS.SMS.SuccessFeedbackRate': {
// DataType: 'String',
// StringValue: '100' // Percentage (0-100) of successful deliveries to report status for
// },
// --- Optional: IAM Role ARN for SNS to use for logging (if needed) ---
// 'AWS.SNS.SMS.SuccessFeedbackRoleArn': {
// DataType: 'String',
// StringValue: 'arn:aws:iam::ACCOUNT_ID:role/SNSSuccessFeedback'
// },
// 'AWS.SNS.SMS.FailureFeedbackRoleArn': {
// DataType: 'String',
// StringValue: 'arn:aws:iam::ACCOUNT_ID:role/SNSFailureFeedback'
// },
// --- Optional: Custom Sender ID (requires registration in some regions/countries) ---
'AWS.SNS.SMS.SenderID': {
DataType: 'String',
StringValue: 'MyApp', // Replace with your registered Sender ID if applicable
},
// Add any other custom attributes if needed
// 'YourCustomAttribute': {
// DataType: 'String',
// StringValue: 'SomeValue'
// }
},
});
const response = await snsClient.send(command);
fastify.log.info({ msg: `SMS published successfully`, messageId: response.MessageId });
// Optional: Store messageId and initial status in a database here
// await db.storeMessage({ externalId: response.MessageId, status: 'SENT_TO_SNS', phoneNumber });
return { success: true, messageId: response.MessageId };
} catch (error) {
fastify.log.error({ msg: 'Error publishing SMS to SNS', error: error.message, stack: error.stack });
reply.code(500);
return { success: false, error: 'Failed to send SMS', details: error.message };
}
});Explanation:
- We define a
/send-smsPOST route. - Basic validation checks for
phoneNumberandmessageBody. A simple E.164 format check is included. (Schema validation is added in Section 3). - Crucially: Inside the
PublishCommandparameters, we setMessageAttributes:AWS.SNS.SMS.SMSType: Setting this toTransactional(orPromotional) tells SNS you want delivery status logging enabled for SMS. Transactional is optimized for reliability, Promotional for cost. This attribute is required for SMS delivery status.AWS.SNS.SMS.SenderID: An optional custom ID displayed on the recipient's device (subject to carrier support and registration requirements).- Other optional attributes related to feedback roles and success rates can also be set here if you prefer per-message configuration over topic-level defaults.
- We use
snsClient.send(command)to publish the message. - We log the success, including the
MessageIdreturned by SNS, which is essential for correlating status updates later. - Robust error handling logs failures and returns a 500 status.
Alternative Approach (Publishing to Topic):
If you prefer publishing to a Topic ARN (config.aws.snsTopicArn) instead of directly to a phone number, the structure is similar, but you use TopicArn instead of PhoneNumber. Ensure the Topic itself has delivery status logging configured (see Section 4.3) OR that the MessageAttributes needed for the specific subscriber type (e.g., SMS, Application) are included in the publish command.
// Example for publishing to Topic ARN
const command = new PublishCommand({
TopicArn: config.aws.snsTopicArn, // Use Topic ARN from config
Message: JSON.stringify({ default: messageBody, sms: messageBody }), // Example for multi-protocol message
MessageStructure: 'json', // If sending different messages per protocol
MessageAttributes: {
// Attributes might differ based on endpoint type (check AWS docs)
// For SMS subscribers to the topic, SMSType is still relevant here if not set on Topic
'AWS.SNS.SMS.SMSType': {
DataType: 'String',
StringValue: 'Transactional',
},
// ... other attributes potentially needed for other subscriber types ...
},
});2.3. Receiving SNS Callbacks
This is the endpoint SNS will call with subscription confirmations and delivery status notifications.
Add the following route to server.js (before the start() call):
// server.js (continued)
const nodeFetch = import('node-fetch').then(mod => mod.default); // Load fetch dynamically once
// --- Route to Handle Incoming SNS Notifications ---
// NOTE: This endpoint *must* be publicly accessible via HTTP/S at the URL defined in SNS_CALLBACK_ENDPOINT
// NOTE: SNS sends JSON, but requires text/plain;charset=UTF-8 content-type header initially for validation.
// Fastify handles JSON parsing automatically if Content-Type is application/json.
// We need the raw body for signature verification regardless of Content-Type.
fastify.post('/sns-callbacks', {
// Fastify v3+ syntax to access the raw request body:
config: {
rawBody: true // Need raw body for signature verification
}
// If using older Fastify: add preParsing hook or adjust body-parser options
}, async (request, reply) => {
let message;
const contentType = request.headers['content-type'] || '';
const xSnsMessageType = request.headers['x-amz-sns-message-type']; // Header indicating message type
fastify.log.trace({ msg: 'Received potential SNS callback', headers: request.headers });
// 1. Check if rawBody is available (required for sns-validator)
if (!request.rawBody) {
fastify.log.error('Raw request body not available. Ensure `rawBody: true` is set in route config.');
reply.code(500);
return { error: 'Server configuration error: Raw body missing.' };
}
// 2. Parse the incoming request body (sns-validator accepts the raw JSON string)
try {
// sns-validator works best with the raw string or a simple JS object parsed from it.
// Avoid passing the Fastify `request.body` directly if it has been heavily processed.
const rawBodyString = request.rawBody.toString('utf8');
message = JSON.parse(rawBodyString); // Parse the raw string for validation and processing
fastify.log.trace({ msg: 'Successfully parsed SNS message body', type: message.Type });
} catch (err) {
fastify.log.error({ msg: 'Failed to parse incoming SNS message JSON', error: err.message, body: request.rawBody?.toString('utf8') });
reply.code(400); // Bad Request - Invalid JSON
return { error: 'Invalid JSON format' };
}
// 3. Validate the message signature (CRUCIAL SECURITY STEP)
try {
// Pass the parsed message object to the promisified validator
await validateSnsMessage(message); // Throws error if invalid
fastify.log.debug({ msg: 'SNS message signature validated successfully', type: message.Type, messageId: message.MessageId });
// 4. Handle different SNS message types based on the 'Type' field in the JSON body
switch (message.Type) {
case 'SubscriptionConfirmation':
// Confirm the subscription automatically by fetching the SubscribeURL
// SECURITY NOTE: In production, consider adding checks (e.g., verify TopicArn)
// or requiring manual confirmation via AWS console instead of auto-confirming.
fastify.log.info(`Received SubscriptionConfirmation for Topic ${message.TopicArn}. Attempting to confirm...`);
if (!message.SubscribeURL || !message.SubscribeURL.startsWith('https://sns.')) {
fastify.log.error('Invalid or missing SubscribeURL in SubscriptionConfirmation');
reply.code(400).send({ error: 'Invalid SubscriptionConfirmation message' });
break;
}
try {
const fetch = await nodeFetch; // Use pre-imported fetch
const confirmationResponse = await fetch(message.SubscribeURL);
if (!confirmationResponse.ok) {
// Log details from the response if available
const responseBody = await confirmationResponse.text();
throw new Error(`Confirmation request failed with status: ${confirmationResponse.status}, Body: ${responseBody}`);
}
fastify.log.info(`Successfully confirmed subscription to Topic ${message.TopicArn}`);
// Send 200 OK back to SNS to acknowledge receipt of the confirmation request
reply.code(200).send({ success: true, message: 'Subscription Confirmed via HTTP GET' });
} catch (confirmError) {
fastify.log.error({ msg: 'Failed to auto-confirm SNS subscription via SubscribeURL', url: message.SubscribeURL, error: confirmError.message });
// Still send 200 OK to SNS so it doesn't keep retrying the *confirmation message delivery* indefinitely.
// The confirmation itself failed, which needs investigation, but SNS should stop sending this message.
reply.code(200).send({ success: false, message: 'Acknowledged SubscriptionConfirmation, but failed to automatically confirm via SubscribeURL. Check logs.' });
}
break; // Important: exit switch after handling
case 'Notification':
// This is the actual delivery status update
fastify.log.info({ msg: 'Received SNS Notification', messageId: message.MessageId });
try {
// The actual status information is nested within the 'Message' field as another JSON string
const notificationData = JSON.parse(message.Message);
// --- Process the Delivery Status ---
// Structure depends on what you configured in SNS Delivery Status Logging (Attributes / Topic level)
// Common fields for SMS: notification.messageId, status, priceInUSD, dwellTimeMs, providerResponse, etc.
const snsOriginalMessageId = notificationData.notification?.messageId; // ID from the original Publish call
const deliveryStatus = notificationData.status; // e.g., DELIVERED, FAILED
fastify.log.info({
msg: 'Processing Delivery Status Notification',
snsCallbackMessageId: message.MessageId, // ID of the callback message itself
snsOriginalMessageId: snsOriginalMessageId, // ID of the message whose status this is about
deliveryStatus: deliveryStatus,
timestamp: notificationData.notification?.timestamp, // Timestamp of the status event
providerResponse: notificationData.providerResponse, // Optional: detailed carrier response
priceInUSD: notificationData.priceInUSD, // Optional: cost if available
// ... other fields like dwellTimeMsUntilDeviceAck may be present
});
// TODO: Implement your application logic here
// Example: Update your database based on snsOriginalMessageId and deliveryStatus
// await db.updateMessageStatus(snsOriginalMessageId, deliveryStatus, notificationData);
// Acknowledge receipt to SNS
reply.code(200).send({ success: true, message: 'Notification received and acknowledged' });
} catch (parseError) {
fastify.log.error({ msg: 'Failed to parse nested JSON in SNS Notification message', error: parseError.message, rawMessageContent: message.Message });
// If the inner JSON is bad, it's a client error (bad message format from SNS?)
reply.code(400).send({ error: 'Invalid Notification message content format' });
}
break; // Important: exit switch
case 'UnsubscribeConfirmation':
// Handle unsubscribe notification if needed (usually just log it)
fastify.log.warn({ msg: 'Received UnsubscribeConfirmation', topicArn: message.TopicArn, messageId: message.MessageId });
// Optional: Clean up resources related to this subscription in your system
reply.code(200).send({ success: true, message: 'Unsubscribe confirmation acknowledged' });
break; // Important: exit switch
default:
fastify.log.warn({ msg: 'Received unknown SNS message type', type: message.Type, messageId: message.MessageId });
reply.code(400).send({ error: `Unsupported SNS message type: ${message.Type}` });
}
} catch (validationError) {
// Signature validation FAILED
fastify.log.error({ msg: 'SNS message signature validation failed!', error: validationError.message, headers: request.headers });
// DO NOT process the message. Return an error indicating forbidden access.
reply.code(403); // Forbidden - indicates signature validation failure
return { error: 'Invalid SNS message signature' };
}
});Explanation:
- Endpoint & Raw Body: Defines
/sns-callbacksPOST route. ConfiguresrawBody: trueto access the raw request body needed for signature verification. Includes a check to ensurerawBodyis available. - Parsing: Parses the
rawBodystring into a JavaScript object (message). Handles potential JSON parsing errors. - Validation: Uses the promisified
validateSnsMessage(message)to check the signature. This is critical for security. Logs success or failure. Returns403 Forbiddenimmediately on validation failure. - Type Handling (
switchstatement): Uses theTypefield from the parsed JSON body (message.Type) to determine the action:SubscriptionConfirmation: Logs receipt, validatesSubscribeURL, then makes an HTTP GET request to it usingnode-fetch(dynamically imported for ESM compatibility). Logs success or failure of the confirmation attempt. Returns200 OKto SNS to acknowledge the receipt of the confirmation message, regardless of whether the subsequent GET succeeded, to prevent SNS retries of the confirmation message itself.Notification: Logs receipt. Parses the nested JSON string withinmessage.Messageto get the actual status details (notificationData). Logs key fields likestatusand the originalmessageId. This is the primary integration point for your application logic (e.g., database updates). Returns200 OKto SNS.UnsubscribeConfirmation: Logs the event. Returns200 OK.default: Handles unexpected types. Returns400 Bad Request.
- Response Codes: Returns
200 OKpromptly after successfully receiving and acknowledging a valid message (Notification, SubscriptionConfirmation, UnsubscribeConfirmation). Internal processing errors (like database failures) should be logged and handled asynchronously (e.g., via a queue) rather than causing the endpoint to return 5xx to SNS, which would trigger SNS retries. Signature validation failures return403. Parsing errors or unknown types return400.
3. Building a Complete API Layer (Example)
Let's refine the /send-sms endpoint with validation and authentication.
// server.js (Add before the '/send-sms' route definition)
// --- Example Auth Hook (Replace with your actual secure implementation) ---
// This is a placeholder. Use @fastify/jwt, @fastify/auth, secure comparison libraries etc.
const authenticate = async (request, reply) => {
const apiKey = request.headers['x-api-key'];
// IMPORTANT: Replace 'YOUR_SECRET_API_KEY_PLACEHOLDER' with a strong, securely generated secret key
// stored securely (e.g., environment variable) and use a constant-time comparison function.
const expectedApiKey = process.env.INTERNAL_API_KEY || 'YOUR_SECRET_API_KEY_PLACEHOLDER'; // Load from env
if (!apiKey || apiKey !== expectedApiKey) { // Replace with secure comparison in production
fastify.log.warn('Authentication failed: Invalid or missing API key');
reply.code(401).send({ error: 'Unauthorized' });
// Throwing an error here prevents the handler from running
return Promise.reject(new Error('Unauthorized'));
}
fastify.log.trace('Authentication successful');
};
// --- Refined /send-sms Route ---
fastify.post('/send-sms', {
// Apply the auth check before the main handler
preHandler: [authenticate],
// Add Request Body Schema Validation
schema: {
body: {
type: 'object',
required: ['phoneNumber', 'messageBody'],
properties: {
phoneNumber: {
type: 'string',
description: 'Recipient phone number in E.164 format (e.g., +12125551234)',
// E.164 regex pattern
pattern: '^\\+[1-9]\\d{1,14}$'
},
messageBody: {
type: 'string',
description: 'The text message content',
minLength: 1,
maxLength: 1600 // Check SNS limits if necessary
}
},
additionalProperties: false // Disallow extra fields not defined in properties
},
response: { // Define expected success/error responses for documentation/validation
200: {
description: 'Successful message publication to SNS',
type: 'object',
properties: {
success: { type: 'boolean', const: true },
messageId: { type: 'string', description: 'The unique ID assigned by SNS to the message' }
}
},
'4xx': { // Covers 400 (validation), 401 (auth)
description: 'Client-side error (validation, authentication)',
type: 'object',
properties: {
statusCode: { type: 'integer' },
code: { type: 'string' },
error: { type: 'string' },
message: { type: 'string' }
}
},
'5xx': { // Covers 500 (server/SNS error)
description: 'Server-side error during SNS publish attempt',
type: 'object',
properties: {
success: { type: 'boolean', const: false },
error: { type: 'string' },
details: { type: 'string', nullable: true }
}
}
}
}
}, async (request, reply) => {
// Handler logic now assumes input is validated by the schema
const { phoneNumber, messageBody } = request.body;
try {
fastify.log.info(`Attempting to send validated SMS to ${phoneNumber}`);
const command = new PublishCommand({
PhoneNumber: phoneNumber,
Message: messageBody,
MessageAttributes: {
'AWS.SNS.SMS.SMSType': { DataType: 'String', StringValue: 'Transactional' },
'AWS.SNS.SMS.SenderID': { DataType: 'String', StringValue: 'MyApp' }, // Optional
// Add other attributes as needed (see Section 2.2)
},
});
const response = await snsClient.send(command);
fastify.log.info({ msg: `SMS published successfully to SNS`, messageId: response.MessageId });
// The 'messageId' is crucial for correlating with the callback later
return { success: true, messageId: response.MessageId };
} catch (error) {
fastify.log.error({ msg: 'Error publishing SMS to SNS after validation', phoneNumber: phoneNumber, error: error.message, stack: error.stack });
reply.code(500);
// Return a structure matching the 5xx schema
return { success: false, error: 'Failed to send SMS via SNS', details: error.message };
}
});
// --- Don't forget the /sns-callbacks route from Section 2.3 ---
// fastify.post('/sns-callbacks', { config: { rawBody: true } }, async (request, reply) => { ... });Explanation of Refinements:
- Authentication Hook (
preHandler): We add apreHandlerhook that calls our exampleauthenticatefunction. This hook is a basic placeholder. Replace it with a robust authentication mechanism (@fastify/jwt,@fastify/auth, OAuth, etc.) and use secure key comparison. Load secrets from environment variables. - Schema Validation: We define a
schemafor thebodyandresponse. Fastify automatically validates incoming request bodies against thebodyschema. If validation fails, it sends a 400 error response automatically. This replaces the manual validation checks. Theresponseschema helps document and potentially validate outgoing responses. - Regex Pattern: The E.164 regex pattern
^\\+[1-9]\\d{1,14}$is correctly defined as a string within the JSON schema. - Error Handling: The
catchblock now focuses on errors during thesnsClient.sendcall, as input validation is handled by Fastify.
Testing with cURL:
# Replace placeholders with your actual values
export FASTIFY_URL="http://localhost:3000" # Or your deployed URL
export API_KEY="YOUR_SECRET_API_KEY_PLACEHOLDER" # Use the *actual* key you configured
export PHONE_NUMBER="+12125551234" # Use a real number for testing
# Test Success
curl -X POST "${FASTIFY_URL}/send-sms" \
-H "Content-Type: application/json" \
-H "X-API-Key: ${API_KEY}" \
-d '{
"phoneNumber": "'"${PHONE_NUMBER}"'",
"messageBody": "Hello from Fastify SNS Test! (Callback Test)"
}'
# Expected: {"success":true,"messageId":"..."}
# Test Auth Failure (Wrong Key)
curl -X POST "${FASTIFY_URL}/send-sms" \
-H "Content-Type: application/json" \
-H "X-API-Key: WRONG_KEY" \
-d '{"phoneNumber": "'"${PHONE_NUMBER}"'", "messageBody": "Test"}'
# Expected: {"statusCode":401,"code":"FST_ERR_AUTH_UNAUTHORIZED","error":"Unauthorized","message":"Unauthorized"} or similar based on auth implementation
# Test Validation Failure (Invalid Phone)
curl -X POST "${FASTIFY_URL}/send-sms" \
-H "Content-Type: application/json" \
-H "X-API-Key: ${API_KEY}" \
-d '{"phoneNumber": "12345", "messageBody": "Test"}'
# Expected: {"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body/phoneNumber must match pattern \"^\\+[1-9]\\d{1,14}$\""}Frequently Asked Questions
How to set up AWS SNS callbacks in Fastify?
Set up an endpoint in your Fastify app, configure it as the callback URL in your SNS topic settings, and implement logic to handle subscription confirmations and delivery status notifications at that endpoint. This allows your Fastify application to receive real-time updates on message delivery status directly from SNS. Don't forget to verify the message signature for security.
What is the purpose of sns-validator library?
The `sns-validator` library is used to verify the authenticity of incoming messages from AWS SNS to your Fastify application. This is a crucial security measure to ensure that the notifications you're receiving are actually from AWS and haven't been tampered with.
Why does SNS require 'AWS.SNS.SMS.SMSType' attribute?
The `AWS.SNS.SMS.SMSType` attribute is required when publishing SMS messages via SNS if you want to receive delivery status notifications. Set it to 'Transactional' for critical messages and 'Promotional' for other types of SMS messages. This lets SNS know to generate delivery reports.
When should I use 'Transactional' vs 'Promotional' SMS type?
Use 'Transactional' for critical messages requiring high reliability, such as one-time passwords (OTPs) or purchase confirmations. Use 'Promotional' for marketing or informational messages where cost optimization is preferred. This setting affects message delivery priority and pricing.
Can I auto-confirm SNS subscriptions programmatically?
Yes, you can confirm SNS subscriptions programmatically in your Fastify application using the `SubscribeURL` provided in the subscription confirmation message. Fetching this URL confirms the subscription. However, for production, consider adding security checks (e.g., TopicArn verification) or manual subscription management through the AWS console.
How to enable delivery status logging for AWS SNS?
Enable delivery status logging by setting the 'AWS.SNS.SMS.SMSType' message attribute to 'Transactional' or 'Promotional' when publishing messages. This applies when publishing directly to phone numbers or if you are setting per-message configurations instead of configuring it on the topic itself.
What is the role of 'MessageAttributes' in SNS publishing?
`MessageAttributes` in the AWS SNS `PublishCommand` allow you to include metadata with your messages, such as the 'AWS.SNS.SMS.SMSType' to enable delivery reports or custom identifiers for your application. These attributes are key for configuring delivery status logging and other message-specific options.
How to handle different SNS message types in Fastify?
Use a `switch` statement or similar logic to differentiate between 'SubscriptionConfirmation', 'Notification', and 'UnsubscribeConfirmation' message types received at your Fastify callback endpoint. The 'Type' field in the SNS message JSON indicates the message type.
What does a 403 Forbidden error mean in the SNS callback context?
A 403 Forbidden error from your Fastify SNS callback endpoint usually means that signature verification failed. This indicates a potential security issue, and you should not process the message. Check your signature validation logic and ensure it is correctly implemented using the sns-validator.
How to secure the Fastify SNS callback endpoint?
Secure the endpoint using signature verification with the `sns-validator` library. Validate the message signature against the certificate provided in the SNS message header. This prevents processing of forged messages and ensures the integrity of your notifications.
Why use rawBody in Fastify for SNS callbacks?
You need access to the raw request body (`rawBody: true`) in your Fastify route configuration to perform signature verification with the `sns-validator` library. The raw body contains the original, unmodified message content required for validation.
What are the prerequisites for setting up Fastify with AWS SNS?
You'll need a Node.js environment with npm/yarn, an AWS account with SNS and IAM permissions, AWS credentials configured, a publicly accessible URL for your Fastify app, and basic familiarity with Fastify, JavaScript/TypeScript, and AWS SDK setup.
How to publish messages to an SNS topic from Fastify?
Use the AWS SDK for JavaScript v3 (@aws-sdk/client-sns) with the `PublishCommand`, providing the `TopicArn` instead of a direct phone number. Ensure the topic has appropriate delivery logging settings. Optionally, define per-message attributes.
How to process delivery status notifications in Fastify?
In the 'Notification' type message handling section of your Fastify callback endpoint, parse the nested JSON string in the 'Message' field to access the actual delivery status (e.g., 'DELIVERED', 'FAILED') and other information provided by SNS. Then, implement your application logic (e.g., database updates) based on this status.