code examples
code examples
Build a Production-Ready SMS Scheduler with Fastify, AWS, and Vonage
A guide to creating an SMS scheduling application using Fastify, AWS SNS, EventBridge, and the Vonage API for reliable message delivery.
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-sdkto 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/credentialsor 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
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:
npm init -y3. Install Dependencies:
Install production dependencies:
npm install fastify @fastify/env @aws-sdk/client-eventbridge @aws-sdk/client-sns @vonage/server-sdk dotenvInstall development dependencies:
npm install --save-dev pino-prettyfastify: 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.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-prettyfor better readability during development. Requirespino-prettyto 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 setup6. Create .gitignore:
Add node_modules and .env to your .gitignore file to prevent committing them.
# .gitignore
node_modules
.env7. 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 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:
- 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_ARNvalue in your.envfile.
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:json{ ""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_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.
- 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:PassRolepermission is crucial for allowing EventBridge to assume the role you created. - Security Best Practice: The
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:
- Schema Validation: Uses Fastify's built-in schema validation to ensure
phoneNumber,message, andsendAtare provided and correctly formatted (E.164 pattern, ISO 8601 date). - Time Validation: Checks if the requested
sendAttime 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 thesnsInputPayloadand specifies theEVENTBRIDGE_ROLE_ARNneeded for permissions.- Response: Returns a
202 Acceptedstatus, 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:
// 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;Frequently Asked Questions
How to schedule SMS messages with AWS EventBridge?
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.
What is the role of AWS SNS in SMS scheduling?
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.
Why use Fastify for building an SMS scheduler?
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.
When should I use a service like ngrok for my SMS scheduler?
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.
Can I schedule recurring SMS messages with this setup?
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.
What are the prerequisites for setting up this SMS scheduler?
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.
How does the SMS scheduler handle future scheduled times?
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.
What is the purpose of the PUBLIC_WEBHOOK_URL environment variable?
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.
How to secure the SNS webhook endpoint in production?
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.
How to set up AWS credentials for the Fastify app?
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.
What Vonage API credentials are needed?
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.
What AWS region should I use for my SNS topic?
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.