Developer Guide: Sending WhatsApp Messages via AWS SNS with Fastify
A guide on building a Node.js Fastify application to publish messages to AWS SNS for triggering WhatsApp message delivery via downstream services like AWS Lambda and the WhatsApp Business API.
This guide details how to build a production-ready system using Fastify and Node.js to publish messages to an AWS Simple Notification Service (SNS) topic. These SNS messages can then trigger downstream processes – typically an AWS Lambda function – to send WhatsApp messages via the official WhatsApp Business API (like Meta's Cloud API).
This approach decouples your main application from the direct interaction with the WhatsApp API, leveraging SNS for resilience and scalability. Your Fastify application focuses on validating requests and initiating the messaging workflow by publishing to SNS.
Project Goals:
Create a Fastify API endpoint that accepts requests to send WhatsApp messages.
Validate incoming requests for required parameters (phone number, message content).
Securely publish validated message details to a designated AWS SNS topic using the AWS SDK.
Provide a robust foundation for a decoupled messaging system.
Technology Stack:
Node.js: Runtime environment.
Fastify: High-performance web framework for Node.js.
AWS SNS: Fully managed pub/sub messaging service.
AWS SDK for JavaScript v3: Used for interacting with AWS SNS.
Downstream Components (Implied): AWS Lambda, WhatsApp Business API (e.g., Meta Cloud API).
System Architecture:
graph LR
A[Client/User] -- HTTP POST --> B(Fastify App);
B -- Validate & Format --> B;
B -- Publish Message --> C(AWS SNS Topic);
C -- Trigger --> D(AWS Lambda Function);
D -- Send Request --> E(WhatsApp Business API);
E -- Deliver Message --> F(End User's WhatsApp);
Prerequisites:
Node.js (LTS version recommended) and npm/yarn installed.
An AWS account with permissions to manage IAM and SNS.
Basic familiarity with Node.js, Fastify, and AWS concepts.
(For end-to-end testing) Access to configure a WhatsApp Business API sender and an AWS Lambda function. This guide focuses on the Fastify -> SNS part.
1. Setting up the project
Let's initialize the Node.js project, install dependencies, and configure the basic structure and environment.
1.1. Initialize Project:
Open your terminal and create a new project directory:
Bash
mkdir fastify-sns-whatsapp
cd fastify-sns-whatsapp
npm init -y
1.2. Install Dependencies:
We need Fastify, the AWS SDK v3 SNS client, and dotenv for managing environment variables.
Bash
npm install fastify @aws-sdk/client-sns dotenv
fastify: The core web framework.
@aws-sdk/client-sns: AWS SDK v3 module for interacting with SNS.
dotenv: Loads environment variables from a .env file into process.env.
1.3. Project Structure:
Create the following basic structure:
fastify-sns-whatsapp/
src/
routes/
whatsapp.js # API routes for sending messages
server.js # Fastify server setup
.env # Environment variables (DO NOT COMMIT)
.gitignore # Git ignore file
package.json
1.4. Configure AWS Credentials:
Your application needs AWS credentials to interact with SNS. The AWS SDK looks for credentials in the standard locations: environment variables, shared credential file (~/.aws/credentials), or IAM role (if running on EC2/ECS/Lambda).
Recommendation: Use an IAM User with programmatic access specifically for this application.
Navigate to IAM: In the AWS Management Console, go to the IAM service.
Create User: Go to Users and click Add users.
User Details: Enter a username (e.g., fastify-sns-app-user) and select Provide user access to the AWS Management Console (optional) if needed, but ensure Programmatic access (Access key - ID and secret access key) is selected. Click Next.
Permissions: Choose Attach policies directly. Search for and select the AmazonSNSFullAccess policy (for simplicity in this guide) or create a custom policy granting only sns:Publish permissions to your specific topic ARN for better security. Click Next.
Tags (Optional): Add any desired tags. Click Next.
Review and Create: Review the details and click Create user.
Save Credentials:Crucially, copy the Access key ID and Secret access key. You won't be able to see the secret key again.
1.5. Configure SNS Topic:
Navigate to SNS: In the AWS Management Console, go to the Simple Notification Service (SNS).
Create Topic: Go to Topics and click Create topic.
Type: Choose Standard. FIFO topics have different considerations not covered here.
Name: Enter a name (e.g., whatsapp-outgoing-messages).
Leave Defaults: Keep other settings as default for now.
Create Topic: Click Create topic.
Copy ARN: Once created, copy the Topic ARN. It will look something like arn:aws:sns:us-east-1:123456789012:whatsapp-outgoing-messages.
1.6. Set Up Environment Variables:
Create a .env file in your project root:
Text
#.env
# AWS Credentials - DO NOT COMMIT THIS FILE TO GIT
AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY_ID_HERE
AWS_SECRET_ACCESS_KEY=YOUR_SECRET_ACCESS_KEY_HERE
AWS_DEFAULT_REGION=your-aws-region # e.g., us-east-1
# SNS Configuration
SNS_TOPIC_ARN=YOUR_SNS_TOPIC_ARN_HERE
# Server Configuration
PORT=3000
HOST=0.0.0.0
# Security (Example API Key)
API_KEY=your-super-secret-api-key
Replace the placeholder values with your actual credentials, region, and topic ARN. For the API_KEY, it is strongly recommended to generate a cryptographically secure random string rather than using a simple placeholder, even for development.
1.7. Configure Git Ignore:
Create a .gitignore file to prevent committing sensitive information and build artifacts:
fastify({ logger: ... }): Initializes Fastify with Pino logging. Conditionally uses pino-pretty for development readability (install pino-pretty as a dev dependency: npm install --save-dev pino-pretty). In production, it defaults to JSON logging.
Route Registration: Loads the WhatsApp routes under /api/v1.
Health Check: Provides a simple /ping endpoint.
Server Start: Listens on the configured host and port.
Graceful Shutdown: Handles OS signals for clean termination.
3. Building the API layer
We'll create the API endpoint to receive WhatsApp send requests, validate them, and trigger the SNS publish action.
3.1. Define API Route (src/routes/whatsapp.js):
This file defines the /send endpoint for initiating WhatsApp messages.
JavaScript
// src/routes/whatsapp.js'use strict'const { SNSClient, PublishCommand } = require('@aws-sdk/client-sns') // Use SDK v3// Initialize SNS Client once (better performance than per-request)const snsClient = newSNSClient({ region: process.env.AWS_DEFAULT_REGION })
// Define the validation schema for the request bodyconst sendBodySchema = {
type: 'object',
required: ['to', 'message'],
properties: {
to: {
type: 'string',
description: 'Recipient phone number in E.164 format (e.g., +14155552671)',
// Basic E.164 pattern - ensure it starts with + and digitspattern: '^\\+[1-9]\\d{1,14}$',
},
message: {
type: 'string',
description: 'The text content of the message',
minLength: 1,
maxLength: 1600, // WhatsApp message limit
},
// Optional: Add more fields to pass via SNS if needed// e.g., templateName, languageCode, userId etc.metadata: { type: 'object' },
},
}
// Define the response schemaconst sendResponseSchema = {
'2xx': { // Covers 200, 202 etc.type: 'object',
properties: {
messageId: { type: 'string', description: 'AWS SNS Message ID' },
status: { type: 'string', description: 'Indicates message queued via SNS' },
},
},
// Add schemas for error responses (400, 401, 500) if desired
}
asyncfunctionwhatsappRoutes(fastify, options) {
// --- Authentication Hook ---// Simple API Key check - Replace with a more robust method in production
fastify.addHook('onRequest', async (request, reply) => {
const apiKey = request.headers['x-api-key']
if (!apiKey || apiKey !== process.env.API_KEY) {
fastify.log.warn('Unauthorized attempt to access API')
// Use `return reply.code(...).send(...)` to stop processing and send responsereturn reply.code(401).send({ error: 'Unauthorized' })
}
// If execution reaches here, the hook passed
})
// --- Send Message Route ---
fastify.post(
'/send',
{
schema: {
description: 'Queues a WhatsApp message for sending via AWS SNS.',
tags: ['whatsapp'],
summary: 'Send WhatsApp message',
body: sendBodySchema,
response: sendResponseSchema,
headers: { // Document required headerstype: 'object',
properties: {
'x-api-key': { type: 'string' }
},
required: ['x-api-key']
}
},
},
async (request, reply) => {
const { to, message, metadata } = request.bodyconst topicArn = process.env.SNS_TOPIC_ARNif (!topicArn) {
request.log.error('SNS_TOPIC_ARN environment variable is not set.')
return reply.code(500).send({ error: 'Internal server configuration error.' })
}
// Construct the message payload for SNS// This payload will be received by the Lambda functionconst snsPayload = {
// Standardize the payload structurerecipientPhoneNumber: to,
messageBody: message,
// Pass through any additional metadata
...(metadata && { metadata }),
}
// Prepare SNS publish command using AWS SDK v3const command = newPublishCommand({
TopicArn: topicArn,
Message: JSON.stringify(snsPayload), // SNS message must be a string// Optional: Add MessageAttributes for filtering/routing in SNS subscriptions// MessageAttributes: {// messageType: { DataType: 'String', StringValue: 'whatsapp' },// },
})
try {
request.log.info(`Publishing message to SNS topic ${topicArn} for recipient ${to}`)
const publishResult = await snsClient.send(command)
request.log.info(
`Successfully published message to SNS. Message ID: ${publishResult.MessageId}`
)
// Accepted: The request is valid and queued via SNS.// Downstream systems handle actual delivery.
reply.code(202).send({
messageId: publishResult.MessageId,
status: 'Message queued successfully via SNS.',
})
} catch (error) {
request.log.error(
{ err: error }, // Log the full error object`Failed to publish message to SNS topic ${topicArn}`
)
// Determine if it's a client error (e.g., throttling) or server errorconst statusCode = error.$metadata?.httpStatusCode || 500
reply
.code(statusCode >= 500 ? 500 : 503) // Use 503 for transient AWS issues
.send({
error: 'Failed to queue message via SNS.',
details: error.message, // Include details cautiously in prod
})
}
}
)
}
module.exports = whatsappRoutes
Schema Validation: Uses Fastify's built-in JSON schema validation (sendBodySchema) to ensure to (in E.164 format) and message are present and valid before processing.
Authentication Hook: Implements a simple API key check using fastify.addHook. Replace this with a proper authentication mechanism (e.g., JWT, OAuth) for production. Note the use of return reply... to correctly stop processing on failure.
Route Definition: Defines a POST /api/v1/send endpoint.
SNS Client: The SNSClient is initialized once when the module loads for better performance.
SNS Payload Construction: Creates a structured JSON payload (snsPayload).
SNS Publishing: Uses the AWS SDK v3 (@aws-sdk/client-sns) directly. Creates a PublishCommand and sends it using the pre-initialized snsClient.
Response Handling: Returns 202 Accepted on success, or appropriate error codes (500/503) on failure.
Environment Variable Check: Ensures SNS_TOPIC_ARN is configured.
3.2. Testing the Endpoint:
Once the server is running (npm start), you can test the endpoint using curl or Postman.
Running the Server:
Bash
# For production mode (JSON logs)
npm start
# For development with pretty logs and auto-reload:# npm install --save-dev nodemon pino-pretty# npx nodemon src/server.js | npx pino-pretty
Example cURL Request:
Replace your-super-secret-api-key and the phone number/message.
Bash
curl -X POST http://localhost:3000/api/v1/send \
-H "Content-Type: application/json" \
-H "x-api-key: your-super-secret-api-key" \
-d '{
"to": "+14155552671",
"message": "Hello from Fastify via SNS!"
}'
Example Success Response (202 Accepted):
JSON
{"messageId":"a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8","status":"Message queued successfully via SNS."}
Example Error Response (400 Bad Request - Invalid Phone):
JSON
{"statusCode":400,"error":"Bad Request","message":"body/to must match pattern \"^\\\\+[1-9]\\\\d{1,14}$\""}
Example Error Response (401 Unauthorized):
JSON
{"error":"Unauthorized"}
Example Error Response (500 Internal Server Error - SNS Publish Failed):
JSON
{"error":"Failed to queue message via SNS.","details":"The security token included in the request is invalid."// Example detail}
4. Integrating with AWS SNS
This section focuses on the specifics of the SNS integration.
4.1. Configuration Recap:
AWS Credentials: Provided via environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION) loaded by dotenv and automatically used by the AWS SDK.
SNS Topic ARN: Provided via the SNS_TOPIC_ARN environment variable, used in the PublishCommand.
4.2. Secure Handling of Secrets:
.env File: Store sensitive keys (AWS_SECRET_ACCESS_KEY, API_KEY) only in the .env file.
.gitignore: Ensure .env is listed in your .gitignore file.
Production Environments: In production, avoid using .env files. Inject secrets directly as environment variables through your deployment mechanism (e.g., ECS Task Definitions, Lambda Environment Variables, Kubernetes Secrets). Use tools like AWS Secrets Manager or HashiCorp Vault.
4.3. Fallback Mechanisms & Retries:
SNS Publish Retries (Client-Side): The AWS SDK v3 has built-in retry logic for transient network errors or throttled requests when communicating with the SNS API endpoint. This is generally sufficient for the Fastify app's interaction with SNS.
SNS Delivery Retries (Server-Side): Once a message is successfully published to SNS, SNS itself handles retries for delivering the message to its subscribers (like your Lambda function). Configure these retry policies and Dead-Letter Queues (DLQs) on the SNS subscription in the AWS console. This ensures resilience if the downstream consumer fails.
4.4. AWS Console Setup Summary:
To configure the necessary AWS resources:
IAM: Create an IAM user with programmatic access. Grant it permissions to publish to your specific SNS topic ARN (e.g., using a custom policy with the sns:Publish action or the AmazonSNSFullAccess managed policy for simplicity). Securely store the generated Access Key ID and Secret Access Key.
SNS: Create a Standard SNS topic. Note its ARN (Amazon Resource Name).
These credentials and the Topic ARN are then used in your .env file.
4.5. Environment Variables Summary:
Variable
Purpose
Format
How to Obtain
AWS_ACCESS_KEY_ID
AWS credential for programmatic access.
String (e.g., AKIAIOSFODNN7EXAMPLE)
AWS IAM console after creating a user with programmatic access.
The network port the Fastify server will listen on.
Number (e.g., 3000)
Choose an available port.
HOST
The network interface the server binds to (0.0.0.0 for all).
String (e.g., 0.0.0.0, 127.0.0.1)
0.0.0.0 is typical for containers/servers.
API_KEY
A secret key for basic API authentication (example purposes).
String (e.g., your-super-secret-api-key)
Generate a secure random string.
LOG_LEVEL
Controls the verbosity of application logs (optional, defaults info).
String (fatal, error, warn, info, debug, trace)
Set based on environment (e.g., debug in dev, info in prod).
NODE_ENV
Sets the environment mode (e.g., development, production).
String (development, production)
Controls features like pretty logging. Set to production in deployments.
5. Error Handling, Logging, and Retry Mechanisms
Robust error handling and logging are essential for production systems.
5.1. Error Handling Strategy:
Validation Errors: Handled automatically by Fastify's schema validation, returning 400 Bad Request.
Authentication Errors: Handled by the onRequest hook, returning 401 Unauthorized.
SNS Publish Errors: Caught in the try...catch block within the /send route handler.
Log the detailed error server-side (including the error object from the SDK).
Return appropriate HTTP status codes (500 for general failures, 503 Service Unavailable for potentially transient AWS issues).
Avoid leaking sensitive internal error details to the client.
Configuration Errors: Check for essential environment variables (like SNS_TOPIC_ARN) before use, returning a 500 Internal Server Error if missing.
Global Error Handler (Optional):fastify.setErrorHandler() can catch unhandled exceptions, but specific handling within routes is preferred.
5.2. Logging:
Fastify Logger (Pino): Used by default. request.log provides request-specific logging with request IDs. fastify.log is for general application logging.
Log Levels: Control verbosity via LOG_LEVEL.
Log Format: JSON format in production (default when NODE_ENV=production) for log aggregation systems. pino-pretty for development.
SNS publish failures (error details, AWS request ID if available error.$metadata?.requestId).
Configuration issues.
Example Logging in Route:
JavaScript
// Inside the /send route handler
request.log.info({ recipient: to }, `Processing send request`);
// ... later ...
request.log.info({ recipient: to, messageId: publishResult.MessageId }, `SNS publish successful`);
// ... on error ...
request.log.error({ recipient: to, error: error.message, awsRequestId: error.$metadata?.requestId, err: error }, `SNS publish failed`); // Log full error object too
5.3. Retry Mechanisms (Recap):
SNS Publish Call: Handled by the AWS SDK's default retry strategy.
SNS Message Delivery: Handled by SNS subscription retry policies configured in AWS (outside the Fastify app). Configure an SNS DLQ on the subscription to capture messages that fail delivery repeatedly.
6. Database Schema and Data Layer (Optional)
Integrating a database allows tracking message status or implementing features like rate limiting.
Data Access: Use an ORM (Prisma, Sequelize) or query builder (Knex.js) with your chosen database (PostgreSQL, MySQL, etc.).
Migrations: Use tools like prisma migrate dev to manage schema changes.
Considerations: Index fields used in queries (e.g., sns_message_id, recipient_phone).
7. Adding Security Features
Enhance security beyond the basic API key:
Input Validation: Provided by Fastify schemas.
Authentication/Authorization: Replace the example API key with JWT (@fastify/jwt) or OAuth 2.0.
Rate Limiting: Use @fastify/rate-limit to prevent abuse.
Bash
npm install @fastify/rate-limit
JavaScript
// In server.js or a pluginawait fastify.register(require('@fastify/rate-limit'), {
max: 100, // Example: Max 100 requests per IP per minutetimeWindow: '1 minute'
})
Helmet: Use @fastify/helmet for security-related HTTP headers.
Bash
npm install @fastify/helmet
JavaScript
// In server.js or a pluginawait fastify.register(require('@fastify/helmet'))
HTTPS: Enforce HTTPS in production (typically via load balancer/API Gateway).
Dependency Audits: Run npm audit regularly.
8. Handling Special Cases
Phone Number Formatting: The E.164 regex is basic. For more robust validation/parsing, consider libphonenumber-js.
Message Content: Adhere to WhatsApp policies. Ensure proper UTF-8 handling (usually managed by SNS/WhatsApp API).
Idempotency: To handle client retries, consider adding an optional idempotencyKey (client-generated UUID) to requests. Cache recent keys (e.g., in Redis) to detect and reject duplicates. The downstream consumer might also need duplicate detection logic.
AWS SDK Client: The example initializes the SNSClient once per module load, which is efficient.
Logging: Pino is asynchronous. Avoid excessive logging or overly verbose levels in production.
Payload Size: Keep SNS payloads reasonably small.
Downstream Optimization: Performance often depends on downstream components (Lambda, WhatsApp API).
10. Adding Monitoring, Observability, and Analytics
Health Checks: Enhance the /ping endpoint or add a /health check for dependencies.
Metrics:
CloudWatch: Leverage built-in SNS metrics and Lambda metrics.
Application Metrics: Create CloudWatch Metric Filters from structured logs (JSON) to track custom metrics (e.g., messages queued, errors).
Error Tracking: Integrate services like Sentry or Datadog Error Tracking.
Distributed Tracing: Use AWS X-Ray or OpenTelemetry for tracing requests across services.
Dashboards: Visualize key metrics (request rate, error rate, latency, SNS/Lambda stats) in CloudWatch, Grafana, etc.
11. Troubleshooting and Caveats
CRITICAL CAVEAT: SNS Does Not Send Directly to WhatsApp: This Fastify app only publishes to SNS. A separate component (e.g., Lambda) must subscribe to the SNS topic and use the WhatsApp Business API to send the actual message.
AccessDenied: Check IAM permissions (sns:Publish).
SNS Errors:
TopicNotFound: Incorrect SNS_TOPIC_ARN or region mismatch.
ThrottlingException: Publishing too fast. Rely on SDK retries; consider limit increases if sustained.
Fastify Validation Errors: Check request body against the schema; error messages indicate the violation.
WhatsApp API Limitations: The downstream process is subject to Meta's rules (templates, rate limits, costs, etc.).
12. Deployment and CI/CD
12.1. Deployment Options:
Container (Recommended): Package using Docker.
Dockerfile Example:
Dockerfile
# Dockerfile
FROM node:18-alpine AS base
WORKDIR /app
COPY package*.json ./
# Install production dependencies only
FROM base AS prod-deps
RUN npm ci --omit=dev
# Build stage (if you have one, e.g., TypeScript)
# FROM base AS build
# COPY . .
# RUN npm run build
# Final production stage
FROM base
ENV NODE_ENV=production
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY ./src ./src
COPY package.json . # Copy essential files
# Copy build artifacts if needed
# COPY --from=build /app/dist ./dist
EXPOSE 3000
# Load PORT and HOST from runtime environment variables
CMD [""node"", ""src/server.js""] # Or your built output, e.g., dist/server.js
Configuration: Inject environment variables securely (Task Definitions, Secrets Manager). Do not bake secrets into the image.
Serverless (Fastify on Lambda): Use @fastify/aws-lambda for sporadic workloads. See Fastify Serverless Guide.
12.2. CI/CD Pipeline (Example using GitHub Actions):
Create .github/workflows/deploy.yml:
YAML
# .github/workflows/deploy.ymlname:DeployFastifySNSWhatsAppAppon:push:branches: [ main ] # Trigger on push to mainjobs:build-and-deploy:runs-on:ubuntu-latestpermissions:id-token:write# Required for configure-aws-credentials using OIDCcontents:readsteps:-name:Checkoutcodeuses:actions/checkout@v4-name:SetupNode.jsuses:actions/setup-node@v4with:node-version:'18'cache:'npm'-name:Installdependenciesrun:npmci# Add linting/testing steps# - name: Lint# run: npm run lint# - name: Test# run: npm test-name:ConfigureAWSCredentials(OIDC)uses:aws-actions/configure-aws-credentials@v4with:role-to-assume:arn:aws:iam::YOUR_AWS_ACCOUNT_ID:role/YourGitHubActionsRole# Replace with your IAM Role ARNaws-region:us-east-1# Replace with your AWS region-name:LogintoAmazonECRid:login-ecruses:aws-actions/amazon-ecr-login@v2-name:Build,tag,andpushimagetoAmazonECRenv:ECR_REGISTRY:${{steps.login-ecr.outputs.registry}}ECR_REPOSITORY:your-ecr-repo-name# Replace with your ECR repo nameIMAGE_TAG:${{github.sha}}run:|
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
# Add deployment steps (e.g., update ECS service, deploy to App Runner)# - name: Deploy to AWS App Runner# run: aws apprunner start-deployment --service-arn ${{ secrets.APP_RUNNER_SERVICE_ARN }}
AWS Credentials: Use OIDC (OpenID Connect) with an IAM Role for secure, keyless authentication from GitHub Actions (recommended over storing long-lived keys).
Secrets: Store necessary secrets (like APP_RUNNER_SERVICE_ARN if used) in GitHub Actions secrets.
ECR Repository: Create an ECR repository in AWS.
Deployment Step: Adapt based on your platform.
13. Verification and Testing
13.1. Unit Tests:
Test components in isolation, mocking external services like SNS. Use tap, Jest, etc. proxyquire is useful for mocking dependencies.
Install testing dependencies: npm install --save-dev tap proxyquire
Add test script to package.json: "test": "tap test/**/*.test.js"
Example Unit Test (test/routes/whatsapp.test.js):
JavaScript
// test/routes/whatsapp.test.js'use strict'const { test } = require('tap')
constFastify = require('fastify')
const proxyquire = require('proxyquire') // To mock dependencies// Mock the AWS SDK v3 client and commandconst mockSNSClientInstance = {
send: async (command) => {
// Basic validation of command input for testingif (!command.input.TopicArn || !command.input.Message) {
thrownewError('Missing TopicArn or Message')
}
if (command.input.TopicArn !== 'arn:aws:sns:us-east-1:123456789012:test-topic') {
thrownewError('Incorrect TopicArn')
}
// Simulate successful SNS publishreturn { MessageId: 'mock-message-id-123', $metadata: { httpStatusCode: 200 } }
}
}
constMockSNSClient = function() { return mockSNSClientInstance }
// Use proxyquire to inject the mock SDK into the routes moduleconst whatsappRoutes = proxyquire('../../src/routes/whatsapp', {
'@aws-sdk/client-sns': {
SNSClient: MockSNSClient, // Replace real client with mock constructorPublishCommand: function(input) { this.input = input } // Mock command constructor
}
})
// --- Test Suite ---test('POST /api/v1/send - success', async (t) => {
const fastify = Fastify()
// Set up necessary environment variables for the test context
process.env.API_KEY = 'test-api-key'
process.env.SNS_TOPIC_ARN = 'arn:aws:sns:us-east-1:123456789012:test-topic'
process.env.AWS_DEFAULT_REGION = 'us-east-1'// Needed for SNSClient mock setup
fastify.register(whatsappRoutes, { prefix: '/api/v1' })
const response = await fastify.inject({
method: 'POST',
url: '/api/v1/send',
headers: {
'content-type': 'application/json',
'x-api-key': 'test-api-key'
},
payload: {
to: '+15551234567',
message: 'Test message'
}
})
t.equal(response.statusCode, 202, 'should return status code 202')
const body = JSON.parse(response.payload)
t.ok(body.messageId, 'should return a messageId')
t.equal(body.messageId, 'mock-message-id-123', 'should return the mocked messageId')
t.equal(body.status, 'Message queued successfully via SNS.', 'should return correct status message')
// Clean up env vars if necessary, though tap runs tests in separate processesdelete process.env.API_KEYdelete process.env.SNS_TOPIC_ARNdelete process.env.AWS_DEFAULT_REGION
})
test('POST /api/v1/send - unauthorized (missing API key)', async (t) => {
const fastify = Fastify()
process.env.API_KEY = 'test-api-key'// API key is set
process.env.SNS_TOPIC_ARN = 'arn:aws:sns:us-east-1:123456789012:test-topic'
process.env.AWS_DEFAULT_REGION = 'us-east-1'
fastify.register(whatsappRoutes, { prefix: '/api/v1' })
const response = await fastify.inject({
method: 'POST',
url: '/api/v1/send',
headers: {
'content-type': 'application/json'// No x-api-key header
},
payload: {
to: '+15551234567',
message: 'Test message'
}
})
t.equal(response.statusCode, 401, 'should return status code 401')
const body = JSON.parse(response.payload)
t.same(body, { error: 'Unauthorized' }, 'should return unauthorized error')
delete process.env.API_KEYdelete process.env.SNS_TOPIC_ARNdelete process.env.AWS_DEFAULT_REGION
})
test('POST /api/v1/send - validation error (invalid phone)', async (t) => {
const fastify = Fastify()
process.env.API_KEY = 'test-api-key'
process.env.SNS_TOPIC_ARN = 'arn:aws:sns:us-east-1:123456789012:test-topic'
process.env.AWS_DEFAULT_REGION = 'us-east-1'
fastify.register(whatsappRoutes, { prefix: '/api/v1' })
const response = await fastify.inject({
method: 'POST',
url: '/api/v1/send',
headers: {
'content-type': 'application/json',
'x-api-key': 'test-api-key'
},
payload: {
to: 'invalid-phone-number', // Invalid formatmessage: 'Test message'
}
})
t.equal(response.statusCode, 400, 'should return status code 400')
const body = JSON.parse(response.payload)
t.equal(body.error, 'Bad Request', 'should return Bad Request error')
t.ok(body.message.includes('body/to must match pattern'), 'should indicate pattern mismatch for phone')
delete process.env.API_KEYdelete process.env.SNS_TOPIC_ARNdelete process.env.AWS_DEFAULT_REGION
})
// Add more tests for SNS publish errors, missing config, etc.
Frequently asked questions
You can send WhatsApp messages using AWS SNS by publishing message details to an SNS topic, which then triggers a Lambda function to interact with the WhatsApp Business API. This decouples your main application from the WhatsApp API, enhancing scalability and resilience. Your application publishes messages to SNS, while a separate process handles the actual WhatsApp interaction.
Fastify acts as a high-performance web framework to create an API endpoint that receives WhatsApp message requests. It validates incoming requests for required parameters like phone number and message content before securely publishing to AWS SNS. This setup maintains a decoupled architecture.
AWS SNS provides a managed pub/sub service for decoupling and scaling message delivery. By using SNS, your core application doesn't need to directly interact with the WhatsApp Business API. This improves resilience and allows for easier management of message workflows.
This architecture is ideal for applications requiring scalable and reliable WhatsApp messaging. Decoupling with SNS becomes particularly beneficial with higher message volumes and complex workflows where direct WhatsApp API integration within the core app would introduce overhead and complexity.
This guide focuses on using SNS as an intermediary; the Fastify application doesn't interact with the WhatsApp API directly. A downstream service, typically an AWS Lambda function, subscribes to the SNS topic and handles direct communication with the WhatsApp Business API.
You'll need an IAM user with programmatic access, specifically the Access Key ID and Secret Access Key. These credentials are used by the AWS SDK to authorize your Fastify application to publish messages to the SNS topic. It's recommended to create a user with least privilege access - permissions only to publish to the relevant SNS topic.
A typical structure includes 'src/routes/whatsapp.js' for API routes, 'src/server.js' for the Fastify server, '.env' for environment variables, and '.gitignore' to exclude sensitive data. The 'routes/whatsapp.js' file contains the core logic for handling incoming requests and publishing to SNS.
Secure your setup by using environment variables for sensitive data, implementing robust authentication beyond the example API key (e.g., JWT), using HTTPS, and leveraging tools like Helmet. Regularly audit dependencies for vulnerabilities using 'npm audit'.
Fastify's built-in schema validation is used to ensure 'to' (phone number in E.164 format) and 'message' fields are present and valid. This prevents invalid requests from reaching the SNS publish stage and helps maintain data integrity.
The AWS SDK v3 for JavaScript, specifically the '@aws-sdk/client-sns' module, is used. Initialize the SNSClient and use the PublishCommand with the SNS topic ARN and the message payload (JSON stringified) to publish messages to the SNS topic.
The message payload sent to SNS should be a JSON object containing at least 'recipientPhoneNumber' and 'messageBody'. Additional metadata can be included as needed for downstream processing by the Lambda function or other consumer.
The Fastify app uses Pino logging by default. For development, use pino-pretty for readable logs. In production, set NODE_ENV=production for JSON formatted logs suitable for log aggregation systems. Log levels are controlled with LOG_LEVEL.
Common errors include incorrect AWS credentials (InvalidClientTokenId, SignatureDoesNotMatch), insufficient IAM permissions (AccessDenied), invalid topic ARN (TopicNotFound), and throttling from SNS if publishing rates are too high (ThrottlingException).
Containerization with Docker is recommended. Build a Docker image with your application code and deploy to platforms like AWS App Runner, AWS Fargate, or other container services. For serverless deployments (for infrequent usage), consider Fastify on AWS Lambda with '@fastify/aws-lambda'.
Remember, SNS only queues the messages. Verify the downstream service (e.g., AWS Lambda) is correctly subscribed to the SNS topic and functioning as expected. Check Lambda logs for errors related to WhatsApp Business API integration.
This guide details how to build a production-ready system using Fastify and Node.js to publish messages to an AWS Simple Notification Service (SNS) topic. These SNS messages can then trigger downstream processes – typically an AWS Lambda function – to send WhatsApp messages via the official WhatsApp Business API (like Meta's Cloud API).
This approach decouples your main application from the direct interaction with the WhatsApp API, leveraging SNS for resilience and scalability. Your Fastify application focuses on validating requests and initiating the messaging workflow by publishing to SNS.
Project Goals:
Technology Stack:
System Architecture:
Prerequisites:
1. Setting up the project
Let's initialize the Node.js project, install dependencies, and configure the basic structure and environment.
1.1. Initialize Project:
Open your terminal and create a new project directory:
mkdir fastify-sns-whatsapp cd fastify-sns-whatsapp npm init -y1.2. Install Dependencies:
We need Fastify, the AWS SDK v3 SNS client, and
dotenvfor managing environment variables.fastify: The core web framework.@aws-sdk/client-sns: AWS SDK v3 module for interacting with SNS.dotenv: Loads environment variables from a.envfile intoprocess.env.1.3. Project Structure:
Create the following basic structure:
fastify-sns-whatsapp/src/routes/whatsapp.js# API routes for sending messagesserver.js# Fastify server setup.env# Environment variables (DO NOT COMMIT).gitignore# Git ignore filepackage.json1.4. Configure AWS Credentials:
Your application needs AWS credentials to interact with SNS. The AWS SDK looks for credentials in the standard locations: environment variables, shared credential file (
~/.aws/credentials), or IAM role (if running on EC2/ECS/Lambda).Recommendation: Use an IAM User with programmatic access specifically for this application.
Usersand clickAdd users.fastify-sns-app-user) and selectProvide user access to the AWS Management Console(optional) if needed, but ensureProgrammatic access(Access key - ID and secret access key) is selected. ClickNext.Attach policies directly. Search for and select theAmazonSNSFullAccesspolicy (for simplicity in this guide) or create a custom policy granting onlysns:Publishpermissions to your specific topic ARN for better security. ClickNext.Next.Create user.1.5. Configure SNS Topic:
Topicsand clickCreate topic.Standard. FIFO topics have different considerations not covered here.whatsapp-outgoing-messages).Create topic.arn:aws:sns:us-east-1:123456789012:whatsapp-outgoing-messages.1.6. Set Up Environment Variables:
Create a
.envfile in your project root:Replace the placeholder values with your actual credentials, region, and topic ARN. For the
API_KEY, it is strongly recommended to generate a cryptographically secure random string rather than using a simple placeholder, even for development.1.7. Configure Git Ignore:
Create a
.gitignorefile to prevent committing sensitive information and build artifacts:2. Implementing core functionality
Now, let's set up the Fastify server and define the core logic for publishing messages.
2.1. Fastify Server Setup (
src/server.js):This file initializes Fastify, loads environment variables, registers routes, and starts the server.
// src/server.js 'use strict' require('dotenv').config() // Load .env variables const fastify = require('fastify')({ logger: { level: process.env.LOG_LEVEL || 'info', // Default to 'info' // Use pino-pretty for development logging readability ...(process.env.NODE_ENV !== 'production' && { transport: { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss Z', ignore: 'pid,hostname', }, }, }), }, }) const start = async () => { try { // Register routes await fastify.register(require('./routes/whatsapp'), { prefix: '/api/v1' }) // Basic health check route fastify.get('/ping', async (request, reply) => { return { pong: 'it worked!' } }) // Start listening const port = parseInt(process.env.PORT || '3000', 10) const host = process.env.HOST || '0.0.0.0' await fastify.listen({ port, host }) fastify.log.info(`Server listening on ${fastify.server.address().port}`) } catch (err) { fastify.log.error(err) process.exit(1) } } start() // Graceful shutdown const signals = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15, } Object.keys(signals).forEach((signal) => { process.on(signal, async () => { fastify.log.info(`Received ${signal}, closing server...`) await fastify.close() fastify.log.info('Server closed.') process.exit(128 + signals[signal]) }) })dotenv.config(): Loads variables from.envearly.fastify({ logger: ... }): Initializes Fastify with Pino logging. Conditionally usespino-prettyfor development readability (installpino-prettyas a dev dependency:npm install --save-dev pino-pretty). In production, it defaults to JSON logging./api/v1./pingendpoint.3. Building the API layer
We'll create the API endpoint to receive WhatsApp send requests, validate them, and trigger the SNS publish action.
3.1. Define API Route (
src/routes/whatsapp.js):This file defines the
/sendendpoint for initiating WhatsApp messages.// src/routes/whatsapp.js 'use strict' const { SNSClient, PublishCommand } = require('@aws-sdk/client-sns') // Use SDK v3 // Initialize SNS Client once (better performance than per-request) const snsClient = new SNSClient({ region: process.env.AWS_DEFAULT_REGION }) // Define the validation schema for the request body const sendBodySchema = { type: 'object', required: ['to', 'message'], properties: { to: { type: 'string', description: 'Recipient phone number in E.164 format (e.g., +14155552671)', // Basic E.164 pattern - ensure it starts with + and digits pattern: '^\\+[1-9]\\d{1,14}$', }, message: { type: 'string', description: 'The text content of the message', minLength: 1, maxLength: 1600, // WhatsApp message limit }, // Optional: Add more fields to pass via SNS if needed // e.g., templateName, languageCode, userId etc. metadata: { type: 'object' }, }, } // Define the response schema const sendResponseSchema = { '2xx': { // Covers 200, 202 etc. type: 'object', properties: { messageId: { type: 'string', description: 'AWS SNS Message ID' }, status: { type: 'string', description: 'Indicates message queued via SNS' }, }, }, // Add schemas for error responses (400, 401, 500) if desired } async function whatsappRoutes(fastify, options) { // --- Authentication Hook --- // Simple API Key check - Replace with a more robust method in production fastify.addHook('onRequest', async (request, reply) => { const apiKey = request.headers['x-api-key'] if (!apiKey || apiKey !== process.env.API_KEY) { fastify.log.warn('Unauthorized attempt to access API') // Use `return reply.code(...).send(...)` to stop processing and send response return reply.code(401).send({ error: 'Unauthorized' }) } // If execution reaches here, the hook passed }) // --- Send Message Route --- fastify.post( '/send', { schema: { description: 'Queues a WhatsApp message for sending via AWS SNS.', tags: ['whatsapp'], summary: 'Send WhatsApp message', body: sendBodySchema, response: sendResponseSchema, headers: { // Document required headers type: 'object', properties: { 'x-api-key': { type: 'string' } }, required: ['x-api-key'] } }, }, async (request, reply) => { const { to, message, metadata } = request.body const topicArn = process.env.SNS_TOPIC_ARN if (!topicArn) { request.log.error('SNS_TOPIC_ARN environment variable is not set.') return reply.code(500).send({ error: 'Internal server configuration error.' }) } // Construct the message payload for SNS // This payload will be received by the Lambda function const snsPayload = { // Standardize the payload structure recipientPhoneNumber: to, messageBody: message, // Pass through any additional metadata ...(metadata && { metadata }), } // Prepare SNS publish command using AWS SDK v3 const command = new PublishCommand({ TopicArn: topicArn, Message: JSON.stringify(snsPayload), // SNS message must be a string // Optional: Add MessageAttributes for filtering/routing in SNS subscriptions // MessageAttributes: { // messageType: { DataType: 'String', StringValue: 'whatsapp' }, // }, }) try { request.log.info(`Publishing message to SNS topic ${topicArn} for recipient ${to}`) const publishResult = await snsClient.send(command) request.log.info( `Successfully published message to SNS. Message ID: ${publishResult.MessageId}` ) // Accepted: The request is valid and queued via SNS. // Downstream systems handle actual delivery. reply.code(202).send({ messageId: publishResult.MessageId, status: 'Message queued successfully via SNS.', }) } catch (error) { request.log.error( { err: error }, // Log the full error object `Failed to publish message to SNS topic ${topicArn}` ) // Determine if it's a client error (e.g., throttling) or server error const statusCode = error.$metadata?.httpStatusCode || 500 reply .code(statusCode >= 500 ? 500 : 503) // Use 503 for transient AWS issues .send({ error: 'Failed to queue message via SNS.', details: error.message, // Include details cautiously in prod }) } } ) } module.exports = whatsappRoutessendBodySchema) to ensureto(in E.164 format) andmessageare present and valid before processing.fastify.addHook. Replace this with a proper authentication mechanism (e.g., JWT, OAuth) for production. Note the use ofreturn reply...to correctly stop processing on failure.POST /api/v1/sendendpoint.SNSClientis initialized once when the module loads for better performance.snsPayload).@aws-sdk/client-sns) directly. Creates aPublishCommandand sends it using the pre-initializedsnsClient.202 Acceptedon success, or appropriate error codes (500/503) on failure.SNS_TOPIC_ARNis configured.3.2. Testing the Endpoint:
Once the server is running (
npm start), you can test the endpoint usingcurlor Postman.Running the Server:
# For production mode (JSON logs) npm start # For development with pretty logs and auto-reload: # npm install --save-dev nodemon pino-pretty # npx nodemon src/server.js | npx pino-prettyExample cURL Request:
Replace
your-super-secret-api-keyand the phone number/message.curl -X POST http://localhost:3000/api/v1/send \ -H "Content-Type: application/json" \ -H "x-api-key: your-super-secret-api-key" \ -d '{ "to": "+14155552671", "message": "Hello from Fastify via SNS!" }'Example Success Response (202 Accepted):
{ "messageId": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", "status": "Message queued successfully via SNS." }Example Error Response (400 Bad Request - Invalid Phone):
{ "statusCode": 400, "error": "Bad Request", "message": "body/to must match pattern \"^\\\\+[1-9]\\\\d{1,14}$\"" }Example Error Response (401 Unauthorized):
{ "error": "Unauthorized" }Example Error Response (500 Internal Server Error - SNS Publish Failed):
{ "error": "Failed to queue message via SNS.", "details": "The security token included in the request is invalid." // Example detail }4. Integrating with AWS SNS
This section focuses on the specifics of the SNS integration.
4.1. Configuration Recap:
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_DEFAULT_REGION) loaded bydotenvand automatically used by the AWS SDK.SNS_TOPIC_ARNenvironment variable, used in thePublishCommand.4.2. Secure Handling of Secrets:
.envFile: Store sensitive keys (AWS_SECRET_ACCESS_KEY,API_KEY) only in the.envfile..gitignore: Ensure.envis listed in your.gitignorefile..envfiles. Inject secrets directly as environment variables through your deployment mechanism (e.g., ECS Task Definitions, Lambda Environment Variables, Kubernetes Secrets). Use tools like AWS Secrets Manager or HashiCorp Vault.4.3. Fallback Mechanisms & Retries:
4.4. AWS Console Setup Summary:
To configure the necessary AWS resources:
sns:Publishaction or theAmazonSNSFullAccessmanaged policy for simplicity). Securely store the generated Access Key ID and Secret Access Key.These credentials and the Topic ARN are then used in your
.envfile.4.5. Environment Variables Summary:
AWS_ACCESS_KEY_IDAKIAIOSFODNN7EXAMPLE)AWS_SECRET_ACCESS_KEYwJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY)AWS_DEFAULT_REGIONus-east-1,eu-west-2)SNS_TOPIC_ARNarn:aws:sns:us-east-1:123...:my-topic)PORT3000)HOST0.0.0.0for all).0.0.0.0,127.0.0.1)0.0.0.0is typical for containers/servers.API_KEYyour-super-secret-api-key)LOG_LEVELinfo).fatal,error,warn,info,debug,trace)debugin dev,infoin prod).NODE_ENVdevelopment,production).development,production)productionin deployments.5. Error Handling, Logging, and Retry Mechanisms
Robust error handling and logging are essential for production systems.
5.1. Error Handling Strategy:
onRequesthook, returning 401 Unauthorized.try...catchblock within the/sendroute handler.SNS_TOPIC_ARN) before use, returning a 500 Internal Server Error if missing.fastify.setErrorHandler()can catch unhandled exceptions, but specific handling within routes is preferred.5.2. Logging:
request.logprovides request-specific logging with request IDs.fastify.logis for general application logging.LOG_LEVEL.NODE_ENV=production) for log aggregation systems.pino-prettyfor development.MessageId).error.$metadata?.requestId).Example Logging in Route:
// Inside the /send route handler request.log.info({ recipient: to }, `Processing send request`); // ... later ... request.log.info({ recipient: to, messageId: publishResult.MessageId }, `SNS publish successful`); // ... on error ... request.log.error({ recipient: to, error: error.message, awsRequestId: error.$metadata?.requestId, err: error }, `SNS publish failed`); // Log full error object too5.3. Retry Mechanisms (Recap):
6. Database Schema and Data Layer (Optional)
Integrating a database allows tracking message status or implementing features like rate limiting.
messagestable:id,recipient_phone,message_body,sns_message_id,status('queued', 'sent', 'failed'),status_timestamp,created_at,updated_at.prisma migrate devto manage schema changes.sns_message_id,recipient_phone).7. Adding Security Features
Enhance security beyond the basic API key:
@fastify/jwt) or OAuth 2.0.@fastify/rate-limitto prevent abuse.// In server.js or a plugin await fastify.register(require('@fastify/rate-limit'), { max: 100, // Example: Max 100 requests per IP per minute timeWindow: '1 minute' })@fastify/helmetfor security-related HTTP headers.// In server.js or a plugin await fastify.register(require('@fastify/helmet'))npm auditregularly.8. Handling Special Cases
libphonenumber-js.idempotencyKey(client-generated UUID) to requests. Cache recent keys (e.g., in Redis) to detect and reject duplicates. The downstream consumer might also need duplicate detection logic.9. Implementing Performance Optimizations
SNSClientonce per module load, which is efficient.10. Adding Monitoring, Observability, and Analytics
/pingendpoint or add a/healthcheck for dependencies.11. Troubleshooting and Caveats
InvalidClientTokenId: CheckAWS_ACCESS_KEY_ID.SignatureDoesNotMatch: CheckAWS_SECRET_ACCESS_KEY.AccessDenied: Check IAM permissions (sns:Publish).TopicNotFound: IncorrectSNS_TOPIC_ARNor region mismatch.ThrottlingException: Publishing too fast. Rely on SDK retries; consider limit increases if sustained.12. Deployment and CI/CD
12.1. Deployment Options:
Container (Recommended): Package using Docker.
Serverless (Fastify on Lambda): Use
@fastify/aws-lambdafor sporadic workloads. See Fastify Serverless Guide.12.2. CI/CD Pipeline (Example using GitHub Actions):
Create
.github/workflows/deploy.yml:# .github/workflows/deploy.yml name: Deploy Fastify SNS WhatsApp App on: push: branches: [ main ] # Trigger on push to main jobs: build-and-deploy: runs-on: ubuntu-latest permissions: id-token: write # Required for configure-aws-credentials using OIDC contents: read steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '18' cache: 'npm' - name: Install dependencies run: npm ci # Add linting/testing steps # - name: Lint # run: npm run lint # - name: Test # run: npm test - name: Configure AWS Credentials (OIDC) uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::YOUR_AWS_ACCOUNT_ID:role/YourGitHubActionsRole # Replace with your IAM Role ARN aws-region: us-east-1 # Replace with your AWS region - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 - name: Build, tag, and push image to Amazon ECR env: ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} ECR_REPOSITORY: your-ecr-repo-name # Replace with your ECR repo name IMAGE_TAG: ${{ github.sha }} run: | docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG # Add deployment steps (e.g., update ECS service, deploy to App Runner) # - name: Deploy to AWS App Runner # run: aws apprunner start-deployment --service-arn ${{ secrets.APP_RUNNER_SERVICE_ARN }}APP_RUNNER_SERVICE_ARNif used) in GitHub Actions secrets.13. Verification and Testing
13.1. Unit Tests:
Test components in isolation, mocking external services like SNS. Use
tap, Jest, etc.proxyquireis useful for mocking dependencies.npm install --save-dev tap proxyquirepackage.json:"test": "tap test/**/*.test.js"Example Unit Test (
test/routes/whatsapp.test.js):// test/routes/whatsapp.test.js 'use strict' const { test } = require('tap') const Fastify = require('fastify') const proxyquire = require('proxyquire') // To mock dependencies // Mock the AWS SDK v3 client and command const mockSNSClientInstance = { send: async (command) => { // Basic validation of command input for testing if (!command.input.TopicArn || !command.input.Message) { throw new Error('Missing TopicArn or Message') } if (command.input.TopicArn !== 'arn:aws:sns:us-east-1:123456789012:test-topic') { throw new Error('Incorrect TopicArn') } // Simulate successful SNS publish return { MessageId: 'mock-message-id-123', $metadata: { httpStatusCode: 200 } } } } const MockSNSClient = function() { return mockSNSClientInstance } // Use proxyquire to inject the mock SDK into the routes module const whatsappRoutes = proxyquire('../../src/routes/whatsapp', { '@aws-sdk/client-sns': { SNSClient: MockSNSClient, // Replace real client with mock constructor PublishCommand: function(input) { this.input = input } // Mock command constructor } }) // --- Test Suite --- test('POST /api/v1/send - success', async (t) => { const fastify = Fastify() // Set up necessary environment variables for the test context process.env.API_KEY = 'test-api-key' process.env.SNS_TOPIC_ARN = 'arn:aws:sns:us-east-1:123456789012:test-topic' process.env.AWS_DEFAULT_REGION = 'us-east-1' // Needed for SNSClient mock setup fastify.register(whatsappRoutes, { prefix: '/api/v1' }) const response = await fastify.inject({ method: 'POST', url: '/api/v1/send', headers: { 'content-type': 'application/json', 'x-api-key': 'test-api-key' }, payload: { to: '+15551234567', message: 'Test message' } }) t.equal(response.statusCode, 202, 'should return status code 202') const body = JSON.parse(response.payload) t.ok(body.messageId, 'should return a messageId') t.equal(body.messageId, 'mock-message-id-123', 'should return the mocked messageId') t.equal(body.status, 'Message queued successfully via SNS.', 'should return correct status message') // Clean up env vars if necessary, though tap runs tests in separate processes delete process.env.API_KEY delete process.env.SNS_TOPIC_ARN delete process.env.AWS_DEFAULT_REGION }) test('POST /api/v1/send - unauthorized (missing API key)', async (t) => { const fastify = Fastify() process.env.API_KEY = 'test-api-key' // API key is set process.env.SNS_TOPIC_ARN = 'arn:aws:sns:us-east-1:123456789012:test-topic' process.env.AWS_DEFAULT_REGION = 'us-east-1' fastify.register(whatsappRoutes, { prefix: '/api/v1' }) const response = await fastify.inject({ method: 'POST', url: '/api/v1/send', headers: { 'content-type': 'application/json' // No x-api-key header }, payload: { to: '+15551234567', message: 'Test message' } }) t.equal(response.statusCode, 401, 'should return status code 401') const body = JSON.parse(response.payload) t.same(body, { error: 'Unauthorized' }, 'should return unauthorized error') delete process.env.API_KEY delete process.env.SNS_TOPIC_ARN delete process.env.AWS_DEFAULT_REGION }) test('POST /api/v1/send - validation error (invalid phone)', async (t) => { const fastify = Fastify() process.env.API_KEY = 'test-api-key' process.env.SNS_TOPIC_ARN = 'arn:aws:sns:us-east-1:123456789012:test-topic' process.env.AWS_DEFAULT_REGION = 'us-east-1' fastify.register(whatsappRoutes, { prefix: '/api/v1' }) const response = await fastify.inject({ method: 'POST', url: '/api/v1/send', headers: { 'content-type': 'application/json', 'x-api-key': 'test-api-key' }, payload: { to: 'invalid-phone-number', // Invalid format message: 'Test message' } }) t.equal(response.statusCode, 400, 'should return status code 400') const body = JSON.parse(response.payload) t.equal(body.error, 'Bad Request', 'should return Bad Request error') t.ok(body.message.includes('body/to must match pattern'), 'should indicate pattern mismatch for phone') delete process.env.API_KEY delete process.env.SNS_TOPIC_ARN delete process.env.AWS_DEFAULT_REGION }) // Add more tests for SNS publish errors, missing config, etc.