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.
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 .env file into process.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:2px
Prerequisites:
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 ngrok can 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:
Bash
mkdir fastify-sns-callbacks
cd fastify-sns-callbacks
npm init -y
1.2. Install Dependencies
Bash
# 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.js
1.3. Project Structure
Create the following basic file structure:
Text
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 point
1.4. Configure .gitignore
Create a .gitignore file and add node_modules and .env to prevent committing sensitive information and dependencies:
Text
# .gitignore
node_modules
.env
*.log
1.5. Environment Variables (.env)
Create a .env file to store sensitive configuration and credentials.
.env
# .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, fatal
Explanation:
Storing credentials and environment-specific settings in .env keeps them separate from the codebase, enhancing security and portability.
dotenv loads these variables into process.env when 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.
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)
JavaScript
// server.jsconstFastify = require('fastify');
const config = require('./config');
const { SnsClient, PublishCommand } = require('@aws-sdk/client-sns');
constMessageValidator = require('sns-validator');
const { promisify } = require('util');
// Determine if running in production for logger transportconst isProduction = process.env.NODE_ENV === 'production';
const fastify = Fastify({
logger: {
level: config.server.logLevel,
// Use pino-pretty only in non-production environments for readabilitytransport: !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 = newSnsClient({
region: config.aws.region,
credentials: {
accessKeyId: config.aws.accessKeyId,
secretAccessKey: config.aws.secretAccessKey,
},
});
// --- SNS Validator Setup ---const validator = newMessageValidator();
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 ---conststart = 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-pretty is 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 its validate method uses callbacks, we promisify it for easier use with async/await.
The start function launches the server, listening on the configured port and 0.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):
JavaScript
// 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 = newPublishCommand({
PhoneNumber: phoneNumber, // For direct SMS publishingMessage: 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-sms POST route.
Basic validation checks for phoneNumber and messageBody. A simple E.164 format check is included. (Schema validation is added in Section 3).
Crucially: Inside the PublishCommand parameters, we set MessageAttributes:
AWS.SNS.SMS.SMSType: Setting this to Transactional (or Promotional) 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 MessageId returned 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.
JavaScript
// Example for publishing to Topic ARNconst command = newPublishCommand({
TopicArn: config.aws.snsTopicArn, // Use Topic ARN from configMessage: JSON.stringify({ default: messageBody, sms: messageBody }), // Example for multi-protocol messageMessageStructure: 'json', // If sending different messages per protocolMessageAttributes: {
// 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):
JavaScript
// 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 JSONreturn { error: 'Invalid JSON format' };
}
// 3. Validate the message signature (CRUCIAL SECURITY STEP)try {
// Pass the parsed message object to the promisified validatorawaitvalidateSnsMessage(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 bodyswitch (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 fetchconst confirmationResponse = awaitfetch(message.SubscribeURL);
if (!confirmationResponse.ok) {
// Log details from the response if availableconst responseBody = await confirmationResponse.text();
thrownewError(`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 handlingcase'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 stringconst 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 callconst deliveryStatus = notificationData.status; // e.g., DELIVERED, FAILED
fastify.log.info({
msg: 'Processing Delivery Status Notification',
snsCallbackMessageId: message.MessageId, // ID of the callback message itselfsnsOriginalMessageId: snsOriginalMessageId, // ID of the message whose status this is aboutdeliveryStatus: deliveryStatus,
timestamp: notificationData.notification?.timestamp, // Timestamp of the status eventproviderResponse: notificationData.providerResponse, // Optional: detailed carrier responsepriceInUSD: 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 switchcase'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 switchdefault:
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 failurereturn { error: 'Invalid SNS message signature' };
}
});
Explanation:
Endpoint & Raw Body: Defines /sns-callbacks POST route. Configures rawBody: true to access the raw request body needed for signature verification. Includes a check to ensure rawBody is available.
Parsing: Parses the rawBody string 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. Returns 403 Forbidden immediately on validation failure.
Type Handling (switch statement): Uses the Type field from the parsed JSON body (message.Type) to determine the action:
SubscriptionConfirmation: Logs receipt, validates SubscribeURL, then makes an HTTP GET request to it using node-fetch (dynamically imported for ESM compatibility). Logs success or failure of the confirmation attempt. Returns 200 OK to 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 within message.Message to get the actual status details (notificationData). Logs key fields like status and the original messageId. This is the primary integration point for your application logic (e.g., database updates). Returns 200 OK to SNS.
UnsubscribeConfirmation: Logs the event. Returns 200 OK.
default: Handles unexpected types. Returns 400 Bad Request.
Response Codes: Returns 200 OK promptly 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 return 403. Parsing errors or unknown types return 400.
3. Building a Complete API Layer (Example)
Let's refine the /send-sms endpoint with validation and authentication.
JavaScript
// 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.constauthenticate = 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 envif (!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 runningreturnPromise.reject(newError('Unauthorized'));
}
fastify.log.trace('Authentication successful');
};
// --- Refined /send-sms Route ---
fastify.post('/send-sms', {
// Apply the auth check before the main handlerpreHandler: [authenticate],
// Add Request Body Schema Validationschema: {
body: {
type: 'object',
required: ['phoneNumber', 'messageBody'],
properties: {
phoneNumber: {
type: 'string',
description: 'Recipient phone number in E.164 format (e.g., +12125551234)',
// E.164 regex patternpattern: '^\\+[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/validation200: {
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 schemaconst { phoneNumber, messageBody } = request.body;
try {
fastify.log.info(`Attempting to send validated SMS to ${phoneNumber}`);
const command = newPublishCommand({
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 laterreturn { 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 schemareturn { 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 a preHandler hook that calls our example authenticate function. 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 schema for the body and response. Fastify automatically validates incoming request bodies against the body schema. If validation fails, it sends a 400 error response automatically. This replaces the manual validation checks. The response schema 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 catch block now focuses on errors during the snsClient.send call, as input validation is handled by Fastify.
Testing with cURL:
Bash
# Replace placeholders with your actual valuesexport FASTIFY_URL="http://localhost:3000"# Or your deployed URLexport API_KEY="YOUR_SECRET_API_KEY_PLACEHOLDER"# Use the *actual* key you configuredexport 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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
DELIVERED,FAILED) for those messages via an HTTP/S endpoint exposed by the Fastify application.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:
@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.System Architecture:
Prerequisites:
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:
1.4. Configure
.gitignoreCreate a
.gitignorefile and addnode_modulesand.envto prevent committing sensitive information and dependencies:1.5. Environment Variables (
.env)Create a
.envfile to store sensitive configuration and credentials.Explanation:
.envkeeps them separate from the codebase, enhancing security and portability.dotenvloads these variables intoprocess.envwhen the application starts.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:
dotenvfirst.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:
config.js.pino-prettyis conditionally used for better readability during development. Production uses standard JSON logging.SnsClient, providing the region and credentials from our configuration.sns-validator. Since itsvalidatemethod uses callbacks, wepromisifyit for easier use withasync/await.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
MessageAttributesin thePublishCommand.Add the following route to
server.js(before thestart()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:
/send-smsPOST route.phoneNumberandmessageBody. A simple E.164 format check is included. (Schema validation is added in Section 3).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).snsClient.send(command)to publish the message.MessageIdreturned by SNS, which is essential for correlating status updates later.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 useTopicArninstead ofPhoneNumber. Ensure the Topic itself has delivery status logging configured (see Section 4.3) OR that theMessageAttributesneeded 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 thestart()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:
/sns-callbacksPOST route. ConfiguresrawBody: trueto access the raw request body needed for signature verification. Includes a check to ensurerawBodyis available.rawBodystring into a JavaScript object (message). Handles potential JSON parsing errors.validateSnsMessage(message)to check the signature. This is critical for security. Logs success or failure. Returns403 Forbiddenimmediately on validation failure.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.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-smsendpoint 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:
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.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.^\\+[1-9]\\d{1,14}$is correctly defined as a string within the JSON schema.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}$\""}