code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / Article

Building Scalable Marketing Campaigns with Fastify and AWS SNS

A guide on creating a Node.js API using Fastify and AWS SNS for managing and sending marketing messages (SMS/email) at scale.

Target Audience: Developers familiar with Node.js and REST APIs, looking to build a scalable system for sending marketing messages (primarily SMS and potentially email) via AWS SNS using the Fastify framework.

Prerequisites:

  • Node.js (v18 or later recommended)
  • npm or yarn
  • An AWS account with permissions to manage SNS and IAM.
  • Basic familiarity with command-line/terminal usage.
  • Optional: Docker for containerization, Postman or curl for API testing.

Building Scalable Marketing Campaigns with Fastify and AWS SNS

This guide details how to build a robust API using Fastify to manage and send marketing messages through AWS Simple Notification Service (SNS). We'll cover everything from project setup and core SNS interactions to API design, security, deployment, and monitoring.

Project Goals:

  • Create a Fastify API to manage SNS topics relevant to marketing campaigns.
  • Implement functionality to subscribe users (via SMS or email) to these topics.
  • Enable sending bulk messages to subscribed users via SNS topics.
  • Enable sending direct SMS messages for targeted communications.
  • Ensure the system is secure, scalable, handles errors gracefully, and is ready for production deployment.

Why these technologies?

  • Fastify: A high-performance, low-overhead Node.js web framework ideal for building efficient APIs. Its plugin architecture makes integration straightforward.
  • AWS SNS: A fully managed pub/sub messaging service that handles the complexities of message delivery across various protocols (SMS, email, push notifications, etc.) at scale. It's cost-effective and reliable.
  • fastify-aws-sns Plugin: Simplifies interaction with the AWS SNS API directly within the Fastify application context. (Note: Plugin functionality should be verified against its documentation, as wrappers can sometimes have limitations or lag behind the underlying SDK.)

System Architecture:

plaintext
+-----------+       +-----------------+      +-----------+      +---------------------+
|  Client   | ----> |  Fastify API    | ---- | AWS SNS   | ---- | User Endpoints      |
| (Web/App) |       | (Node.js/Docker)|      | (Topics)  |      | (SMS, Email, etc.)  |
+-----------+       +-----------------+      +-----------+      +---------------------+
                         |        ^
                         |        | (Optional: Store metadata)
                         v        |
                     +-----------+
                     | Database  |
                     | (Postgres)|
                     +-----------+

Expected Outcome:

By the end of this guide, you will have a functional Fastify API capable of:

  • Creating and listing SNS topics.
  • Subscribing phone numbers and email addresses to topics.
  • Handling the SNS subscription confirmation workflow.
  • Publishing messages to topics.
  • Sending direct SMS messages.
  • Basic security, logging, and error handling implemented.

1. Setting up the Project

Let's initialize our Node.js project and install the necessary dependencies.

1.1. Initialize Project

Open your terminal and create a new project directory:

bash
mkdir fastify-sns-campaigns
cd fastify-sns-campaigns
npm init -y

1.2. Install Dependencies

We need Fastify, the SNS plugin, a tool to load environment variables, and the core AWS SDK (which fastify-aws-sns uses under the hood, but explicitly installing ensures compatibility and allows direct SDK usage if needed).

bash
npm install fastify fastify-aws-sns dotenv @aws-sdk/client-sns
  • fastify: The core web framework.
  • fastify-aws-sns: The plugin for easy SNS integration.
  • dotenv: Loads environment variables from a .env file into process.env.
  • @aws-sdk/client-sns: The official AWS SDK v3 for SNS (provides underlying functionality).

1.3. Project Structure

Create the following basic structure:

plaintext
fastify-sns-campaigns/
├── node_modules/
├── routes/
│   └── index.js      # Main route definitions
├── plugins/
│   └── sns.js        # Plugin registration for SNS
├── .env              # Environment variables (DO NOT COMMIT)
├── .gitignore
├── server.js         # Main application entry point
└── package.json

1.4. Configure .gitignore

Create a .gitignore file to prevent committing sensitive information and unnecessary files:

plaintext
# .gitignore

node_modules
.env
npm-debug.log
*.log

1.5. Environment Variables (.env)

Create a .env file in the project root. This file will hold your AWS credentials and other configuration. Never commit this file to version control.

dotenv
# .env

# AWS Credentials - Obtain from IAM User (See Section 4)
AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY
AWS_REGION=us-east-1 # Or your preferred AWS region

# Default SNS Topic (Optional - can be overridden via API)
# AWS_TOPIC_NAME=my-marketing-topic

# API Configuration
API_PORT=3000
API_HOST=127.0.0.1
API_KEY=your-secret-api-key # Simple API key for auth (See Section 7 - Use robust auth in production!)
  • Why .env? It keeps sensitive credentials and configuration separate from your code, making it easier to manage different environments (development, staging, production) and enhancing security.

1.6. Basic Server Setup (server.js)

This file initializes Fastify, loads environment variables, registers plugins and routes, and starts the server.

javascript
// 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 logger
});

// Register custom plugins (SNS integration)
fastify.register(require('./plugins/sns'));

// Register API routes
fastify.register(require('./routes/index'), { prefix: '/api' }); // Prefix all routes with /api

// Health check route
fastify.get('/health', async (request, reply) => {
  return { status: 'ok', timestamp: new Date().toISOString() };
});

// Run the server
const start = async () => {
  try {
    const port = process.env.API_PORT || 3000;
    const host = process.env.NODE_ENV === 'production' ? '0.0.0.0' : process.env.API_HOST || '127.0.0.1';

    await fastify.listen({ port: parseInt(port, 10), host });
    fastify.log.info(`Server listening on ${fastify.server.address().port}`);
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();
  • Why logger: true? Enables detailed logging of requests, responses, and errors, crucial for debugging.
  • Why 0.0.0.0 in production? Necessary for containerized environments (like Docker) to accept connections from outside the container.

2. Implementing Core Functionality (SNS Plugin)

Now, let's integrate the fastify-aws-sns plugin.

2.1. Register the SNS Plugin (plugins/sns.js)

This file registers the fastify-aws-sns plugin, making SNS functions available on the Fastify instance (e.g., fastify.snsTopics, fastify.snsMessage). The plugin automatically picks up AWS credentials from environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION) if they follow the standard naming convention.

javascript
// plugins/sns.js
'use strict';

const fp = require('fastify-plugin');
const fastifyAwsSns = require('fastify-aws-sns');

async function snsPlugin(fastify, options) {
  // The plugin automatically uses AWS credentials from environment variables
  // (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION)
  // or IAM roles if running on EC2/ECS/Lambda.
  fastify.register(fastifyAwsSns);

  fastify.log.info('AWS SNS Plugin registered');
}

module.exports = fp(snsPlugin, {
  name: 'snsPlugin',
  // Specify dependencies if needed, e.g., ['configPlugin']
});
  • Why fastify-plugin? It prevents Fastify from creating separate encapsulated contexts for the plugin, ensuring that the fastify.sns* decorators are available globally across your application routes.

2.2. Understanding Plugin Methods

The fastify-aws-sns plugin exposes several methods categorized under namespaces attached to the fastify instance. These typically include:

  • fastify.snsTopics: For managing topics (create, list, delete, get/set attributes).
  • fastify.snsMessage: For publishing messages to a topic.
  • fastify.snsSubscriptions: For managing subscriptions (list, subscribe different protocols like email/SMS/HTTP, confirm, unsubscribe).
  • fastify.snsSMS: For SMS-specific actions (publish direct SMS, check opt-out status, manage SMS attributes).

Important: The exact methods and their behavior depend on the plugin version. Always consult the fastify-aws-sns documentation or source code to confirm available functionality and parameters. If the plugin lacks a specific feature (e.g., advanced pagination, batching), you may need to use the AWS SDK directly (see Section 9.1 - Note: Section 9.1 is mentioned but not provided in the original text).

We will use these methods within our API routes in the next section, assuming they function as described.


3. Building the API Layer

We'll define RESTful endpoints to interact with SNS functionalities.

3.1. Basic Authentication Hook

For simplicity, we'll use a basic API key check. In production, use a more robust method like JWT or OAuth (see Section 7 - Note: Section 7 is mentioned but not provided in the original text).

Add this hook at the beginning of your main routes file (routes/index.js).

javascript
// routes/index.js (Start of file)
'use strict';

const API_KEY = process.env.API_KEY;

// Basic Authentication Hook
async function authenticate(request, reply) {
  const apiKey = request.headers['x-api-key'];
  if (!API_KEY || !apiKey || apiKey !== API_KEY) {
    reply.code(401).send({ error: 'Unauthorized', message: 'Valid API key required' });
    return; // Stop execution
  }
}

3.2. Route Definitions (routes/index.js)

Define the routes within an async function exported by the module.

javascript
// routes/index.js (Continued)
const { SNSClient, ListTopicsCommand, ListSubscriptionsByTopicCommand } = require(""@aws-sdk/client-sns""); // For direct SDK calls if needed

async function routes(fastify, options) {
  // Apply the authentication hook to all routes defined in this plugin
  fastify.addHook('preHandler', authenticate);

  // Initialize SNS Client for direct SDK calls if needed
  // Credentials and region are picked up from environment by default
  const snsClient = new SNSClient({});

  fastify.log.info('Registering API routes...');

  // --- Topic Management ---

  // POST /api/topics - Create a new SNS topic
  fastify.post('/topics', {
    schema: {
      body: {
        type: 'object',
        required: ['topicName'],
        properties: {
          topicName: { type: 'string', minLength: 1, maxLength: 256, pattern: '^[a-zA-Z0-9_-]+$' },
          isFifo: { type: 'boolean', default: false }, // Optional: For FIFO topics
          tags: { type: 'object' } // Optional: { key: value, ... }
        }
      },
      response: {
        201: {
          type: 'object',
          properties: {
            TopicArn: { type: 'string' }
          }
        }
      }
    }
  }, async (request, reply) => {
    const { topicName, isFifo, tags } = request.body;
    const attributes = {};
    if (isFifo) {
        attributes.FifoTopic = 'true';
        // FIFO topics require .fifo suffix
        if (!topicName.endsWith('.fifo')) {
            reply.code(400).send({ error: 'Bad Request', message: 'FIFO topic names must end with .fifo' });
            return;
        }
        // ContentBasedDeduplication is often useful for FIFO
        attributes.ContentBasedDeduplication = 'true';
    }

    const params = {
      topic: topicName, // Plugin uses 'topic' for name
      attributes: attributes,
      tags: tags ? Object.entries(tags).map(([Key, Value]) => ({ Key, Value })) : undefined
    };

    try {
      fastify.log.info(`Creating SNS topic: ${topicName}`);
      // Assuming fastify.snsTopics.create exists and works as expected
      const result = await fastify.snsTopics.create(params);
      reply.code(201).send({ TopicArn: result.TopicArn });
    } catch (error) {
      fastify.log.error({ error, params }, 'Error creating SNS topic');
      reply.code(500).send({ error: 'Failed to create topic', message: error.message });
    }
  });

  // GET /api/topics - List existing SNS topics
  fastify.get('/topics', {
     schema: {
        query: { // Add query schema for pagination token
            type: 'object',
            properties: {
                nextToken: { type: 'string' }
            }
        },
        response: {
            200: {
                type: 'object',
                properties: {
                    Topics: {
                        type: 'array',
                        items: {
                            type: 'object',
                            properties: {
                                TopicArn: { type: 'string' }
                            }
                        }
                    },
                    NextToken: { type: ['string', 'null'] } // For pagination
                }
            }
        }
     }
  }, async (request, reply) => {
    const { nextToken } = request.query;
    try {
      fastify.log.info(`Listing SNS topics (nextToken: ${nextToken})`);
      // Use direct SDK for reliable pagination
      const command = new ListTopicsCommand({ NextToken: nextToken });
      const result = await snsClient.send(command);
      reply.send({
          Topics: result.Topics || [],
          NextToken: result.NextToken || null
      });
      // --- Alternative using plugin (if pagination is not needed or verified to work): ---
      // const result = await fastify.snsTopics.list({}); // Check plugin docs for pagination support
      // reply.send(result);
    } catch (error) {
      fastify.log.error({ error }, 'Error listing SNS topics');
      reply.code(500).send({ error: 'Failed to list topics', message: error.message });
    }
  });

  // --- Subscription Management ---

  // POST /api/topics/:topicArn/subscriptions - Subscribe an endpoint
  fastify.post('/topics/:topicArn/subscriptions', {
    schema: {
      params: {
        type: 'object',
        required: ['topicArn'],
        properties: { topicArn: { type: 'string', pattern: '^arn:aws:sns:.*:.*:.*$' } }
      },
      body: {
        type: 'object',
        required: ['protocol', 'endpoint'],
        properties: {
          protocol: { type: 'string', enum: ['sms', 'email', 'email-json'] },
          endpoint: { type: 'string' } // Phone number (E.164) or email address
        }
      },
      response: {
        // SNS subscription requires confirmation (except sometimes SMS depending on region/settings)
        202: {
          type: 'object',
          properties: {
            message: { type: 'string' },
            SubscriptionArn: { type: 'string' } // Often 'pending confirmation'
          }
        }
      }
    }
  }, async (request, reply) => {
    const { topicArn } = request.params;
    const { protocol, endpoint } = request.body;

    let subscribePromise;
    // Plugin parameter names might differ slightly from SDK - check plugin docs
    const params = { topicArn };

    try {
      fastify.log.info(`Subscribing ${endpoint} (${protocol}) to topic ${topicArn}`);
      switch (protocol) {
        case 'sms':
          // Validate E.164 format (+ followed by country code and number)
          if (!/^\+[1-9]\d{1,14}$/.test(endpoint)) {
             reply.code(400).send({ error: 'Bad Request', message: 'Invalid phone number format. Use E.164 (e.g., +12125551234).' });
             return;
          }
          params.phoneNumber = endpoint; // Assuming plugin uses 'phoneNumber'
          // Verify method name and parameters with plugin documentation
          subscribePromise = fastify.snsSubscriptions.setBySMS(params);
          break;
        case 'email':
          params.email = endpoint; // Assuming plugin uses 'email'
          // Verify method name and parameters with plugin documentation
          subscribePromise = fastify.snsSubscriptions.setByEMail(params);
          break;
        case 'email-json':
          params.email = endpoint; // Assuming plugin uses 'email'
           // Verify method name and parameters with plugin documentation
          subscribePromise = fastify.snsSubscriptions.setByEMailJSON(params);
           break;
        default:
          reply.code(400).send({ error: 'Bad Request', message: `Unsupported protocol: ${protocol}` });
          return;
      }

      const result = await subscribePromise;
      // Note: Email subscriptions return a SubscriptionArn of 'pending confirmation'
      // and require confirmation via a link sent to the email.
      // SMS might auto-confirm in some regions/setups or also pend.
      reply.code(202).send({
        message: `Subscription request sent for ${endpoint}. Confirmation might be required.`,
        SubscriptionArn: result.SubscriptionArn || 'pending confirmation'
      });
    } catch (error) {
       fastify.log.error({ error, params }, 'Error subscribing endpoint');
       reply.code(500).send({ error: 'Failed to subscribe', message: error.message });
    }
  });

   // GET /api/topics/:topicArn/subscriptions - List subscriptions for a topic
   fastify.get('/topics/:topicArn/subscriptions', {
        schema: {
            params: {
                type: 'object',
                required: ['topicArn'],
                properties: { topicArn: { type: 'string', pattern: '^arn:aws:sns:.*:.*:.*$' } }
            },
            query: { // Add query schema for pagination token
                type: 'object',
                properties: {
                    nextToken: { type: 'string' }
                }
            },
            response: {
                200: {
                    type: 'object',
                    properties: {
                        Subscriptions: {
                            type: 'array',
                            items: {
                                type: 'object',
                                properties: {
                                    SubscriptionArn: { type: 'string' },
                                    Owner: { type: 'string' },
                                    Protocol: { type: 'string' },
                                    Endpoint: { type: 'string' },
                                    TopicArn: { type: 'string' }
                                }
                            }
                        },
                        NextToken: { type: ['string', 'null'] }
                    }
                }
            }
        }
   }, async (request, reply) => {
        const { topicArn } = request.params;
        const { nextToken } = request.query;
        try {
            fastify.log.info(`Listing subscriptions for topic ${topicArn} (nextToken: ${nextToken})`);
            // Use direct SDK for reliable pagination
            const command = new ListSubscriptionsByTopicCommand({ TopicArn: topicArn, NextToken: nextToken });
            const result = await snsClient.send(command);
            reply.send({
                Subscriptions: result.Subscriptions || [],
                NextToken: result.NextToken || null
            });
            // --- Alternative using plugin (if pagination is not needed or verified to work): ---
            // const result = await fastify.snsSubscriptions.list({ topicArn }); // Check plugin docs for pagination support
            // reply.send(result);
        } catch (error) {
            fastify.log.error({ error, topicArn }, 'Error listing subscriptions');
            reply.code(500).send({ error: 'Failed to list subscriptions', message: error.message });
        }
   });


  // --- Message Publishing ---

  // POST /api/topics/:topicArn/publish - Publish a message to a topic
  fastify.post('/topics/:topicArn/publish', {
    schema: {
      params: {
        type: 'object',
        required: ['topicArn'],
        properties: { topicArn: { type: 'string', pattern: '^arn:aws:sns:.*:.*:.*$' } }
      },
      body: {
        type: 'object',
        required: ['message'],
        properties: {
          message: { type: 'string', minLength: 1 },
          subject: { type: 'string', maxLength: 100 }, // Primarily for email
          messageAttributes: { type: 'object' } // Optional: { key: { DataType: 'String', StringValue: 'value' }, ...}
        }
      },
      response: {
        200: {
          type: 'object',
          properties: {
            MessageId: { type: 'string' }
          }
        }
      }
    }
  }, async (request, reply) => {
    const { topicArn } = request.params;
    const { message, subject, messageAttributes } = request.body;
    // Plugin parameter names might differ slightly from SDK - check plugin docs
    const params = {
      topicArn,
      message,
      subject, // Subject is ignored by SMS
      messageAttributes
    };

    try {
      fastify.log.info(`Publishing message to topic ${topicArn}`);
      // Assuming fastify.snsMessage.publish exists and works as expected
      const result = await fastify.snsMessage.publish(params);
      reply.send({ MessageId: result.MessageId });
    } catch (error) {
      fastify.log.error({ error, params }, 'Error publishing message to topic');
      reply.code(500).send({ error: 'Failed to publish message', message: error.message });
    }
  });

  // POST /api/sms/publish - Send a direct SMS message
  fastify.post('/sms/publish', {
    schema: {
      body: {
        type: 'object',
        required: ['phoneNumber', 'message'],
        properties: {
          phoneNumber: { type: 'string', pattern: '^\+[1-9]\d{1,14}$' }, // E.164 format
          message: { type: 'string', minLength: 1, maxLength: 1600 }, // Check SNS limits
          senderId: { type: 'string', maxLength: 11 }, // Optional: Custom Sender ID (if supported)
          messageType: { type: 'string', enum: ['Promotional', 'Transactional'], default: 'Promotional' }
        }
      },
      response: {
        200: {
          type: 'object',
          properties: {
            MessageId: { type: 'string' }
          }
        }
      }
    }
  }, async (request, reply) => {
    const { phoneNumber, message, senderId, messageType } = request.body;
    // Plugin parameter names might differ slightly from SDK - check plugin docs
    const params = {
      phoneNumber,
      message,
      messageAttributes: {} // Note: Verify if plugin handles attributes directly in publish or requires separate setAttributes call
    };

    if (senderId) {
       params.messageAttributes['AWS.SNS.SMS.SenderID'] = {
           DataType: 'String',
           StringValue: senderId
       };
    }
     params.messageAttributes['AWS.SNS.SMS.SMSType'] = {
         DataType: 'String',
         StringValue: messageType
     };


    try {
      fastify.log.info(`Sending direct SMS to ${phoneNumber}`);
       // Assuming fastify.snsSMS.publish exists and handles messageAttributes correctly.
       // If not, you might need to call a separate setAttributes method via plugin or SDK first.
      const result = await fastify.snsSMS.publish(params);
      reply.send({ MessageId: result.MessageId });
    } catch (error) {
      fastify.log.error({ error, params }, 'Error sending direct SMS');
      reply.code(500).send({ error: 'Failed to send SMS', message: error.message });
    }
  });

  fastify.log.info('API routes registered.');
}

module.exports = routes;
  • Why Schema Validation? Ensures incoming requests have the correct structure and data types before your handler logic runs, preventing errors and improving security. Fastify handles this efficiently.
  • Why async/await? Simplifies handling asynchronous operations like calling the AWS API.
  • Why log errors? Essential for debugging production issues. Logging includes the error object and relevant parameters.

3.3. API Testing Examples (curl)

Replace placeholders like YOUR_API_KEY, YOUR_TOPIC_ARN, +12223334444 with actual values. Use single quotes around JSON data for curl on most shells.

  • Create Topic:

    bash
    curl -X POST http://localhost:3000/api/topics \
      -H ""Content-Type: application/json"" \
      -H ""x-api-key: YOUR_API_KEY"" \
      -d '{ ""topicName"": ""my-new-campaign"" }'
    # Expected: 201 Created with { ""TopicArn"": ""arn:aws:sns:..."" }
  • List Topics:

    bash
    curl http://localhost:3000/api/topics -H ""x-api-key: YOUR_API_KEY""
    # Expected: 200 OK with { ""Topics"": [...] }
  • Subscribe SMS:

    bash
    curl -X POST http://localhost:3000/api/topics/YOUR_TOPIC_ARN/subscriptions \
      -H ""Content-Type: application/json"" \
      -H ""x-api-key: YOUR_API_KEY"" \
      -d '{ ""protocol"": ""sms"", ""endpoint"": ""+12223334444"" }'
    # Expected: 202 Accepted with { ""message"": ""..."", ""SubscriptionArn"": ""..."" }
  • Subscribe Email:

    bash
    curl -X POST http://localhost:3000/api/topics/YOUR_TOPIC_ARN/subscriptions \
      -H ""Content-Type: application/json"" \
      -H ""x-api-key: YOUR_API_KEY"" \
      -d '{ ""protocol"": ""email"", ""endpoint"": ""user@example.com"" }'
    # Expected: 202 Accepted with { ""message"": ""..."", ""SubscriptionArn"": ""pending confirmation"" }
    # Check user@example.com for a confirmation email.
  • Publish to Topic:

    bash
    curl -X POST http://localhost:3000/api/topics/YOUR_TOPIC_ARN/publish \
      -H ""Content-Type: application/json"" \
      -H ""x-api-key: YOUR_API_KEY"" \
      -d '{ ""message"": ""Hello subscribers!"", ""subject"": ""Campaign Update"" }'
    # Expected: 200 OK with { ""MessageId"": ""..."" }
    # Check subscribed endpoints (SMS/Email)
  • Publish Direct SMS:

    bash
    curl -X POST http://localhost:3000/api/sms/publish \
      -H ""Content-Type: application/json"" \
      -H ""x-api-key: YOUR_API_KEY"" \
      -d '{ ""phoneNumber"": ""+12223334444"", ""message"": ""Direct message test"" }'
    # Expected: 200 OK with { ""MessageId"": ""..."" }
    # Check the phone number

4. Integrating with AWS SNS (Credentials & IAM)

Securely connecting to AWS is paramount.

4.1. Create an IAM User

It's best practice to create a dedicated IAM user with least privilege access.

  1. Go to the AWS Management Console -> IAM.
  2. Navigate to Users -> Add users.
  3. Enter a User name (e.g., fastify-sns-api-user).
  4. Select Provide user access to the AWS Management Console - Optional. If selected, set a password.
  5. Select Access key - Programmatic access. This is crucial for API interaction. Click Next.
  6. Set permissions: Choose Attach policies directly.
  7. Search for and select the AmazonSNSFullAccess policy. Note: For stricter security, create a custom policy granting only the specific SNS actions needed (e.g., sns:CreateTopic, sns:ListTopics, sns:Subscribe, sns:Publish, sns:ListSubscriptionsByTopic, sns:SetSMSAttributes, sns:CheckIfPhoneNumberIsOptedOut, sns:GetSMSSandboxAccountStatus etc.). Start with AmazonSNSFullAccess for simplicity during development, but refine for production.
  8. Click Next: Tags (Optional: Add tags for organization).
  9. Click Next: Review.
  10. Click Create user.
  11. IMPORTANT: On the final screen, copy the Access key ID and Secret access key. Store them securely (e.g., in your .env file locally, or a secrets manager in production). You won't be able to see the secret key again.

4.2. Configure Credentials

Store the copied credentials securely in your .env file for local development:

dotenv
# .env
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE # Replace with your Access Key ID
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY # Replace with your Secret Access Key
AWS_REGION=us-east-1 # Ensure this matches the region you want to use SNS in
  • AWS_ACCESS_KEY_ID: Identifies the IAM user.
  • AWS_SECRET_ACCESS_KEY: The secret password for the user. Treat this like a password.
  • AWS_REGION: The AWS region where your SNS topics and resources will reside (e.g., us-east-1, eu-west-1). SNS is region-specific.

The fastify-aws-sns plugin (and the underlying AWS SDK) will automatically detect and use these environment variables. If deploying to AWS services like EC2, ECS, or Lambda, you should use IAM Roles instead, which is more secure as credentials aren't hardcoded or stored in files.


5. Error Handling, Logging, and Retry Mechanisms

Robust applications need to handle failures gracefully.

5.1. Consistent Error Handling

Fastify allows defining a global error handler. We'll augment the existing logging within routes.

Add this to server.js before start():

javascript
// server.js (before start())

fastify.setErrorHandler(function (error, request, reply) {
  // Log the error
  fastify.log.error({
    request: {
      method: request.method,
      url: request.url,
      headers: request.headers,
      // Avoid logging sensitive data from body in production
      body: process.env.NODE_ENV !== 'production' ? request.body : undefined,
      query: request.query,
      params: request.params,
    },
    error: {
      message: error.message,
      stack: error.stack,
      code: error.code, // AWS SDK errors often have codes
      statusCode: error.statusCode,
    },
  }, 'Unhandled error occurred');

  // Send generic error response in production
  if (process.env.NODE_ENV === 'production' && (!error.statusCode || error.statusCode >= 500)) {
      reply.status(500).send({ error: 'Internal Server Error', message: 'An unexpected error occurred' });
      return;
  }

  // Send detailed error response in development or for client errors (4xx)
  const statusCode = error.statusCode >= 400 ? error.statusCode : 500;
  reply.status(statusCode).send({
    error: error.name || 'Internal Server Error',
    message: error.message || 'An unexpected error occurred',
    code: error.code, // Include code for debugging
  });
});
  • Why setErrorHandler? Catches unhandled errors thrown within route handlers or plugins, ensuring a consistent error response format and logging.
  • Why log request details? Helps immensely in debugging by showing the exact request that caused the error (be careful with sensitive body data).
  • AWS Error Codes: AWS SDK errors often include specific codes (e.g., InvalidParameterValue, AuthorizationError, ThrottlingException) which are useful for diagnostics.

5.2. Logging

Fastify's built-in Pino logger (logger: true) is efficient.

  • Levels: By default, it logs info and above. You can configure the level: logger: { level: 'debug' }.
  • Formats: Logs are typically JSON, suitable for log aggregation systems (CloudWatch Logs, Datadog, Splunk).
  • Context: We added specific logging within routes (fastify.log.info, fastify.log.error) to show application-specific actions and errors.

5.3. Retry Mechanisms

  • SNS Delivery Retries: AWS SNS automatically handles retries for delivering messages to subscribed endpoints (e.g., if an email server is temporarily down or an SMS gateway is busy). This is configured within SNS itself (Delivery policy).

  • API Call Retries: What if the API call to AWS SNS fails due to network issues or transient AWS problems (like throttling)?

    • Simple Approach: For critical operations like publishing, you could wrap the fastify.sns*.publish() or direct SDK call in a simple retry loop with exponential backoff.
    • Library Approach: Use a library like async-retry for more sophisticated retry logic.

    Example using async-retry (install npm i async-retry):

    javascript
    // Example within a route handler (e.g., publish)
    const retry = require('async-retry');
    
    // ... inside POST /api/topics/:topicArn/publish handler ...
    try {
      const result = await retry(async (bail, attempt) => {
          fastify.log.info(`Publishing message to topic ${topicArn}, attempt ${attempt}`);
          try {
             // Use either the plugin or direct SDK call here
             return await fastify.snsMessage.publish(params);
             // OR: return await snsClient.send(new PublishCommand(sdkParams));
          } catch (error) {
              // Don't retry on client errors (4xx) like invalid parameters
              // AWS SDK v3 errors might not have statusCode directly, check $metadata
              const statusCode = error.$metadata?.httpStatusCode || error.statusCode;
              if (statusCode >= 400 && statusCode < 500) {
                  // Give up on client errors
                  bail(new Error(`Non-retriable error (${statusCode}): ${error.message}`));
                  return; // Important: return after bail
              }
              // Throw other errors (like 5xx, network errors) to trigger retry
              throw error;
          }
      }, {
          retries: 3, // Number of retries
          factor: 2, // Exponential backoff factor
          minTimeout: 1000, // Initial delay ms
          onRetry: (error, attempt) => {
              fastify.log.warn(`Retrying publish operation (attempt ${attempt}) due to error: ${error.message}`);
          }
      });
    
      reply.send({ MessageId: result.MessageId });
    
    } catch (error) {
      // This catches errors after retries have failed or non-retriable errors
      fastify.log.error({ error, params }, 'Error publishing message to topic after retries');
      // Use the status code from the bailed error if available
      const finalStatusCode = error.originalError?.$metadata?.httpStatusCode || error.originalError?.statusCode || 500;
      reply.code(finalStatusCode).send({ error: 'Failed to publish message', message: error.message });
    }
    // ... rest of handler ...