This guide provides a step-by-step walkthrough for building a simple API endpoint using Node.js and the Fastify framework to send SMS messages via Amazon Simple Notification Service (SNS). We will cover everything from project setup and AWS configuration to implementing the core logic, handling errors, and testing the endpoint.
By the end of this tutorial, you will have a functional Fastify application with a single API endpoint (POST /sms
) capable of sending text messages to valid phone numbers. This serves as a foundational building block for applications requiring SMS notifications, OTP verification, or other text-based communication features.
Technologies Used:
- Node.js: JavaScript runtime environment.
- Fastify: A fast and low-overhead web framework for Node.js.
- AWS SDK for JavaScript v3: To interact with AWS services, specifically SNS.
- AWS SNS (Simple Notification Service): A managed messaging service for sending messages, including SMS.
- AWS IAM (Identity and Access Management): To securely manage access to AWS services.
- dotenv: To manage environment variables.
System Architecture:
+-------------+ +-----------------+ +-------------------+ +--------------+
| | HTTP | | SDK Call| | SMS | |
| User/Client |------>| Fastify API App |-------->| AWS SNS |---->| Phone Number |
| (e.g. curl) | POST | (Node.js) | | (SMS Capability) | | |
| | /sms | | | | | |
+-------------+ +--------+--------+ +-------------------+ +--------------+
|
| Reads Credentials
v
+---------------------+
| Environment Variables |
| (.env file) |
+---------------------+
Prerequisites:
- Node.js (v18 or later recommended) and npm (or yarn) installed.
- An AWS account. You can leverage the AWS Free Tier for initial usage.
- Basic familiarity with Node.js, APIs, and the command line.
1. Project Setup
Let's initialize our Node.js project and install the necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir fastify-sns-sms cd fastify-sns-sms
-
Initialize npm Project: This command creates a
package.json
file to manage project dependencies and metadata.npm init -y
-
Install Dependencies: We need Fastify for the web server, the AWS SDK v3 client for SNS, and
dotenv
to handle environment variables securely.npm install fastify @aws-sdk/client-sns dotenv
-
Create Project Structure: Create the necessary files for our application.
touch server.js sns.service.js .env .gitignore
server.js
: Will contain our Fastify application code.sns.service.js
: Will contain the logic for interacting with AWS SNS..env
: Will store sensitive AWS credentials and configuration (DO NOT commit this file)..gitignore
: Specifies intentionally untracked files that Git should ignore.
-
Configure
.gitignore
: Addnode_modules
and.env
to your.gitignore
file to prevent committing dependencies and sensitive credentials.# .gitignore node_modules .env
This establishes the basic structure and installs the core tools needed for our application.
2. AWS Configuration: IAM User and Credentials
To allow our Node.js application to interact with AWS SNS securely, we need to create an IAM user with specific permissions and obtain its access keys.
-
Log in to AWS Console: Access your AWS Management Console.
-
Navigate to IAM: Use the search bar at the top to find and navigate to the IAM service (Identity and Access Management).
-
Create IAM User:
- In the IAM dashboard, click on ""Users"" in the left-hand navigation pane.
- Click the ""Create user"" button.
- User details: Enter a descriptive ""User name"" (e.g.,
fastify-sms-sender
). Do not check ""Provide user access to the AWS Management Console"" - this user only needs programmatic access. Click ""Next"". - Set permissions: Select ""Attach policies directly"".
- In the ""Permissions policies"" search box, type
AmazonSNSFullAccess
. - Check the box next to
AmazonSNSFullAccess
. Click ""Next"". - Security Best Practice: While
AmazonSNSFullAccess
is used here for simplicity, it grants broad permissions (including deleting topics, managing subscriptions, etc.). For production environments, strongly consider creating a custom IAM policy that grants only thesns:Publish
permission. This adheres to the principle of least privilege and reduces potential security risks. - Review and create: Review the user details and permissions. Click ""Create user"".
-
Retrieve Access Keys:
- Crucial Step: On the success screen after user creation, click on the username you just created (e.g.,
fastify-sms-sender
). - Navigate to the ""Security credentials"" tab.
- Scroll down to the ""Access keys"" section and click ""Create access key"".
- Use case: Select ""Command Line Interface (CLI)"". This is suitable even though we're using the SDK, as it signifies programmatic access outside the console.
- Acknowledge the recommendation for alternative access methods by checking the confirmation box. Click ""Next"".
- Description tag (Optional): Add a tag if desired (e.g.,
fastify-sms-app
). Click ""Create access key"". - Retrieve keys: You will now see the Access key ID and the Secret access key. This is the only time the Secret access key will be shown. Copy both values immediately and store them securely. You can also download the
.csv
file which contains these keys.
- Crucial Step: On the success screen after user creation, click on the username you just created (e.g.,
-
Configure Environment Variables: Open the
.env
file you created earlier and add the retrieved credentials and your desired AWS region. Important: SNS SMS functionality might be region-specific.us-east-1
(N. Virginia) is often a reliable choice for global SMS sending, but check the latest AWS documentation for supported regions.# .env AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY_ID_HERE AWS_SECRET_ACCESS_KEY=YOUR_SECRET_ACCESS_KEY_HERE AWS_REGION=us-east-1 # Or your preferred SNS-supported region
Replace
YOUR_ACCESS_KEY_ID_HERE
andYOUR_SECRET_ACCESS_KEY_HERE
with the actual values you copied.Security: The
.env
file keeps your sensitive credentials out of your source code. Ensure it's listed in your.gitignore
file to prevent accidental commits.
3. Implementing the Fastify Server and API Endpoint
Now, let's write the code for our Fastify server and the /sms
endpoint.
server.js
:
// server.js
'use strict';
// Load environment variables from .env file
require('dotenv').config();
// Import Fastify
const fastify = require('fastify')({
logger: true // Enable Fastify's built-in Pino logger
});
// Import the SNS service utility
const { sendSms } = require('./sns.service');
// --- API Endpoint Definition ---
// Define the schema for request body validation
const sendSmsSchema = {
body: {
type: 'object',
required: ['phoneNumber', 'message'],
properties: {
phoneNumber: {
type: 'string',
// Basic E.164 format validation
// E.164 format: [+] [country code] [subscriber number including area code]
// Example: +12065551212
pattern: '^\\+[1-9]\\d{1,14}$' // Regex includes start/end anchors
},
message: {
type: 'string',
minLength: 1,
maxLength: 160 // Standard SMS length limit (can vary)
}
}
},
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
messageId: { type: 'string' },
message: { type: 'string' }
}
},
400: { // Validation or expected client error (like OptedOut)
type: 'object',
properties: {
statusCode: { type: 'number' },
error: { type: 'string' },
message: { type: 'string' }
}
},
429: { // Throttling Error
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' }
}
},
500: { // Server/SNS Error
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' } // Include specific error details
}
}
}
};
// Register the POST /sms route
fastify.post('/sms', { schema: sendSmsSchema }, async (request, reply) => {
const { phoneNumber, message } = request.body;
// Use request-bound logger for context
const log = request.log;
log.info(`Attempting to send SMS to ${phoneNumber}`);
try {
// Pass the logger to the service if you want service-level logging tied to requests
// const result = await sendSms(phoneNumber, message, log);
const result = await sendSms(phoneNumber, message); // Calling without logger for now
// sendSms throws on error, so if we get here, it was successful.
log.info(`SNS Publish successful. Message ID: ${result.messageId}`);
return reply.code(200).send({
success: true,
messageId: result.messageId,
message: 'SMS sent successfully.'
});
} catch (error) {
log.error({ err: error }, `Error sending SMS via SNS: ${error.message}`);
// Customize error response based on potential AWS SDK errors
let statusCode = 500;
let errorMessage = 'Failed to send SMS due to an unexpected server error.';
let specificError = error.name || error.message || 'Unknown error'; // Prefer error.name
// Check for common AWS SDK error types
if (error.name === 'InvalidParameterException') {
statusCode = 400; // Bad request due to invalid phone number format likely
errorMessage = 'Failed to send SMS due to invalid parameters (e.g., phone number format).';
} else if (error.name === 'AuthorizationErrorException') {
statusCode = 500; // Internal server error from our side (config issue)
errorMessage = 'Failed to send SMS due to authorization error. Check AWS credentials and permissions.';
} else if (error.name === 'ThrottlingException') {
statusCode = 429; // Too Many Requests
errorMessage = 'SMS sending throttled. Please try again later.';
} else if (error.name === 'PhoneNumberOptedOutException') {
statusCode = 400; // Bad Request - can't send to opted-out number
errorMessage = 'Cannot send SMS because the phone number has opted out.';
}
// Add more specific error handling as needed based on observed AWS errors
return reply.code(statusCode).send({
success: false,
message: errorMessage,
error: specificError // Provide the specific AWS error name/message
});
}
});
// --- Start the Server ---
const start = async () => {
try {
const port = process.env.PORT || 3000;
await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces
// Logger automatically outputs server start info if logger:true
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Explanation:
- Environment Variables:
require('dotenv').config()
loads variables from the.env
file intoprocess.env
. - Fastify Instance: We create a Fastify instance with logging enabled (
logger: true
). Fastify uses Pino for high-performance logging. Each request gets its own logger instance (request.log
). - Import Service: We import the
sendSms
function fromsns.service.js
. - Schema Definition:
sendSmsSchema
defines the expected structure of the request body (phoneNumber
,message
) and potential responses (200, 400, 429, 500). Fastify uses this schema for automatic request validation and response serialization.- The
pattern
forphoneNumber
enforces the E.164 format. maxLength
formessage
sets a typical SMS limit.
- The
- Route Definition:
fastify.post('/sms', { schema: sendSmsSchema }, ...)
defines the endpoint:- It listens for
POST
requests on the/sms
path. - It applies the
sendSmsSchema
for validation. - The
async (request, reply)
handler extracts the validatedphoneNumber
andmessage
fromrequest.body
.
- It listens for
- Service Call: It calls
sendSms(phoneNumber, message)
within atry...catch
block. - Success Response: If
sendSms
completes without throwing, it logs the success using the request logger (log.info
) and returns a 200 response with themessageId
from SNS. - Error Handling:
- The
catch
block logs the error in detail usinglog.error
. - It attempts to classify common AWS SDK errors (
InvalidParameterException
,AuthorizationErrorException
,ThrottlingException
,PhoneNumberOptedOutException
) to return more specific HTTP status codes (400, 500, 429) and user-friendly error messages. - A generic 500 error is returned for unexpected issues.
- The
- Server Start: The
start
function listens on the specified port (defaulting to 3000) and host0.0.0.0
. Fastify's logger automatically logs the listening address.
4. Implementing the SNS Service Logic
Let's create the service file that handles the interaction with AWS SNS using the AWS SDK v3.
sns.service.js
:
// sns.service.js
'use strict';
const { SNSClient, PublishCommand } = require('@aws-sdk/client-sns');
// Ensure AWS credentials and region are loaded from environment variables
const snsClient = new SNSClient({
region: process.env.AWS_REGION // Loaded via dotenv in server.js
// Credentials are automatically picked up from environment variables:
// AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
});
/**
* Sends an SMS message using AWS SNS.
* Note: For better logging context, consider passing the Fastify request logger instance.
*
* @param {string} phoneNumber - The recipient's phone number in E.164 format (e.g., +12065551212).
* @param {string} message - The text message content.
* @returns {Promise<{success: boolean_ messageId: string}>} - Promise resolving with success status and MessageId.
* @throws {Error} Throws AWS SDK errors or other exceptions if the publish fails.
*/
const sendSms = async (phoneNumber, message) => {
// Construct parameters for the SNS publish command
const params = {
Message: message, /* required */
PhoneNumber: phoneNumber, /* required */
MessageAttributes: {
'AWS.SNS.SMS.SMSType': { // Optional: Set message type (Promotional or Transactional)
DataType: 'String',
StringValue: 'Transactional' // Or 'Promotional'. Transactional has higher deliverability, potentially higher cost.
}
// 'AWS.SNS.SMS.SenderID': { // Optional: Set a custom Sender ID (requires registration in some countries)
// DataType: 'String',
// StringValue: 'MyCompany'
// }
}
};
// Create the command object
const command = new PublishCommand(params);
try {
// Send the command to SNS
const data = await snsClient.send(command);
// Success! Log (using console here, consider passing logger) and return MessageId
// Using console for simplicity, but Fastify logger passed from handler is better practice.
console.log(`SNS Publish Success. MessageID: ${data.MessageId}`);
return { success: true, messageId: data.MessageId };
} catch (err) {
// Log the error details (using console here)
console.error(`SNS Publish Error: ${err.name} - ${err.message}`);
// Re-throw the error to be handled by the calling route handler in server.js
throw err;
}
};
module.exports = {
sendSms
};
Explanation:
- Import SDK: We import
SNSClient
andPublishCommand
from@aws-sdk/client-sns
. - Create SNS Client: An instance of
SNSClient
is created, reading the region from environment variables. Credentials are automatically sourced by the SDK. sendSms
Function:- Takes
phoneNumber
andmessage
as arguments. - Parameters: Creates the
params
object forPublishCommand
, includingMessage
,PhoneNumber
, and optionalMessageAttributes
likeSMSType
. - Command: Creates an instance of
PublishCommand
. - Send: Uses
snsClient.send(command)
within atry...catch
block. - Success: If
send
succeeds, it logs theMessageId
(usingconsole.log
here for simplicity) and returns{ success: true, messageId: data.MessageId }
. - Error: If
snsClient.send()
fails, it catches the error, logs it (usingconsole.error
), and crucially re-throws it (throw err
). This ensures the error is handled by thecatch
block in theserver.js
route handler. - Logging Note: Using
console.log
/error
here works but lacks request context. Passing the Fastify logger (request.log
) from the route handler into this function is a better practice for production logging.
- Takes
5. Running and Testing the Application
Now, let's run the server and test the /sms
endpoint.
-
Run the Server: Open your terminal in the project directory (
fastify-sns-sms
) and run:node server.js
You should see output indicating the server is listening, similar to:
{""level"":30,""time"":1678886400000,""pid"":12345,""hostname"":""your-machine"",""msg"":""Server listening at http://127.0.0.1:3000""}
(Note: The exact log format and timestamp come from Fastify's default Pino logger).
-
Test with
curl
: Open another terminal window. Usecurl
(or any API client like Postman) to send a POST request to your running server. Replace+1XXXXXXXXXX
with a valid phone number in E.164 format that you can receive SMS on.curl -X POST http://localhost:3000/sms \ -H ""Content-Type: application/json"" \ -d '{ ""phoneNumber"": ""+1XXXXXXXXXX"", ""message"": ""Hello from Fastify and AWS SNS!"" }'
-
Check Responses:
-
Success: If everything is configured correctly, you should receive an SMS on the specified phone number shortly. The
curl
command will return a success response:{ ""success"": true, ""messageId"": ""xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"", ""message"": ""SMS sent successfully."" }
You will also see log messages in the server terminal confirming the request and success.
-
Validation Error (e.g., bad phone number format):
# Example with invalid phone number curl -X POST http://localhost:3000/sms \ -H ""Content-Type: application/json"" \ -d '{ ""phoneNumber"": ""12345"", ""message"": ""Test message"" }'
Response:
{ ""statusCode"": 400, ""error"": ""Bad Request"", ""message"": ""body/phoneNumber must match pattern \""^\\\\+[1-9]\\\\d{1,14}$\"""" }
-
SNS Error (e.g., Opted-Out Number): If you try sending to a number that has previously replied ""STOP"" to messages from AWS SNS: Response (might vary slightly based on SDK version/error handling):
{ ""success"": false, ""message"": ""Cannot send SMS because the phone number has opted out."", ""error"": ""PhoneNumberOptedOutException"" }
-
Server Error (e.g., incorrect AWS credentials): Response:
{ ""success"": false, ""message"": ""Failed to send SMS due to authorization error. Check AWS credentials and permissions."", ""error"": ""AuthorizationErrorException"" }
Check the server logs for more detailed error information in case of 500 errors.
-
6. Troubleshooting and Caveats
- AWS Region: Ensure the
AWS_REGION
in your.env
file supports SNS SMS sending and matches the region where you might have configured any specific SNS settings (like Sender IDs).us-east-1
is generally a safe bet for starting. - IAM Permissions: Double-check that the IAM user has the necessary
sns:Publish
permission. If usingAmazonSNSFullAccess
for testing, remember to switch to a least-privilege custom policy for production. - Credentials: Verify that
AWS_ACCESS_KEY_ID
andAWS_SECRET_ACCESS_KEY
in.env
are correct and have no extra spaces or characters. Ensure the.env
file is being loaded correctly (e.g., check for typos inrequire('dotenv').config()
). - E.164 Format: Phone numbers must be in the E.164 format (
+
followed by country code and number, e.g.,+14155552671
,+442071838750
). Incorrect formatting is a common cause ofInvalidParameterException
. - Opted-Out Numbers: Users can opt out of receiving SMS messages from AWS SNS by replying ""STOP"". Subsequent attempts to send to that number will result in a
PhoneNumberOptedOutException
. AWS provides mechanisms to list and manage opt-outs (seeListPhoneNumbersOptedOutCommand
in the SDK). - AWS Service Limits & Throttling: AWS imposes limits on the rate and volume of SMS messages you can send. Exceeding these can lead to
ThrottlingException
. Check AWS documentation for current limits and consider requesting limit increases if necessary. Transactional messages generally have higher throughput limits than Promotional ones. - Sender ID Restrictions: Using custom Sender IDs (
AWS.SNS.SMS.SenderID
attribute) is subject to country-specific regulations. Some countries require pre-registration. Using an unregistered Sender ID can lead to message failure or filtering. - Cost: Sending SMS messages via SNS incurs costs. Review the AWS SNS pricing page for details specific to your target countries and message type (Transactional vs. Promotional).
7. Security Considerations
- Credentials Management: Never commit your
.env
file or hardcode AWS credentials directly in your source code. Use environment variables and ensure.gitignore
is correctly configured. Consider more robust secrets management solutions (like AWS Secrets Manager or HashiCorp Vault) for production environments. - Input Validation: The Fastify schema validation is crucial for preventing invalid data from reaching the SNS service and for basic security against malformed requests. Ensure your patterns (like the E.164 regex) are sufficiently strict for your needs.
- Rate Limiting: In a production scenario, implement rate limiting on your API endpoint (e.g., using
fastify-rate-limit
plugin) to prevent abuse and manage costs. - IAM Least Privilege: As mentioned in Section 2, always prefer a custom IAM policy granting only the
sns:Publish
permission over the broadAmazonSNSFullAccess
policy for production applications.
8. Deployment Considerations
- Environment Variables: When deploying to platforms like AWS EC2, Elastic Beanstalk, ECS, Lambda, or others (Heroku, Vercel, etc.), ensure you configure the
AWS_ACCESS_KEY_ID
,AWS_SECRET_ACCESS_KEY
, andAWS_REGION
environment variables securely within the deployment environment's configuration settings. Do not deploy the.env
file itself. - Serverless (AWS Lambda): Fastify can be adapted for serverless environments like AWS Lambda using adapters like
@fastify/aws-lambda
. Refer to the Fastify Serverless documentation for specific instructions. Ensure the Lambda execution role hassns:Publish
permissions. - Containerization (Docker): You can easily containerize this application using Docker for deployment on platforms like AWS Fargate, EKS, or Google Cloud Run. Remember to pass environment variables securely to the container.
Conclusion
You have successfully built a Node.js application using Fastify that integrates with AWS SNS to send SMS messages via a simple API endpoint. This guide covered project setup, AWS IAM configuration, implementing the API route with validation, handling the SNS interaction, basic error handling, and testing.
This forms a solid foundation. Potential next steps include:
- Implementing more sophisticated error handling and retry mechanisms (e.g., exponential backoff for throttling errors).
- Adding monitoring and alerting (e.g., using AWS CloudWatch).
- Integrating unit and integration tests (mocking the AWS SDK).
- Implementing robust rate limiting at the API gateway or application level.
- Exploring advanced SNS features like topics for sending to multiple subscribers.
- Refactoring logging to consistently use the Fastify logger, potentially by passing
request.log
to service functions.