Build a Production-Ready SMS Scheduler with Fastify, AWS SNS, EventBridge, and Vonage
This guide details how to build a robust SMS scheduling application using Node.js with the Fastify framework, AWS Simple Notification Service (SNS), AWS EventBridge for scheduling, and the Vonage Communications API for sending SMS messages.
You will learn how to create an API endpoint that accepts SMS scheduling requests, leverages AWS services for reliable time-based triggering, and uses Vonage to deliver the messages. This solves the common need for sending timely notifications, reminders, or alerts without maintaining complex internal cron jobs or schedulers.
Project Overview and Goals
Goal: Create a Fastify application with two main API endpoints:
/schedule
: Accepts POST requests to schedule an SMS message for a future time./webhook/sns
: Receives notifications from AWS SNS (triggered by EventBridge) and sends the SMS via Vonage.
Problem Solved: Provides a scalable and reliable mechanism for scheduling SMS messages without managing stateful timers or cron jobs within the application itself. Leverages managed cloud services for scheduling and notifications.
Technologies Used:
- Node.js: JavaScript runtime environment.
- Fastify: High-performance Node.js web framework.
- AWS SDK for JavaScript v3: To interact with AWS services (EventBridge, SNS).
- AWS EventBridge (Scheduler): Managed service to schedule events that trigger AWS resources.
- AWS SNS (Simple Notification Service): Managed pub/sub messaging service used to decouple the scheduler from the SMS sending logic.
- Vonage SMS API: Used via
@vonage/server-sdk
to send SMS messages. - dotenv: To manage environment variables locally.
- pino-pretty: (Development Only) To format logs for readability.
System Architecture:
The system follows this flow: A user sends a request to the Fastify /schedule
endpoint. Fastify creates a one-time rule in AWS EventBridge. At the scheduled time, EventBridge triggers an AWS SNS topic. SNS sends a notification message to the Fastify /webhook/sns
endpoint. Fastify receives the notification, extracts the SMS details, and uses the Vonage API to send the SMS to the end user's phone number.
Prerequisites:
- Node.js: Version 18.x or later recommended.
- npm or yarn: Package manager for Node.js.
- AWS Account: With access to create IAM users/roles, EventBridge rules, and SNS topics. AWS Free Tier is sufficient for initial testing.
- AWS Credentials: An IAM user with programmatic access (Access Key ID and Secret Access Key) configured locally (e.g., via
~/.aws/credentials
or environment variables). See IAM Permissions section below. - Vonage Account: Sign up for a Vonage API account. You'll need your API Key, API Secret, and a purchased Vonage phone number capable of sending SMS.
- Publicly Accessible URL: Required for the Fastify server to receive SNS webhook notifications. Services like
ngrok
are excellent for local development and testing.
1. Setting up the Project
Let's initialize the project, install dependencies, and set up the basic structure.
1. Create Project Directory:
mkdir fastify-sms-scheduler
cd fastify-sms-scheduler
2. Initialize Node.js Project:
npm init -y
3. Install Dependencies:
Install production dependencies:
npm install fastify @fastify/env @aws-sdk/client-eventbridge @aws-sdk/client-sns @vonage/server-sdk dotenv
Install development dependencies:
npm install --save-dev pino-pretty
fastify
: The web framework.@fastify/env
: For loading and validating environment variables.@aws-sdk/client-eventbridge
: AWS SDK v3 client for EventBridge.@aws-sdk/client-sns
: AWS SDK v3 client for SNS.@vonage/server-sdk
: Vonage Node.js SDK.dotenv
: Loads environment variables from a.env
file for local development.pino-pretty
: (Dev Dependency) Makes Fastify's logs more readable during development.
4. Configure package.json
Scripts:
Add the following scripts to your package.json
for easier development:
// package.json
{
// ... other configurations
""scripts"": {
""start"": ""node server.js"",
""dev"": ""node server.js | npx pino-pretty""
}
// ...
}
npm start
: Runs the server normally (suitable for production).npm run dev
: Runs the server with logs piped throughpino-pretty
for better readability during development. Requirespino-pretty
to be installed (as a dev dependency).
5. Create Project Structure:
fastify-sms-scheduler/
├── node_modules/
├── routes/
│ ├── schedule.js # Handles /schedule endpoint
│ └── webhook.js # Handles /webhook/sns endpoint
├── .env # Environment variables (DO NOT COMMIT)
├── .gitignore
├── package.json
├── package-lock.json
└── server.js # Main Fastify application setup
6. Create .gitignore
:
Add node_modules
and .env
to your .gitignore
file to prevent committing them.
# .gitignore
node_modules
.env
7. Setup Environment Variables (.env
):
Create a .env
file in the project root. Remember to replace placeholders with your actual credentials and ARNs.
# .env
# AWS Configuration
# Ensure AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) are configured
# via environment variables OR ~/.aws/credentials file.
AWS_REGION=us-east-1 # Replace with your desired AWS region
SNS_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:MySmsSchedulerTopic # Replace with your SNS Topic ARN (created later)
EVENTBRIDGE_ROLE_ARN=arn:aws:iam::123456789012:role/MyEventBridgeSnsRole # Replace with your EventBridge IAM Role ARN (created later)
# Vonage Configuration
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_FROM_NUMBER=+12015550123 # Replace with your purchased Vonage number (E.164 format)
# Server Configuration
HOST=0.0.0.0
PORT=3000
LOG_LEVEL=info
# For development, set this to your ngrok URL or public IP/domain
# For production, set this to your server's public domain/IP
PUBLIC_WEBHOOK_URL=https://your-publicly-accessible-url.com # IMPORTANT! Replace with your actual public URL
Explanation of Environment Variables:
AWS_REGION
: The AWS region where your SNS topic and EventBridge rules will reside.SNS_TOPIC_ARN
: The unique identifier for the SNS topic that EventBridge will publish to.EVENTBRIDGE_ROLE_ARN
: The ARN of the IAM role that grants EventBridge permission to publish messages to your SNS topic.VONAGE_API_KEY
,VONAGE_API_SECRET
: Your Vonage API credentials found on the Vonage API Dashboard.VONAGE_FROM_NUMBER
: The Vonage virtual number you purchased, used as the sender ID for SMS messages. Ensure it's in E.164 format (e.g.,+14155552671
). Note: For US numbers, ensure you comply with 10DLC regulations.HOST
,PORT
: Network configuration for the Fastify server.LOG_LEVEL
: Controls the verbosity of logs (info
,debug
,warn
,error
).PUBLIC_WEBHOOK_URL
: The base URL where your/webhook/sns
endpoint is publicly accessible. Crucial for SNS to send notifications.
8. Basic Fastify Server Setup (server.js
):
This file initializes Fastify, loads environment variables, registers routes, and starts the server.
// server.js
'use strict';
const path = require('path');
const Fastify = require('fastify');
const fastifyEnv = require('@fastify/env');
const scheduleRoutes = require('./routes/schedule');
const webhookRoutes = require('./routes/webhook');
// --- Environment Variable Schema and Loading ---
// Defines required environment variables and their expected format/pattern
const envSchema = {
type: 'object',
required: [
'AWS_REGION',
'SNS_TOPIC_ARN',
'EVENTBRIDGE_ROLE_ARN',
'VONAGE_API_KEY',
'VONAGE_API_SECRET',
'VONAGE_FROM_NUMBER',
'PUBLIC_WEBHOOK_URL',
],
properties: {
AWS_REGION: { type: 'string' },
SNS_TOPIC_ARN: { type: 'string', pattern: '^arn:aws:sns:[^:]+:[^:]+:[^:]+' }, // Matches standard SNS ARN format
EVENTBRIDGE_ROLE_ARN: { type: 'string', pattern: '^arn:aws:iam::[^:]+:role/.+' }, // Matches standard IAM Role ARN format
VONAGE_API_KEY: { type: 'string' },
VONAGE_API_SECRET: { type: 'string' },
VONAGE_FROM_NUMBER: { type: 'string', pattern: '^\\+[1-9]\\d{1,14}' }, // E.164 format
HOST: { type: 'string', default: '0.0.0.0' },
PORT: { type: 'number', default: 3000 },
LOG_LEVEL: { type: 'string', default: 'info' },
PUBLIC_WEBHOOK_URL: { type: 'string', format: 'uri' }, // Ensures it's a valid URI
},
};
const envOptions = {
confKey: 'config', // Access environment variables via `fastify.config`
schema: envSchema, // Validate environment variables against the schema
dotenv: true, // Load .env file if present
};
// --- Fastify Instance ---
const fastify = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info', // Set log level from env or default
// Use pino-pretty only if NODE_ENV is not 'production' for readability
// The 'dev' script handles piping to pino-pretty
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
},
});
// --- Plugin Registration ---
fastify
.register(fastifyEnv, envOptions) // Load and validate environment variables
.after(err => { // Ensure env vars are loaded before proceeding
if (err) {
fastify.log.error('Environment variable validation failed:', err);
process.exit(1);
}
fastify.log.info('Environment variables loaded successfully.');
// Decorate Fastify instance with AWS/Vonage clients after config is loaded
try {
// AWS SDK Clients (v3 uses modular clients)
const { EventBridgeClient } = require('@aws-sdk/client-eventbridge');
const { SNSClient } = require('@aws-sdk/client-sns');
// Initialize EventBridge client
fastify.decorate('eventBridge', new EventBridgeClient({ region: fastify.config.AWS_REGION }));
// Initialize SNS client (Needed for signature verification or future SNS actions)
fastify.decorate('snsClient', new SNSClient({ region: fastify.config.AWS_REGION }));
fastify.log.info('AWS SDK clients initialized.');
// Vonage Client
const { Vonage } = require('@vonage/server-sdk');
const vonage = new Vonage({
apiKey: fastify.config.VONAGE_API_KEY,
apiSecret: fastify.config.VONAGE_API_SECRET,
});
fastify.decorate('vonage', vonage); // Make Vonage client available as fastify.vonage
fastify.log.info('Vonage SDK client initialized.');
} catch (sdkError) {
fastify.log.error('Failed to initialize SDK clients:', sdkError);
process.exit(1);
}
// Register Routes after plugins and decorators are ready
fastify.register(scheduleRoutes, { prefix: '/schedule' }); // Routes defined in routes/schedule.js
fastify.register(webhookRoutes, { prefix: '/webhook' }); // Routes defined in routes/webhook.js
fastify.log.info('Routes registered.');
});
// --- Health Check Route ---
// Basic endpoint to check if the server is running
fastify.get('/health', async (request, reply) => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
// --- Start Server ---
const start = async () => {
try {
// Wait for Fastify to be fully ready (plugins loaded, decorators applied)
await fastify.ready();
// Start listening for connections
await fastify.listen({ port: fastify.config.PORT, host: fastify.config.HOST });
fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
fastify.log.info(`Webhook base URL configured as: ${fastify.config.PUBLIC_WEBHOOK_URL}`);
fastify.log.warn('Ensure the /webhook/sns endpoint is publicly accessible and matches the PUBLIC_WEBHOOK_URL for SNS notifications.');
} catch (err) {
fastify.log.error('Error starting server:', err);
process.exit(1); // Exit if server fails to start
}
};
start(); // Run the server start function
This sets up the core server, loads configuration securely, initializes necessary SDK clients, and registers route handlers (which we'll create next).
2. AWS Setup (SNS, EventBridge, IAM)
Before writing the API logic, configure the necessary AWS resources. You can do this via the AWS Management Console or AWS CLI.
1. Create an SNS Topic:
- Navigate to the SNS service in the AWS Console.
- Go to ""Topics"" and click ""Create topic"".
- Choose ""Standard"" type.
- Give it a name (e.g.,
MySmsSchedulerTopic
). - Keep default settings for Access policy, Encryption, etc., for now.
- Click ""Create topic"".
- Important: Copy the Topic ARN and update the
SNS_TOPIC_ARN
value in your.env
file.
2. Create an IAM Role for EventBridge:
EventBridge needs permission to publish messages to your SNS topic.
- Navigate to the IAM service in the AWS Console.
- Go to ""Roles"" and click ""Create role"".
- For ""Trusted entity type"", select ""AWS service"".
- For ""Use case"", find and select ""EventBridge (CloudWatch Events)"". (EventBridge uses the CloudWatch Events service role). Click ""Next"".
- On the ""Add permissions"" page, click ""Create policy"". This opens a new tab.
- In the policy editor, switch to the ""JSON"" tab.
- Paste the following policy, replacing
<YOUR_SNS_TOPIC_ARN>
with the ARN you copied earlier:{ ""Version"": ""2012-10-17"", ""Statement"": [ { ""Effect"": ""Allow"", ""Action"": ""sns:Publish"", ""Resource"": ""<YOUR_SNS_TOPIC_ARN>"" } ] }
- Click ""Next: Tags"", then ""Next: Review"".
- Give the policy a name (e.g.,
EventBridgePublishToMySmsSchedulerTopicPolicy
). - Click ""Create policy"".
- Switch back to the ""Create role"" tab. Refresh the policies list and search for the policy you just created. Select it.
- Click ""Next"".
- Give the role a name (e.g.,
MyEventBridgeSnsRole
). - Review and click ""Create role"".
- Important: Find the role you just created, click on it, and copy the Role ARN. Update the
EVENTBRIDGE_ROLE_ARN
value in your.env
file.
3. Configure IAM Permissions for Your Fastify Application:
The AWS credentials used by your Fastify application (configured via environment variables or ~/.aws/credentials
) need permissions to create EventBridge rules and targets.
- Create an IAM policy (or add to an existing one used by your application's IAM user/role) with the following permissions. Replace
<YOUR_EVENTBRIDGE_ROLE_ARN>
with the Role ARN from the previous step.{ ""Version"": ""2012-10-17"", ""Statement"": [ { ""Sid"": ""AllowEventBridgeManageRules"", ""Effect"": ""Allow"", ""Action"": [ ""events:PutRule"", // To create/update the schedule rule ""events:PutTargets"", // To link the rule to the SNS topic ""events:DeleteRule"", // Optional: if you want to clean up rules ""events:RemoveTargets"" // Optional: if you want to clean up rules ], ""Resource"": ""*"" // SECURITY NOTE: For production, scope this down if possible, e.g., ""arn:aws:events:<region>:<account_id>:rule/sms-schedule-*"" }, { ""Sid"": ""AllowPassRoleToEventBridge"", ""Effect"": ""Allow"", ""Action"": ""iam:PassRole"", ""Resource"": ""<YOUR_EVENTBRIDGE_ROLE_ARN>"", // The ARN of the role created in step 2 ""Condition"": { ""StringEquals"": { ""iam:PassedToService"": ""events.amazonaws.com"" } } } // Add sns:Subscribe, sns:ConfirmSubscription, sns:Unsubscribe // if you plan to manage SNS subscriptions programmatically // from this application in the future. For this guide, we assume // manual subscription confirmation via the webhook. ] }
- Attach this policy to the IAM user or role whose credentials your Fastify application uses. The
iam:PassRole
permission is crucial for allowing EventBridge to assume the role you created. - Security Best Practice: The
Resource: ""*""
forAllowEventBridgeManageRules
is permissive. In production, it's highly recommended to restrict this to ARNs matching your rule naming convention (e.g.,arn:aws:events:us-east-1:123456789012:rule/sms-schedule-*
) to prevent the application from managing unrelated EventBridge rules.
4. Create SNS Subscription (Manual Step Later):
We will create the subscription after the webhook endpoint is running and publicly accessible. SNS needs to send a confirmation request to the endpoint URL.
/schedule
)
3. Implementing the Scheduling API (This endpoint receives the scheduling request and creates the one-time EventBridge rule.
Create routes/schedule.js
:
// routes/schedule.js
'use strict';
const { PutRuleCommand, PutTargetsCommand } = require('@aws-sdk/client-eventbridge');
const { randomUUID } = require('crypto'); // For unique rule names
// --- Request Body Schema for Validation ---
// Defines the expected structure and constraints for the POST request body
const scheduleBodySchema = {
type: 'object',
required: ['phoneNumber', 'message', 'sendAt'],
properties: {
phoneNumber: {
type: 'string',
description: 'Recipient phone number in E.164 format (e.g., +14155552671)',
// Validates the phone number against the E.164 format pattern
pattern: '^\\+[1-9]\\d{1,14}',
},
message: {
type: 'string',
description: 'The SMS message content',
minLength: 1, // Must not be empty
maxLength: 1600, // Vonage supports longer messages via concatenation
},
sendAt: {
type: 'string',
description: 'ISO 8601 timestamp for when to send the message (e.g., 2025-12-31T23:59:00Z). Must be in the future.',
format: 'date-time', // Validates ISO 8601 format
},
},
};
// --- Route Plugin ---
async function scheduleRoutes(fastify, options) {
// Define the POST /schedule endpoint with schema validation
fastify.post('/', { schema: { body: scheduleBodySchema } }, async (request, reply) => {
const { phoneNumber, message, sendAt } = request.body;
// Access decorated clients (eventBridge), config, and logger from Fastify instance
const { eventBridge, config, log } = fastify;
const scheduleTimestamp = new Date(sendAt);
const now = new Date();
// --- Input Validation ---
// Check if the requested send time is in the future
if (scheduleTimestamp <= now) {
log.warn({ body: request.body }_ 'Scheduled time is not in the future');
return reply.code(400).send({ error: 'Scheduled time must be in the future.' });
}
// EventBridge rule names must be unique and < 64 chars
// Generate a unique name using a prefix and UUID
const ruleName = `sms-schedule-${randomUUID()}`;
// EventBridge schedule expression for a specific time (one-time trigger)
// Format: at(YYYY-MM-DDTHH:MM:SS) - Requires UTC time
const scheduleExpression = `at(${scheduleTimestamp.toISOString().substring(0_ 19)})`;
// Payload to send to the SNS topic when the schedule triggers
const snsInputPayload = JSON.stringify({
phoneNumber: phoneNumber_
message: message_
// Include metadata for potential tracking/debugging in the webhook
scheduledRuleName: ruleName_
originalSendAt: sendAt_
});
log.info({ ruleName_ scheduleExpression_ phoneNumber }_ 'Attempting to create EventBridge rule');
try {
// --- Create EventBridge Rule ---
// Define parameters for the PutRule API call
const putRuleParams = {
Name: ruleName_ // Unique rule name
ScheduleExpression: scheduleExpression_ // Specific time trigger
State: 'ENABLED'_ // Ensure the rule is active
Description: `One-time schedule to send SMS to ${phoneNumber}`_
// EventBridge automatically deletes one-time schedule rules ('at()' expression)
// after they run_ so no explicit deletion logic is needed here.
};
const ruleCommand = new PutRuleCommand(putRuleParams);
// Send the command to AWS EventBridge
const ruleResponse = await eventBridge.send(ruleCommand);
log.info({ ruleName_ ruleArn: ruleResponse.RuleArn }_ 'EventBridge rule created successfully');
// --- Set Target for the Rule (SNS Topic) ---
// Define parameters to link the rule to our SNS topic
const putTargetsParams = {
Rule: ruleName_ // The rule we just created
Targets: [
{
Id: `target-${ruleName}`_ // Unique target ID for this rule
Arn: config.SNS_TOPIC_ARN_ // Target is our SNS Topic ARN from config
Input: snsInputPayload_ // Pass the SMS details as input to the target
RoleArn: config.EVENTBRIDGE_ROLE_ARN_ // Role allowing EventBridge to publish to SNS
}_
]_
};
const targetCommand = new PutTargetsCommand(putTargetsParams);
// Send the command to link the target
await eventBridge.send(targetCommand);
log.info({ ruleName_ targetId: `target-${ruleName}` }_ 'EventBridge target set successfully');
// --- Respond to Client ---
// Send a 202 Accepted response: the request is accepted_ and the SMS is scheduled
reply.code(202).send({
message: 'SMS scheduled successfully.'_
ruleName: ruleName_ // Optionally return the rule name for reference
scheduledTime: scheduleTimestamp.toISOString()_
});
} catch (error) {
log.error({ err: error_ ruleName }_ 'Error creating EventBridge rule or target');
// Basic error handling for AWS SDK errors
// Consider more specific error handling (e.g._ check AWS error codes)
// Potential enhancement: Attempt cleanup (delete rule) if target setting fails_
// though EventBridge might handle partially created resources.
reply.code(500).send({ error: 'Failed to schedule SMS due to an internal error.' });
}
});
}
module.exports = scheduleRoutes;
Explanation:
- Schema Validation: Uses Fastify's built-in schema validation to ensure
phoneNumber
_message
_ andsendAt
are provided and correctly formatted (E.164 pattern_ ISO 8601 date). - Time Validation: Checks if the requested
sendAt
time is actually in the future. - Rule Naming: Generates a unique name for the EventBridge rule using
randomUUID
. - Schedule Expression: Creates an
at()
schedule expression required by EventBridge for one-time events. Crucially_ this uses UTC time (toISOString
). - SNS Payload: Defines the JSON payload containing the phone number and message that EventBridge will send to SNS when the schedule triggers. Includes metadata like
ruleName
. PutRuleCommand
: Creates the EventBridge rule with the specific schedule time.State: 'ENABLED'
ensures it's active. One-timeat()
schedules are auto-deleted by EventBridge after execution.PutTargetsCommand
: Links the created rule to the SNS Topic ARN specified in the environment variables. It passes thesnsInputPayload
and specifies theEVENTBRIDGE_ROLE_ARN
needed for permissions.- Response: Returns a
202 Accepted
status_ indicating the request was successful and the SMS is scheduled for future delivery. - Error Handling: Includes a try-catch block to log errors during AWS SDK calls and return a 500 status.
/webhook/sns
)
4. Implementing the SNS Webhook Handler (This endpoint is crucial. It needs to handle two types of incoming POST requests from AWS SNS:
- Subscription Confirmation: When you first subscribe this endpoint to the SNS topic.
- Notification: When EventBridge triggers the SNS topic at the scheduled time_ containing the SMS details.
Create routes/webhook.js
:
// routes/webhook.js
'use strict';
const https = require('https'); // Standard Node.js module for HTTPS requests
const { Buffer } = require('buffer'); // Potentially needed for signature verification
// --- Helper function to perform HTTPS GET requests ---
// Used to confirm the SNS subscription by visiting the SubscribeURL
function httpsGet(url) {
return new Promise((resolve_ reject) => {
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
// Resolve on success status codes
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ statusCode: res.statusCode_ data: data });
} else {
reject(new Error(`HTTPS GET failed for ${url} with status ${res.statusCode}`));
}
});
}).on('error'_ (err) => {
// Reject on network errors
reject(err);
});
});
}
// --- Route Plugin ---
async function webhookRoutes(fastify, options) {
const { vonage, config, log, snsClient } = fastify; // Access decorated clients, config, logger, and snsClient
fastify.post('/sns', {
// Configure Fastify to provide the raw request body
// This is needed because SNS sends a raw JSON string, and
// signature verification (if implemented) requires the raw bytes.
config: {
rawBody: true
}
}, async (request, reply) => {
// Get the SNS message type header (e.g., 'SubscriptionConfirmation', 'Notification')
const messageType = request.headers['x-amz-sns-message-type'];
let snsMessage;
// --- Log raw headers and body for debugging ---
log.debug({ headers: request.headers, rawBody: request.rawBody?.toString() }, 'Received SNS webhook request');
// --- Parse SNS Message Body ---
try {
// SNS sends the message as a raw JSON string in the request body
if (!request.rawBody) {
throw new Error('Request body is missing');
}
// Parse the raw body string into a JavaScript object
snsMessage = JSON.parse(request.rawBody.toString());
log.info({ messageType, messageId: snsMessage.MessageId || 'N/A' }, 'Parsed SNS message');
} catch (parseError) {
log.error({ err: parseError, rawBody: request.rawBody?.toString() }, 'Failed to parse SNS message JSON');
return reply.code(400).send({ error: 'Invalid SNS message format' });
}
// --- Handle Subscription Confirmation ---
if (messageType === 'SubscriptionConfirmation') {
log.info({ subscribeURL: snsMessage.SubscribeURL }, 'Received SNS Subscription Confirmation request');
try {
// IMPORTANT SECURITY CHECK: Validate the SubscribeURL origin before fetching
const subscribeUrl = new URL(snsMessage.SubscribeURL);
// Ensure it's HTTPS and an expected AWS domain to prevent SSRF attacks
if (subscribeUrl.protocol !== 'https:' || !subscribeUrl.hostname.endsWith('.amazonaws.com')) {
log.error({ subscribeURL: snsMessage.SubscribeURL }, 'Invalid SubscribeURL domain or protocol');
return reply.code(400).send({ error: 'Invalid subscription URL' });
}
// Call the SubscribeURL provided by AWS to confirm the subscription
log.info(`Attempting to confirm subscription by visiting: ${snsMessage.SubscribeURL}`);
const confirmationResponse = await httpsGet(snsMessage.SubscribeURL);
log.info({ url: snsMessage.SubscribeURL, statusCode: confirmationResponse.statusCode }, 'Successfully confirmed SNS subscription');
// Respond with 200 OK to SNS to indicate successful confirmation
return reply.code(200).send({ message: 'Subscription confirmed' });
} catch (confirmError) {
log.error({ err: confirmError, subscribeURL: snsMessage.SubscribeURL }, 'Failed to confirm SNS subscription via SubscribeURL');
return reply.code(500).send({ error: 'Failed to confirm subscription' });
}
}
// --- Handle Notification ---
else if (messageType === 'Notification') {
log.info({ messageId: snsMessage.MessageId, subject: snsMessage.Subject }, 'Received SNS Notification');
try {
// --- [PRODUCTION CRITICAL] Verify SNS Message Signature ---
// This step is crucial to ensure the message is authentic (from AWS SNS) and unaltered.
// Implementation involves:
// 1. Getting the message fields in the correct order.
// 2. Fetching the AWS SNS public signing certificate from snsMessage.SigningCertURL (with validation).
// 3. Verifying the signature (snsMessage.Signature) using the certificate and message data.
// See AWS Docs: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html
// Example libraries: 'sns-validator', or manual implementation using Node 'crypto'.
/*
const { MessageVerifier } = require('aws-sns-message-validator'); // Example using a library
const verifier = new MessageVerifier();
try {
await verifier.validate(snsMessage); // Throws error if invalid
log.info({ messageId: snsMessage.MessageId }, 'SNS message signature verified successfully.');
} catch (verificationError) {
log.warn({ messageId: snsMessage.MessageId, err: verificationError }, 'Invalid SNS message signature');
return reply.code(403).send({ error: 'Invalid signature or verification failed' });
}
*/
// --- Signature Verification Logic Placeholder End ---
// --- Parse the inner message payload ---
// The actual data sent by EventBridge is nested within the 'Message' field of the SNS notification
const payload = JSON.parse(snsMessage.Message);
const { phoneNumber, message, scheduledRuleName } = payload; // Extract details
// Validate required fields in the payload
if (!phoneNumber || !message) {
log.error({ payload, messageId: snsMessage.MessageId }, 'Missing phoneNumber or message in SNS notification payload');
return reply.code(400).send({ error: 'Invalid notification payload content' });
}
log.info({ phoneNumber, scheduledRuleName, messageId: snsMessage.MessageId }, 'Processing scheduled SMS via Vonage');
// --- Send SMS using Vonage ---
// Use the decorated Vonage client (fastify.vonage)
await vonage.sms.send({
to: phoneNumber, // Recipient number from payload
from: config.VONAGE_FROM_NUMBER, // Sender number from config
text: message, // Message content from payload
});
log.info({ phoneNumber, scheduledRuleName, messageId: snsMessage.MessageId }, 'SMS sent successfully via Vonage.');
// Respond 200 OK to SNS to acknowledge receipt and processing
return reply.code(200).send({ message: 'Notification processed and SMS sent' });
} catch (processError) {
log.error({ err: processError, messageId: snsMessage.MessageId }, 'Error processing SNS notification or sending SMS');
// Respond with 500 to indicate failure; SNS may retry based on its policy
return reply.code(500).send({ error: 'Failed to process notification' });
}
}
// --- Handle Unknown Message Type ---
else {
log.warn({ messageType, messageId: snsMessage.MessageId || 'N/A' }, 'Received unknown SNS message type');
// Respond politely but indicate it wasn't processed as expected
return reply.code(400).send({ error: `Unsupported message type: ${messageType}` });
}
});
}
module.exports = webhookRoutes;