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.env
file 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: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:
mkdir fastify-sns-callbacks
cd fastify-sns-callbacks
npm init -y
1.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.js
1.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 point
.gitignore
1.4. Configure Create a .gitignore
file and add node_modules
and .env
to prevent committing sensitive information and dependencies:
# .gitignore
node_modules
.env
*.log
.env
)
1.5. Environment Variables (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, fatal
Explanation:
- Storing credentials and environment-specific settings in
.env
keeps them separate from the codebase, enhancing security and portability. dotenv
loads these variables intoprocess.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.
config.js
)
1.6. Configuration File (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
dotenv
first. - 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.
server.js
)
2.1. Basic Fastify Server (// 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-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 itsvalidate
method uses callbacks, wepromisify
it for easier use withasync/await
. - The
start
function 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-sms
POST route. - Basic validation checks for
phoneNumber
andmessageBody
. A simple E.164 format check is included. (Schema validation is added in Section 3). - Crucially: Inside the
PublishCommand
parameters, 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
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.
// 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-callbacks
POST route. ConfiguresrawBody: true
to access the raw request body needed for signature verification. Includes a check to ensurerawBody
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. Returns403 Forbidden
immediately on validation failure. - Type Handling (
switch
statement): Uses theType
field 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 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 withinmessage.Message
to get the actual status details (notificationData
). Logs key fields likestatus
and the originalmessageId
. This is the primary integration point for your application logic (e.g., database updates). Returns200 OK
to SNS.UnsubscribeConfirmation
: Logs the event. Returns200 OK
.default
: Handles unexpected types. Returns400 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 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 apreHandler
hook that calls our exampleauthenticate
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 thebody
andresponse
. Fastify automatically validates incoming request bodies against thebody
schema. If validation fails, it sends a 400 error response automatically. This replaces the manual validation checks. Theresponse
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 thesnsClient.send
call, 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}$\""}