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:
Bash
mkdir fastify-sms-scheduler
cd fastify-sms-scheduler
npm start: Runs the server normally (suitable for production).
npm run dev: Runs the server with logs piped through pino-pretty for better readability during development. Requires pino-pretty to be installed (as a dev dependency).
Add node_modules and .env to your .gitignore file to prevent committing them.
Text
# .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.
ini
# .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 regionSNS_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 ConfigurationVONAGE_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 ConfigurationHOST=0.0.0.0PORT=3000LOG_LEVEL=info
# For development, set this to your ngrok URL or public IP/domain# For production, set this to your server's public domain/IPPUBLIC_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.
JavaScript
// server.js'use strict';
const path = require('path');
constFastify = 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/patternconst 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 formatEVENTBRIDGE_ROLE_ARN: { type: 'string', pattern: '^arn:aws:iam::[^:]+:role/.+' }, // Matches standard IAM Role ARN formatVONAGE_API_KEY: { type: 'string' },
VONAGE_API_SECRET: { type: 'string' },
VONAGE_FROM_NUMBER: { type: 'string', pattern: '^\\+[1-9]\\d{1,14}' }, // E.164 formatHOST: { 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 schemadotenv: 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-prettytransport: 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 proceedingif (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 loadedtry {
// 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', newEventBridgeClient({ region: fastify.config.AWS_REGION }));
// Initialize SNS client (Needed for signature verification or future SNS actions)
fastify.decorate('snsClient', newSNSClient({ region: fastify.config.AWS_REGION }));
fastify.log.info('AWS SDK clients initialized.');
// Vonage Clientconst { Vonage } = require('@vonage/server-sdk');
const vonage = newVonage({
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: newDate().toISOString() };
});
// --- Start Server ---conststart = async () => {
try {
// Wait for Fastify to be fully ready (plugins loaded, decorators applied)await fastify.ready();
// Start listening for connectionsawait 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:
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.
JSON
{""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: ""*"" for AllowEventBridgeManageRules 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.
3. Implementing the Scheduling API (/schedule)
This endpoint receives the scheduling request and creates the one-time EventBridge rule.
Create routes/schedule.js:
JavaScript
// 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 bodyconst 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 patternpattern: '^\\+[1-9]\\d{1,14}',
},
message: {
type: 'string',
description: 'The SMS message content',
minLength: 1, // Must not be emptymaxLength: 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 ---asyncfunctionscheduleRoutes(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 instanceconst { eventBridge, config, log } = fastify;
const scheduleTimestamp = newDate(sendAt);
const now = newDate();
// --- Input Validation ---// Check if the requested send time is in the futureif (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 UUIDconst ruleName = `sms-schedule-${randomUUID()}`;
// EventBridge schedule expression for a specific time (one-time trigger)// Format: at(YYYY-MM-DDTHH:MM:SS) - Requires UTC timeconst scheduleExpression = `at(${scheduleTimestamp.toISOString().substring(0, 19)})`;
// Payload to send to the SNS topic when the schedule triggersconst snsInputPayload = JSON.stringify({
phoneNumber: phoneNumber,
message: message,
// Include metadata for potential tracking/debugging in the webhookscheduledRuleName: ruleName,
originalSendAt: sendAt,
});
log.info({ ruleName, scheduleExpression, phoneNumber }, 'Attempting to create EventBridge rule');
try {
// --- Create EventBridge Rule ---// Define parameters for the PutRule API callconst putRuleParams = {
Name: ruleName, // Unique rule nameScheduleExpression: scheduleExpression, // Specific time triggerState: 'ENABLED', // Ensure the rule is activeDescription: `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 = newPutRuleCommand(putRuleParams);
// Send the command to AWS EventBridgeconst 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 topicconst putTargetsParams = {
Rule: ruleName, // The rule we just createdTargets: [
{
Id: `target-${ruleName}`, // Unique target ID for this ruleArn: config.SNS_TOPIC_ARN, // Target is our SNS Topic ARN from configInput: snsInputPayload, // Pass the SMS details as input to the targetRoleArn: config.EVENTBRIDGE_ROLE_ARN, // Role allowing EventBridge to publish to SNS
},
],
};
const targetCommand = newPutTargetsCommand(putTargetsParams);
// Send the command to link the targetawait 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 referencescheduledTime: 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, and sendAt 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-time at() 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 the snsInputPayload and specifies the EVENTBRIDGE_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.
4. Implementing the SNS Webhook Handler (/webhook/sns)
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:
JavaScript
// routes/webhook.js'use strict';
const https = require('https'); // Standard Node.js module for HTTPS requestsconst { Buffer } = require('buffer'); // Potentially needed for signature verification// --- Helper function to perform HTTPS GET requests ---// Used to confirm the SNS subscription by visiting the SubscribeURLfunctionhttpsGet(url) {
returnnewPromise((resolve, reject) => {
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
// Resolve on success status codesif (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ statusCode: res.statusCode, data: data });
} else {
reject(newError(`HTTPS GET failed for ${url} with status ${res.statusCode}`));
}
});
}).on('error', (err) => {
// Reject on network errorsreject(err);
});
});
}
// --- Route Plugin ---asyncfunctionwebhookRoutes(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 bodyif (!request.rawBody) {
thrownewError('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 fetchingconst subscribeUrl = newURL(snsMessage.SubscribeURL);
// Ensure it's HTTPS and an expected AWS domain to prevent SSRF attacksif (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 = awaithttpsGet(snsMessage.SubscribeURL);
log.info({ url: snsMessage.SubscribeURL, statusCode: confirmationResponse.statusCode }, 'Successfully confirmed SNS subscription');
// Respond with 200 OK to SNS to indicate successful confirmationreturn 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 ---elseif (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 notificationconst payload = JSON.parse(snsMessage.Message);
const { phoneNumber, message, scheduledRuleName } = payload; // Extract details// Validate required fields in the payloadif (!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 payloadfrom: config.VONAGE_FROM_NUMBER, // Sender number from configtext: 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 processingreturn 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 policyreturn 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 expectedreturn reply.code(400).send({ error: `Unsupported message type: ${messageType}` });
}
});
}
module.exports = webhookRoutes;
Frequently asked questions
You schedule SMS messages using EventBridge by creating a one-time rule with an "at" schedule expression specifying the desired send time in UTC. This rule targets an AWS SNS topic, which then triggers your application's webhook to send the SMS via Vonage. The application uses the AWS SDK to interact with EventBridge and create these scheduled rules.
AWS SNS acts as a decoupling mechanism between the scheduler (EventBridge) and the SMS sending logic. When the scheduled time arrives, EventBridge publishes a message to the designated SNS topic. This message contains the SMS details (phone number, message content), which are then forwarded to your application's webhook for processing and delivery via Vonage. This architecture enhances reliability.
Fastify is a high-performance Node.js web framework chosen for its speed and efficiency. It provides a robust foundation for building the API endpoints needed to handle scheduling requests and process incoming webhook notifications from AWS SNS. Its plugin architecture and schema validation capabilities also contribute to a more organized and maintainable codebase.
Ngrok is beneficial during development and testing when your local Fastify server isn't publicly accessible. Ngrok creates a secure tunnel, providing a public URL that AWS SNS can use to deliver webhook notifications to your locally running server, enabling efficient local development and testing with AWS services.
The provided example demonstrates one-time SMS scheduling using the 'at' expression in EventBridge. For recurring schedules, you'd modify the EventBridge rule to use a 'cron' or 'rate' expression instead of 'at', specifying the desired recurrence pattern. The rest of the architecture (SNS, webhook, Vonage integration) would remain the same.
You'll need a Node.js environment, an AWS account with access to EventBridge and SNS, AWS credentials configured for your application, a Vonage account with a purchased phone number, and a publicly accessible URL for the webhook (ngrok for development, public domain/IP for production). Also, necessary Node packages and AWS resource setup are required.
The application validates the provided 'sendAt' timestamp to ensure it's in the future. If the timestamp is in the past, the scheduling request is rejected with a 400 Bad Request error, preventing attempts to schedule messages for past times.
The PUBLIC_WEBHOOK_URL is essential for AWS SNS to deliver notifications to your Fastify application. It specifies the publicly accessible base URL where your '/webhook/sns' endpoint resides. This URL is used by SNS to send subscription confirmations and notification messages containing SMS details for processing.
Securing the webhook requires verifying the message signature to ensure authenticity and prevent unauthorized requests. This involves verifying the signature against AWS's public key. While not fully implemented in the example, it's crucial for production to confirm the message originates from AWS SNS.
AWS credentials (AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY) are required for the application to interact with AWS services. These can be configured as environment variables or placed in the standard ~/.aws/credentials file. Ensure these credentials have the necessary IAM permissions to manage EventBridge rules, targets, and interact with your SNS topic.
You'll need your Vonage API Key, API Secret, and a Vonage phone number capable of sending SMS. These should be obtained from your Vonage API Dashboard and set in the .env file to allow the application to send SMS messages.
You can use any AWS region that supports SNS and EventBridge. The chosen region should ideally be close to your target audience for lower latency and should be consistent throughout your AWS configuration. Specify your chosen region in the AWS_REGION environment variable.
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:
@vonage/server-sdkto send SMS messages.System Architecture:
The system follows this flow: A user sends a request to the Fastify
/scheduleendpoint. 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/snsendpoint. 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:
~/.aws/credentialsor environment variables). See IAM Permissions section below.ngrokare 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-scheduler2. Initialize Node.js Project:
3. Install Dependencies:
Install production dependencies:
Install development dependencies:
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.envfile for local development.pino-pretty: (Dev Dependency) Makes Fastify's logs more readable during development.4. Configure
package.jsonScripts:Add the following scripts to your
package.jsonfor 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-prettyfor better readability during development. Requirespino-prettyto be installed (as a dev dependency).5. Create Project Structure:
6. Create
.gitignore:Add
node_modulesand.envto your.gitignorefile to prevent committing them.7. Setup Environment Variables (
.env):Create a
.envfile 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 URLExplanation 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/snsendpoint 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 functionThis 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:
MySmsSchedulerTopic).SNS_TOPIC_ARNvalue in your.envfile.2. Create an IAM Role for EventBridge:
EventBridge needs permission to publish messages to your SNS topic.
<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>"" } ] }EventBridgePublishToMySmsSchedulerTopicPolicy).MyEventBridgeSnsRole).EVENTBRIDGE_ROLE_ARNvalue in your.envfile.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.<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. ] }iam:PassRolepermission is crucial for allowing EventBridge to assume the role you created.Resource: ""*""forAllowEventBridgeManageRulesis 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.
3. Implementing the Scheduling API (
/schedule)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:
phoneNumber,message, andsendAtare provided and correctly formatted (E.164 pattern, ISO 8601 date).sendAttime is actually in the future.randomUUID.at()schedule expression required by EventBridge for one-time events. Crucially, this uses UTC time (toISOString).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 thesnsInputPayloadand specifies theEVENTBRIDGE_ROLE_ARNneeded for permissions.202 Acceptedstatus, indicating the request was successful and the SMS is scheduled for future delivery.4. Implementing the SNS Webhook Handler (
/webhook/sns)This endpoint is crucial. It needs to handle two types of incoming POST requests from AWS SNS:
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;