Build a Scalable Bulk SMS System with RedwoodJS and AWS SNS
A comprehensive guide on creating a bulk SMS broadcasting application using RedwoodJS, AWS SNS, Prisma, and PostgreSQL, including setup, implementation, and security.
This guide provides a complete walkthrough for building a production-ready bulk SMS broadcasting system using the RedwoodJS framework and Amazon Simple Notification Service (AWS SNS).
We'll build an application that enables authenticated users to send a single message to a list of contacts stored in a database. This solves the common need for businesses to efficiently distribute notifications, alerts, or marketing messages via SMS.
Technologies Used:
RedwoodJS: A full-stack JavaScript/TypeScript framework for building modern web applications. Chosen for its integrated GraphQL API, Prisma ORM, and developer experience.
AWS Simple Notification Service (SNS): A fully managed messaging service for both application-to-application (A2A) and application-to-person (A2P) communication. Chosen for its scalability, reliability, and direct SMS sending capabilities.
PostgreSQL: A powerful, open-source object-relational database system. (Other databases supported by Prisma can also be used).
Prisma: A next-generation Node.js and TypeScript ORM for database access.
AWS SDK for JavaScript v3: Used to interact with AWS SNS programmatically.
By the end of this guide, you will have a RedwoodJS application with:
A database schema for storing contacts (name, phone number).
A secure GraphQL API endpoint for triggering bulk SMS messages.
Integration with AWS SNS to send messages efficiently using batching.
Basic error handling and logging.
Prerequisites:
Node.js (v18 or later recommended)
Yarn (v1 or later)
An AWS account with permissions to manage SNS and IAM.
Basic understanding of RedwoodJS, GraphQL, and AWS concepts.
AWS CLI configured locally (optional but helpful for verification).
1. Setting up the RedwoodJS Project
Let's initialize a new RedwoodJS project and install necessary dependencies.
Create RedwoodJS App:
Open your terminal and run the following command. Choose TypeScript when prompted.
Bash
yarn create redwood-app redwood-sns-bulk-sms
Navigate to Project Directory:
Bash
cd redwood-sns-bulk-sms
Install AWS SDK v3 for SNS:
We need the specific client package for SNS.
Bash
yarn workspace api add @aws-sdk/client-sns
Why @aws-sdk/client-sns? The AWS SDK v3 is modular. Installing only the SNS client keeps our dependency footprint smaller compared to installing the entire SDK.
Setup Database:
RedwoodJS uses Prisma. By default, it's configured for SQLite. Let's switch to PostgreSQL (adjust connection string for your setup).
Edit api/db/schema.prisma: Change the provider in the datasource block.
Create a .env file in the project root (if it doesn't exist) and add your PostgreSQL connection string:
.env
# ./.env
DATABASE_URL=""postgresql://user:password@host:port/database?schema=public""
# Replace with your actual connection details
Explanation: The schema.prisma file defines our database connection and data models. The .env file stores sensitive information like database credentials, keeping them out of version control.
Initial Database Migration:
Apply the initial schema setup to your database.
Bash
yarn rw prisma migrate dev
# Follow prompts to name the migration (e.g., ""init"")
Your basic RedwoodJS project structure is now ready. The api directory contains backend code (GraphQL API, services, database schema), and the web directory contains frontend code (React components, pages).
2. Implementing Core Functionality (Backend Service & SNS Client)
We'll create a reusable SNS client and a service function to handle the logic of sending bulk messages.
Configure AWS Credentials Securely:
The AWS SDK needs credentials to interact with your account. We'll use environment variables, which RedwoodJS automatically loads from the .env file in development. Never hardcode credentials in your source code.
Add the following to your .env file (obtain these from your AWS IAM user setup - see Section 4):
.env
# ./.env
# Add these lines
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
Explanation: The SDK automatically detects AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_REGION (or AWS_DEFAULT_REGION) environment variables. Storing them here keeps them secure and environment-specific.
Create an SNS Client Library:
This centralizes the SNS client initialization.
Create a new file: api/src/lib/snsClient.js
JavaScript
// api/src/lib/snsClient.jsimport { SNSClient } from""@aws-sdk/client-sns"";
import { logger } from""src/lib/logger""; // Import Redwood logger// The AWS SDK v3 automatically reads credentials from environment variables// (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION)// or IAM roles if deployed on AWS infrastructure.const region = process.env.AWS_REGION;
if (!region) {
logger.warn(
""AWS_REGION environment variable not set. SNSClient might not initialize correctly.""
);
// Consider throwing an error in production if region is mandatory
}
exportconst snsClient = newSNSClient({ region });
logger.info(""AWSSNSClient initialized."");
Explanation: We import the SNSClient, instantiate it (optionally passing the region), and export it. The Redwood logger is used for basic initialization feedback.
Define the Contact Data Model:
We need a way to store the phone numbers we want to message.
Edit api/db/schema.prisma and add the Contact model:
Prisma
// api/db/schema.prisma
// Keep the datasource and generator blocks from before
model Contact {
id Int @id @default(autoincrement())
name String? // Optional name
phone String @unique // E.164 format recommended, ensure uniqueness
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Explanation: Defines a simple Contact table with an ID, an optional name, a unique phone number, and timestamps. Using @unique on phone prevents duplicate entries.
Apply Database Migration:
Create and apply a migration for the new Contact model.
Bash
yarn rw prisma migrate dev
# Name the migration (e.g., ""add contact model"")
Generate Contact Scaffolding (Optional but helpful):
Redwood can generate basic CRUD operations (Create, Read, Update, Delete) for the Contact model. This gives us a service and SDL definitions automatically.
api/src/services/contacts/contacts.js (Service functions for DB interaction)
Frontend pages/components in web/src (we won't focus on the UI here, but they are generated)
Create the Messaging Service:
This service will contain the core logic for fetching contacts and sending the bulk SMS via SNS.
Generate a new service:
Bash
yarn rw generate service messaging
Edit api/src/services/messaging/messaging.js:
JavaScript
// api/src/services/messaging/messaging.jsimport { PublishBatchCommand } from""@aws-sdk/client-sns"";
import { UserInputError } from""@redwoodjs/graphql-server"";
import { db } from""src/lib/db"";
import { logger } from""src/lib/logger"";
import { snsClient } from""src/lib/snsClient""; // Import our initialized clientimport { requireAuth } from""src/lib/auth""; // We'll add auth later// E.164 regex (basic example; for robust validation, consider using libphonenumber-js as discussed in Section 8)constE164_REGEX = /^\+[1-9]\d{1,14}$/;
// Max batch size for SNS PublishBatchconstMAX_BATCH_SIZE = 10;
exportconstsendBulkSms = async ({ message }) => {
requireAuth(); // Ensure user is logged inif (!message || message.trim().length === 0) {
thrownewUserInputError(""Message content cannot be empty."");
}
// Consider adding length checks for SMS here (e.g., 160 chars for standard SMS)
logger.info(""Initiating bulk SMS send job..."");
let contacts = []; // Initialize contacts arraytry {
contacts = await db.contact.findMany({
select: { id: true, phone: true }, // Only select necessary fields
});
if (!contacts || contacts.length === 0) {
logger.warn(""No contacts found in the database. AbortingSMS job."");
return {
successCount: 0,
failureCount: 0,
failedNumbers: [],
message: ""No contacts found to send messages to."",
};
}
logger.info(`Found ${contacts.length} contacts. Preparing batches...`);
let successfulSends = 0;
let failedSends = 0;
const failedNumbersDetails = []; // Store { number, reason }// Process contacts in batches of MAX_BATCH_SIZEfor (let i = 0; i < contacts.length; i += MAX_BATCH_SIZE) {
const batch = contacts.slice(i, i + MAX_BATCH_SIZE);
const batchEntries = batch
.map((contact, index) => {
// Validate phone number format before adding to batchif (!E164_REGEX.test(contact.phone)) {
logger.warn(
`Invalid phone number format skipped: ${contact.phone}`
);
failedSends++;
failedNumbersDetails.push({
number: contact.phone,
reason: ""Invalid E.164Format"",
});
returnnull; // Skip this entry
}
return {
Id: `msg_${contact.id}_${index}`, // Unique ID within the batchPhoneNumber: contact.phone,
// Optional: Add MessageAttributes if needed// MessageAttributes: {// 'AWS.SNS.SMS.SMSType': { DataType: 'String', StringValue: 'Transactional' }, // Or 'Promotional'// // Add other attributes if necessary// }
};
})
.filter((entry) => entry !== null); // Remove null entries (invalid numbers)if (batchEntries.length === 0) {
logger.warn(`Skipping empty batch starting at index ${i}.`);
continue; // Nothing to send in this batch
}
const command = newPublishBatchCommand({
PublishBatchRequestEntries: batchEntries,
Message: message, // The actual SMS content
});
try {
logger.info(`Sending batch of ${batchEntries.length} messages...`);
const result = await snsClient.send(command);
// Process resultsif (result.Successful) {
successfulSends += result.Successful.length;
// Optional: Log successful IDs: result.Successful.map(s => s.Id)
}
if (result.Failed) {
failedSends += result.Failed.length;
result.Failed.forEach((failure) => {
// Find the original phone number from the batch using failure.Idconst originalEntry = batchEntries.find(
(entry) => entry.Id === failure.Id
);
const failedNumber = originalEntry
? originalEntry.PhoneNumber
: ""UnknownID: "" + failure.Id;
logger.error(
`Failed to send to ${failedNumber}: ${failure.Code} - ${failure.Message} (SenderFault: ${failure.SenderFault})`
);
failedNumbersDetails.push({
number: failedNumber,
reason: `${failure.Code}: ${failure.Message}`,
});
});
}
logger.info(
`Batch finished. Success: ${
result.Successful?.length || 0
}, Failed: ${result.Failed?.length || 0}`
);
} catch (batchError) {
// Handle errors sending the entire batch
logger.error(
`Error sending SMS batch starting at index ${i}:`,
batchError
);
// Increment failure count for all intended recipients in this batchconst attemptedCount = batchEntries.length;
failedSends += attemptedCount;
batchEntries.forEach((entry) => {
failedNumbersDetails.push({
number: entry.PhoneNumber,
reason: `Batch Send Error: ${batchError.message || batchError.name}`,
});
});
}
} // End of batch loop
logger.info(
`Bulk SMS job completed. Total Success: ${successfulSends}, Total Failed: ${failedSends}`
);
return {
successCount: successfulSends,
failureCount: failedSends,
failedNumbers: failedNumbersDetails, // Return detailed failuresmessage: `SMS Job Finished. Sent: ${successfulSends}, Failed: ${failedSends}.`,
};
} catch (error) {
logger.error(""Error during bulk SMSprocessing:"", error);
// Generic error for the entire processreturn {
successCount: 0,
failureCount: contacts?.length || 0, // Assume all failed if error before/during loopfailedNumbers: [],
message: `An unexpected error occurred: ${error.message}`,
error: true, // Indicate a top-level error occurred
};
}
};
Explanation:
Imports necessary modules: PublishBatchCommand from AWS SDK, db for database access, logger, our snsClient, and UserInputError for GraphQL validation errors.
Defines MAX_BATCH_SIZE (SNS limit is 10 for PublishBatch).
The sendBulkSms function takes the message content as input.
It fetches all contacts from the DB (selecting only id and phone).
It iterates through contacts in batches.
Phone Number Validation: Includes a basic check using E164_REGEX before adding a number to a batch. Invalid numbers are skipped and logged. (Reminds user about Section 8 for better validation).
It constructs PublishBatchRequestEntries for each valid contact in the batch, ensuring a unique Id for each entry.
It creates and sends a PublishBatchCommand using snsClient.send().
Batch Result Handling: It processes the Successful and Failed arrays from the SNS response, logging details about failures and updating counters. It attempts to map failure IDs back to phone numbers for better reporting.
Error Handling: Includes try...catch blocks for both individual batch sends and the overall process.
Returns a summary object with success/failure counts and details on failed numbers.
requireAuth() is added (we'll set up auth next).
3. Building the API Layer (GraphQL Mutation)
We need a way for the frontend (or external clients) to trigger the sendBulkSms service function. We'll use a GraphQL mutation.
Define the GraphQL Mutation:
Edit api/src/graphql/messaging.sdl.js:
GraphQL
# api/src/graphql/messaging.sdl.jstype SendBulkSmsFailure {number: String!reason: String!}type SendBulkSmsResponse {successCount: Int!failureCount: Int!message: String!failedNumbers:[SendBulkSmsFailure!]# Return detailserror: Boolean # Optional flag for top-level errors}typeMutation{# Mutation to trigger sending SMS to all contacts
sendBulkSms(message: String!): SendBulkSmsResponse!@requireAuth}
Explanation:
Defines a custom SendBulkSmsFailure type to structure failure details.
Defines a SendBulkSmsResponse type to structure the return value of our mutation, including success/failure counts and the detailed failures list.
Defines the sendBulkSms mutation within the Mutation type. It accepts a required message string.
Applies the @requireAuth directive to ensure only authenticated users can call this mutation.
Implement Basic Authentication (dbAuth):
Redwood makes setting up simple database authentication straightforward.
Run the setup command:
Bash
yarn rw setup auth dbAuth
This command:
Adds necessary auth models (User, UserCredential, UserRole) to schema.prisma.
Creates auth-related service (api/src/services/users) and GraphQL definitions (api/src/graphql/users.sdl.js).
Sets up auth functions (api/src/lib/auth.js).
Adds frontend pages for login/signup (web/src/pages/LoginPage, web/src/pages/SignupPage, etc.) and updates routing.
Apply Auth Migrations: Run the migration to add the new auth tables to your database.
Bash
yarn rw prisma migrate dev
# Name the migration (e.g., ""add dbAuth models"")
Create a Test User: You'll need a user to test the authenticated mutation. You can either:
Start the dev server (yarn rw dev) and navigate to the signup page (/signup) in your browser.
Or, use yarn rw prisma studio to add a user directly to the database (remember to hash the password correctly if adding manually, or use the signup page).
Link SDL to Service:
Redwood automatically maps the sendBulkSms mutation in messaging.sdl.js to the sendBulkSms function exported from api/src/services/messaging/messaging.js because the names match. No extra resolver code is needed here.
Testing the Mutation (GraphQL Playground):
Start the Redwood development server:
Bash
yarn rw dev
Open your browser to http://localhost:8911/graphql.
Authentication Header: You need to simulate being logged in.
Log in via the web interface (/login).
Open your browser's developer tools (Network tab).
Find a GraphQL request (e.g., one made after logging in).
Copy the value of the auth-provider header (should be dbAuth).
Copy the value of the authorization header (should be Bearer <session-token>).
In the GraphQL Playground, click the ""HTTP HEADERS"" tab at the bottom left.
Execute the Mutation: Run the following mutation in the Playground:
GraphQL
mutation SendTestBulkSms {
sendBulkSms(message:""Hello from Redwood SNS Bulk Sender!""){
successCount
failureCount
message
failedNumbers {
number
reason
}}}
Check the response in the Playground and the logs in your terminal (yarn rw dev output) to see the outcome. You should see logs indicating contacts being fetched, batches being sent, and the final result.
4. Integrating with AWS SNS (IAM & Setup)
Properly configuring AWS permissions is crucial for security and functionality.
Create an IAM User for Your Application:
It's best practice to create a dedicated IAM user with the minimum necessary permissions rather than using your root AWS account credentials.
Navigate to the IAM console in AWS.
Go to Users -> Create user.
Enter a User name (e.g., redwood-sns-app-user).
Select Provide user access to the AWS Management Console - Optional. If you select this, choose a password option.
Click Next.
Choose Attach policies directly.
Search for and select the AmazonSNSFullAccess policy. Note: For production, create a custom policy granting only sns:Publish and sns:PublishBatch permissions, potentially restricted to specific regions or topics if applicable. AmazonSNSFullAccess is simpler for this guide but too permissive for production.
JSON
// Example Custom Policy (More Secure){""Version"":""2012-10-17"",""Statement"":[{""Effect"":""Allow"",""Action"":[""sns:Publish"",""sns:PublishBatch""],""Resource"":""*""// Restrict further by ARN if possible: ""arn:aws:sns:REGION:ACCOUNT_ID:*""}]}
Click Next.
Review the user details and policy, then click Create user.
Generate Access Keys:
After the user is created, click on the username to go to the user's summary page.
Go to the Security credentials tab.
Scroll down to Access keys and click Create access key.
Select Application running outside AWS as the use case.
Click Next.
(Optional) Add a description tag (e.g., redwood-sns-bulk-sms-key).
Click Create access key.
CRITICAL: Copy the Access key ID and Secret access key immediately and store them securely. You cannot retrieve the secret key again after closing this screen.
Add Credentials to .env:
Open your project's .env file.
Paste the copied keys into the respective variables:
.env
# ./.env
DATABASE_URL=""..."" # Keep existing vars
AWS_ACCESS_KEY_ID=""PASTE_YOUR_ACCESS_KEY_ID_HERE""
AWS_SECRET_ACCESS_KEY=""PASTE_YOUR_SECRET_ACCESS_KEY_HERE""
AWS_REGION=""us-east-1"" # Ensure this matches your desired region
Restart Development Server:
If your dev server (yarn rw dev) is running, stop it (Ctrl+C) and restart it to ensure it picks up the new environment variables from .env.
Bash
yarn rw dev
Your application is now configured to authenticate with AWS SNS using the dedicated IAM user's credentials.
5. Implementing Error Handling, Logging, and Retries
Robust error handling and logging are essential for production systems.
Error Handling (Already Implemented):
Input Validation: The service function (sendBulkSms) already includes checks for an empty message (UserInputError) and basic E.164 phone number format validation.
SNS Batch Errors: The code iterates through the Failed array in the PublishBatch response, logs each specific failure, and includes details in the mutation response.
General Exceptions: The main try...catch block in sendBulkSms catches unexpected errors during the process (e.g., database connection issues, SDK client errors).
Authentication: The @requireAuth directive handles unauthorized access attempts.
Logging (Already Implemented):
RedwoodJS's built-in logger (api/src/lib/logger.js) is used throughout the sendBulkSms service.
We log:
Job initiation and completion.
Number of contacts found.
Batch processing progress.
Specific send failures with reasons from SNS.
Invalid phone numbers skipped.
Overall success/failure counts.
Major exceptions.
Configuration: You can configure Redwood's logger levels and destinations (e.g., console, file, third-party services) in api/src/lib/logger.js. For production, you'd typically set the level to info or warn and potentially log to a file or a log aggregation service.
Retry Mechanisms:
SNS Internal Retries: AWS SNS itself has built-in retry mechanisms for transient delivery issues when sending SMS messages. You generally don't need to implement complex retry logic for the final delivery attempt from SNS to the carrier.
Application-Level Retries (for PublishBatch): The PublishBatch API call itself might fail due to transient network issues or AWS throttling. Our current code logs batch errors but doesn't automatically retry the entire batch.
Implementing Retries (Optional Enhancement): To add retries for failed PublishBatch calls, you could wrap the snsClient.send(command) call within a loop with exponential backoff:
JavaScript
// Inside the batch loop in api/src/services/messaging/messaging.jsconstMAX_RETRIES = 3;
let attempt = 0;
let batchResult = null;
let lastBatchError = null;
while (attempt < MAX_RETRIES && !batchResult) {
try {
logger.info(`Sending batch (Attempt ${attempt + 1}/${MAX_RETRIES})...`);
batchResult = await snsClient.send(command);
// Success, break loop
} catch (error) {
lastBatchError = error;
logger.warn(`Batch send attempt ${attempt + 1} failed: ${error.name} - ${error.message}`);
if (attempt + 1 < MAX_RETRIES) {
// Exponential backoff: wait 1s, 2s, 4s...const delay = Math.pow(2, attempt) * 1000;
logger.info(`Retrying batch in ${delay / 1000}s...`);
awaitnewPromise(resolve =>setTimeout(resolve, delay));
}
attempt++;
}
}
if (!batchResult && lastBatchError) {
// Handle final failure after retries (log, increment counters as before)
logger.error(`Error sending SMS batch starting at index ${i} after ${MAX_RETRIES} attempts:`, lastBatchError);
// ... update failedSends and failedNumbersDetails for the whole batch ...continue; // Move to next batch
}
// Process the successful batchResult as before...if (batchResult.Successful) { /* ... */ }
if (batchResult.Failed) { /* ... */ }
Consideration: Adding retries increases complexity. Evaluate if the potential benefits outweigh the added code, especially given SNS's own reliability. For critical messages, retrying might be necessary. Also consider idempotency if retrying.
6. Creating Database Schema and Data Layer
This was largely covered during the setup and core functionality implementation.
Schema Definition (api/db/schema.prisma):
We defined the Contact model and the dbAuth models.
Prisma
// api/db/schema.prisma recap
datasource db {
provider = ""postgresql""
url = env(""DATABASE_URL"")
}
generator client {
provider = ""prisma-client-js""
binaryTargets = ""native""
}
model Contact {
id Int @id @default(autoincrement())
name String?
phone String @unique // E.164 format recommended
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// --- dbAuth Models ---
// RedwoodJS automatically adds these via `yarn rw setup auth dbAuth`
// model User { ... }
// model UserCredential { ... }
// model UserRole { ... }
Data Access Layer:
RedwoodJS uses Prisma Client as the data access layer. The db object imported from src/lib/db provides typed access to your database models.
Example usage in messaging.js: await db.contact.findMany(...)
The contacts service (api/src/services/contacts/contacts.js) generated by the scaffold command provides standard CRUD operations.
Migrations:
We used yarn rw prisma migrate dev to apply schema changes. This command:
Compares the schema.prisma file to the database state.
Generates SQL migration files in api/db/migrations.
Applies the migration to the development database.
Updates the Prisma Client types.
For production deployments, you'll typically use yarn rw prisma migrate deploy.
Sample Data Population (Seeding):
To easily add test contacts, you can use Prisma's seeding feature.
Create a seed file: api/db/seed.js
JavaScript
// api/db/seed.jsimport { db } from'src/lib/db'// Import any other libraries you need// Use ""+COUNTRYCODE AREACODE NUMBER"" format (E.164)constCONTACTS_TO_SEED = [
{ name: 'Test User One', phone: '+15551234567' },
{ name: 'Test User Two', phone: '+15559876543' },
{ name: 'Test User Three', phone: '+447700900123' }, // Example UK number
{ name: 'Invalid Number User', phone: '555-INVALID'}, // To test validation
]
exportdefaultasync () => {
try {
console.log('Seeding contacts...')
// Use Prisma Promise API for potential performance improvement with many recordsawait db.$transaction(
CONTACTS_TO_SEED.map((contact) =>
db.contact.upsert({
where: { phone: contact.phone }, // Avoid duplicates based on phoneupdate: { name: contact.name }, // Update name if phone existscreate: contact,
})
)
)
console.log(`Seeded ${CONTACTS_TO_SEED.length} contacts (or updated existing).`);
// Add other seed data if needed (e.g., default UserRoles)
} catch (error) {
console.error('Error seeding database:', error)
// Avoid exiting with error code 1 in dev to prevent Prisma migrate prompts// process.exit(1)
}
}
Run the seed command (this automatically runs after migrate dev or can be run manually):
Bash
yarn rw prisma db seed
7. Adding Security Features
Security is paramount, especially when dealing with potentially sensitive contact information and incurring costs via AWS services.
Frequently asked questions
You can send bulk SMS messages by creating a RedwoodJS application that integrates with AWS SNS. This involves setting up a GraphQL API endpoint connected to a service function that interacts with the AWS SDK. This function handles sending messages in batches to contacts stored in your database.
RedwoodJS is the core framework for building the bulk SMS application. It provides structure, routing, GraphQL API capabilities, and integration with Prisma ORM for database interactions. This simplifies development and provides a cohesive full-stack experience.
AWS SNS is chosen for its scalability and reliability in handling message delivery. It's a fully managed service, meaning AWS handles the infrastructure, allowing developers to focus on application logic. SNS also provides direct SMS sending capabilities.
First, create a new RedwoodJS project using yarn create redwood-app. Then, install the necessary AWS SDK modules and configure your database connection in schema.prisma. Finally, set up authentication using Redwood's built-in dbAuth and define a GraphQL mutation to trigger the SMS sending process.
Prisma acts as the Object-Relational Mapper (ORM) allowing RedwoodJS to interact with the database. It simplifies database operations by providing a type-safe and easy-to-use API for querying and managing data, such as fetching contact information.
The PublishBatch API is ideal for sending messages to multiple recipients simultaneously. This optimizes the sending process and is more efficient than individual API calls for each message. However, keep in mind that SNS limits batch sizes to 10 recipients.
Yes, Prisma supports other databases besides PostgreSQL. You can configure the database connection by editing the provider field in your schema.prisma file and updating the DATABASE_URL environment variable accordingly.
Store your AWS credentials (Access Key ID and Secret Access Key) as environment variables in a .env file. RedwoodJS loads these variables automatically in development. Never hardcode credentials directly in your code.
The maximum batch size for the PublishBatch API in AWS SNS is 10. The provided code enforces this with a MAX_BATCH_SIZE constant. Exceeding this limit will cause errors.
The example code provides error handling at multiple levels: input validation for empty messages, individual message failures within a batch using the SNS response's Failed array, batch-level error handling with retries, and overall process errors using try-catch blocks. Ensure logging is configured for monitoring.
The recommended format is E.164, which includes a plus sign (+) followed by the country code, area code, and local number. The code includes a basic regular expression, and Section 8 in the original article was meant to have better validation. For robust validation, consider a dedicated library like libphonenumber-js.
Using dedicated IAM users with limited permissions is crucial for security. It reduces the potential damage from compromised credentials and ensures least privilege access. In production, create custom IAM policies to restrict access only to essential SNS actions (like Publish and PublishBatch).
The dbAuth setup protects the bulk SMS sending functionality. It ensures that only authorized users can access and trigger the GraphQL mutation responsible for sending messages, preventing unauthorized access.
While the code provides a basic regular expression for E.164 validation, consider using a specialized library like libphonenumber-js to enhance this process. A library handles various number formats and provides more accurate validation than regex alone.
Centralizing SNS client initialization in a separate library (snsClient.js) improves code organization and modularity. It makes it easier to manage and update the SNS client configuration across your application.
This guide provides a complete walkthrough for building a production-ready bulk SMS broadcasting system using the RedwoodJS framework and Amazon Simple Notification Service (AWS SNS).
We'll build an application that enables authenticated users to send a single message to a list of contacts stored in a database. This solves the common need for businesses to efficiently distribute notifications, alerts, or marketing messages via SMS.
Technologies Used:
System Architecture:
Final Outcome:
By the end of this guide, you will have a RedwoodJS application with:
Prerequisites:
1. Setting up the RedwoodJS Project
Let's initialize a new RedwoodJS project and install necessary dependencies.
Create RedwoodJS App: Open your terminal and run the following command. Choose TypeScript when prompted.
Navigate to Project Directory:
cd redwood-sns-bulk-smsInstall AWS SDK v3 for SNS: We need the specific client package for SNS.
Why
@aws-sdk/client-sns? The AWS SDK v3 is modular. Installing only the SNS client keeps our dependency footprint smaller compared to installing the entire SDK.Setup Database: RedwoodJS uses Prisma. By default, it's configured for SQLite. Let's switch to PostgreSQL (adjust connection string for your setup).
Edit
api/db/schema.prisma: Change theproviderin thedatasourceblock.Create a
.envfile in the project root (if it doesn't exist) and add your PostgreSQL connection string:Explanation: The
schema.prismafile defines our database connection and data models. The.envfile stores sensitive information like database credentials, keeping them out of version control.Initial Database Migration: Apply the initial schema setup to your database.
yarn rw prisma migrate dev # Follow prompts to name the migration (e.g., ""init"")Your basic RedwoodJS project structure is now ready. The
apidirectory contains backend code (GraphQL API, services, database schema), and thewebdirectory contains frontend code (React components, pages).2. Implementing Core Functionality (Backend Service & SNS Client)
We'll create a reusable SNS client and a service function to handle the logic of sending bulk messages.
Configure AWS Credentials Securely: The AWS SDK needs credentials to interact with your account. We'll use environment variables, which RedwoodJS automatically loads from the
.envfile in development. Never hardcode credentials in your source code.Add the following to your
.envfile (obtain these from your AWS IAM user setup - see Section 4):Explanation: The SDK automatically detects
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY, andAWS_REGION(orAWS_DEFAULT_REGION) environment variables. Storing them here keeps them secure and environment-specific.Create an SNS Client Library: This centralizes the SNS client initialization.
Create a new file:
api/src/lib/snsClient.js// api/src/lib/snsClient.js import { SNSClient } from ""@aws-sdk/client-sns""; import { logger } from ""src/lib/logger""; // Import Redwood logger // The AWS SDK v3 automatically reads credentials from environment variables // (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION) // or IAM roles if deployed on AWS infrastructure. const region = process.env.AWS_REGION; if (!region) { logger.warn( ""AWS_REGION environment variable not set. SNS Client might not initialize correctly."" ); // Consider throwing an error in production if region is mandatory } export const snsClient = new SNSClient({ region }); logger.info(""AWS SNS Client initialized."");Explanation: We import the
SNSClient, instantiate it (optionally passing the region), and export it. The Redwood logger is used for basic initialization feedback.Define the Contact Data Model: We need a way to store the phone numbers we want to message.
Edit
api/db/schema.prismaand add theContactmodel:Explanation: Defines a simple
Contacttable with an ID, an optional name, a unique phone number, and timestamps. Using@uniqueonphoneprevents duplicate entries.Apply Database Migration: Create and apply a migration for the new
Contactmodel.yarn rw prisma migrate dev # Name the migration (e.g., ""add contact model"")Generate Contact Scaffolding (Optional but helpful): Redwood can generate basic CRUD operations (Create, Read, Update, Delete) for the
Contactmodel. This gives us a service and SDL definitions automatically.api/src/graphql/contacts.sdl.js(GraphQL schema definitions)api/src/services/contacts/contacts.js(Service functions for DB interaction)web/src(we won't focus on the UI here, but they are generated)Create the Messaging Service: This service will contain the core logic for fetching contacts and sending the bulk SMS via SNS.
Generate a new service:
Edit
api/src/services/messaging/messaging.js:// api/src/services/messaging/messaging.js import { PublishBatchCommand } from ""@aws-sdk/client-sns""; import { UserInputError } from ""@redwoodjs/graphql-server""; import { db } from ""src/lib/db""; import { logger } from ""src/lib/logger""; import { snsClient } from ""src/lib/snsClient""; // Import our initialized client import { requireAuth } from ""src/lib/auth""; // We'll add auth later // E.164 regex (basic example; for robust validation, consider using libphonenumber-js as discussed in Section 8) const E164_REGEX = /^\+[1-9]\d{1,14}$/; // Max batch size for SNS PublishBatch const MAX_BATCH_SIZE = 10; export const sendBulkSms = async ({ message }) => { requireAuth(); // Ensure user is logged in if (!message || message.trim().length === 0) { throw new UserInputError(""Message content cannot be empty.""); } // Consider adding length checks for SMS here (e.g., 160 chars for standard SMS) logger.info(""Initiating bulk SMS send job...""); let contacts = []; // Initialize contacts array try { contacts = await db.contact.findMany({ select: { id: true, phone: true }, // Only select necessary fields }); if (!contacts || contacts.length === 0) { logger.warn(""No contacts found in the database. Aborting SMS job.""); return { successCount: 0, failureCount: 0, failedNumbers: [], message: ""No contacts found to send messages to."", }; } logger.info(`Found ${contacts.length} contacts. Preparing batches...`); let successfulSends = 0; let failedSends = 0; const failedNumbersDetails = []; // Store { number, reason } // Process contacts in batches of MAX_BATCH_SIZE for (let i = 0; i < contacts.length; i += MAX_BATCH_SIZE) { const batch = contacts.slice(i, i + MAX_BATCH_SIZE); const batchEntries = batch .map((contact, index) => { // Validate phone number format before adding to batch if (!E164_REGEX.test(contact.phone)) { logger.warn( `Invalid phone number format skipped: ${contact.phone}` ); failedSends++; failedNumbersDetails.push({ number: contact.phone, reason: ""Invalid E.164 Format"", }); return null; // Skip this entry } return { Id: `msg_${contact.id}_${index}`, // Unique ID within the batch PhoneNumber: contact.phone, // Optional: Add MessageAttributes if needed // MessageAttributes: { // 'AWS.SNS.SMS.SMSType': { DataType: 'String', StringValue: 'Transactional' }, // Or 'Promotional' // // Add other attributes if necessary // } }; }) .filter((entry) => entry !== null); // Remove null entries (invalid numbers) if (batchEntries.length === 0) { logger.warn(`Skipping empty batch starting at index ${i}.`); continue; // Nothing to send in this batch } const command = new PublishBatchCommand({ PublishBatchRequestEntries: batchEntries, Message: message, // The actual SMS content }); try { logger.info(`Sending batch of ${batchEntries.length} messages...`); const result = await snsClient.send(command); // Process results if (result.Successful) { successfulSends += result.Successful.length; // Optional: Log successful IDs: result.Successful.map(s => s.Id) } if (result.Failed) { failedSends += result.Failed.length; result.Failed.forEach((failure) => { // Find the original phone number from the batch using failure.Id const originalEntry = batchEntries.find( (entry) => entry.Id === failure.Id ); const failedNumber = originalEntry ? originalEntry.PhoneNumber : ""Unknown ID: "" + failure.Id; logger.error( `Failed to send to ${failedNumber}: ${failure.Code} - ${failure.Message} (SenderFault: ${failure.SenderFault})` ); failedNumbersDetails.push({ number: failedNumber, reason: `${failure.Code}: ${failure.Message}`, }); }); } logger.info( `Batch finished. Success: ${ result.Successful?.length || 0 }, Failed: ${result.Failed?.length || 0}` ); } catch (batchError) { // Handle errors sending the entire batch logger.error( `Error sending SMS batch starting at index ${i}:`, batchError ); // Increment failure count for all intended recipients in this batch const attemptedCount = batchEntries.length; failedSends += attemptedCount; batchEntries.forEach((entry) => { failedNumbersDetails.push({ number: entry.PhoneNumber, reason: `Batch Send Error: ${batchError.message || batchError.name}`, }); }); } } // End of batch loop logger.info( `Bulk SMS job completed. Total Success: ${successfulSends}, Total Failed: ${failedSends}` ); return { successCount: successfulSends, failureCount: failedSends, failedNumbers: failedNumbersDetails, // Return detailed failures message: `SMS Job Finished. Sent: ${successfulSends}, Failed: ${failedSends}.`, }; } catch (error) { logger.error(""Error during bulk SMS processing:"", error); // Generic error for the entire process return { successCount: 0, failureCount: contacts?.length || 0, // Assume all failed if error before/during loop failedNumbers: [], message: `An unexpected error occurred: ${error.message}`, error: true, // Indicate a top-level error occurred }; } };Explanation:
PublishBatchCommandfrom AWS SDK,dbfor database access,logger, oursnsClient, andUserInputErrorfor GraphQL validation errors.MAX_BATCH_SIZE(SNS limit is 10 forPublishBatch).sendBulkSmsfunction takes themessagecontent as input.idandphone).E164_REGEXbefore adding a number to a batch. Invalid numbers are skipped and logged. (Reminds user about Section 8 for better validation).PublishBatchRequestEntriesfor each valid contact in the batch, ensuring a uniqueIdfor each entry.PublishBatchCommandusingsnsClient.send().SuccessfulandFailedarrays from the SNS response, logging details about failures and updating counters. It attempts to map failure IDs back to phone numbers for better reporting.try...catchblocks for both individual batch sends and the overall process.requireAuth()is added (we'll set up auth next).3. Building the API Layer (GraphQL Mutation)
We need a way for the frontend (or external clients) to trigger the
sendBulkSmsservice function. We'll use a GraphQL mutation.Define the GraphQL Mutation:
Edit
api/src/graphql/messaging.sdl.js:# api/src/graphql/messaging.sdl.js type SendBulkSmsFailure { number: String! reason: String! } type SendBulkSmsResponse { successCount: Int! failureCount: Int! message: String! failedNumbers: [SendBulkSmsFailure!] # Return details error: Boolean # Optional flag for top-level errors } type Mutation { # Mutation to trigger sending SMS to all contacts sendBulkSms(message: String!): SendBulkSmsResponse! @requireAuth }Explanation:
SendBulkSmsFailuretype to structure failure details.SendBulkSmsResponsetype to structure the return value of our mutation, including success/failure counts and the detailed failures list.sendBulkSmsmutation within theMutationtype. It accepts a requiredmessagestring.@requireAuthdirective to ensure only authenticated users can call this mutation.Implement Basic Authentication (dbAuth): Redwood makes setting up simple database authentication straightforward.
Run the setup command:
This command:
User,UserCredential,UserRole) toschema.prisma.api/src/services/users) and GraphQL definitions (api/src/graphql/users.sdl.js).api/src/lib/auth.js).web/src/pages/LoginPage,web/src/pages/SignupPage, etc.) and updates routing.Apply Auth Migrations: Run the migration to add the new auth tables to your database.
yarn rw prisma migrate dev # Name the migration (e.g., ""add dbAuth models"")Create a Test User: You'll need a user to test the authenticated mutation. You can either:
yarn rw dev) and navigate to the signup page (/signup) in your browser.yarn rw prisma studioto add a user directly to the database (remember to hash the password correctly if adding manually, or use the signup page).Link SDL to Service: Redwood automatically maps the
sendBulkSmsmutation inmessaging.sdl.jsto thesendBulkSmsfunction exported fromapi/src/services/messaging/messaging.jsbecause the names match. No extra resolver code is needed here.Testing the Mutation (GraphQL Playground):
Start the Redwood development server:
Open your browser to
http://localhost:8911/graphql.Authentication Header: You need to simulate being logged in.
/login).auth-providerheader (should bedbAuth).authorizationheader (should beBearer <session-token>).{ ""auth-provider"": ""dbAuth"", ""authorization"": ""Bearer YOUR_COPIED_SESSION_TOKEN"" }Execute the Mutation: Run the following mutation in the Playground:
mutation SendTestBulkSms { sendBulkSms(message: ""Hello from Redwood SNS Bulk Sender!"") { successCount failureCount message failedNumbers { number reason } } }Check the response in the Playground and the logs in your terminal (
yarn rw devoutput) to see the outcome. You should see logs indicating contacts being fetched, batches being sent, and the final result.4. Integrating with AWS SNS (IAM & Setup)
Properly configuring AWS permissions is crucial for security and functionality.
Create an IAM User for Your Application: It's best practice to create a dedicated IAM user with the minimum necessary permissions rather than using your root AWS account credentials.
redwood-sns-app-user).AmazonSNSFullAccesspolicy. Note: For production, create a custom policy granting onlysns:Publishandsns:PublishBatchpermissions, potentially restricted to specific regions or topics if applicable.AmazonSNSFullAccessis simpler for this guide but too permissive for production.// Example Custom Policy (More Secure) { ""Version"": ""2012-10-17"", ""Statement"": [ { ""Effect"": ""Allow"", ""Action"": [ ""sns:Publish"", ""sns:PublishBatch"" ], ""Resource"": ""*"" // Restrict further by ARN if possible: ""arn:aws:sns:REGION:ACCOUNT_ID:*"" } ] }Generate Access Keys:
redwood-sns-bulk-sms-key).Add Credentials to
.env:Open your project's
.envfile.Paste the copied keys into the respective variables:
Restart Development Server: If your dev server (
yarn rw dev) is running, stop it (Ctrl+C) and restart it to ensure it picks up the new environment variables from.env.Your application is now configured to authenticate with AWS SNS using the dedicated IAM user's credentials.
5. Implementing Error Handling, Logging, and Retries
Robust error handling and logging are essential for production systems.
Error Handling (Already Implemented):
sendBulkSms) already includes checks for an empty message (UserInputError) and basic E.164 phone number format validation.Failedarray in thePublishBatchresponse, logs each specific failure, and includes details in the mutation response.try...catchblock insendBulkSmscatches unexpected errors during the process (e.g., database connection issues, SDK client errors).@requireAuthdirective handles unauthorized access attempts.Logging (Already Implemented):
api/src/lib/logger.js) is used throughout thesendBulkSmsservice.api/src/lib/logger.js. For production, you'd typically set the level toinfoorwarnand potentially log to a file or a log aggregation service.Retry Mechanisms:
SNS Internal Retries: AWS SNS itself has built-in retry mechanisms for transient delivery issues when sending SMS messages. You generally don't need to implement complex retry logic for the final delivery attempt from SNS to the carrier.
Application-Level Retries (for
PublishBatch): ThePublishBatchAPI call itself might fail due to transient network issues or AWS throttling. Our current code logs batch errors but doesn't automatically retry the entire batch.Implementing Retries (Optional Enhancement): To add retries for failed
PublishBatchcalls, you could wrap thesnsClient.send(command)call within a loop with exponential backoff:// Inside the batch loop in api/src/services/messaging/messaging.js const MAX_RETRIES = 3; let attempt = 0; let batchResult = null; let lastBatchError = null; while (attempt < MAX_RETRIES && !batchResult) { try { logger.info(`Sending batch (Attempt ${attempt + 1}/${MAX_RETRIES})...`); batchResult = await snsClient.send(command); // Success, break loop } catch (error) { lastBatchError = error; logger.warn(`Batch send attempt ${attempt + 1} failed: ${error.name} - ${error.message}`); if (attempt + 1 < MAX_RETRIES) { // Exponential backoff: wait 1s, 2s, 4s... const delay = Math.pow(2, attempt) * 1000; logger.info(`Retrying batch in ${delay / 1000}s...`); await new Promise(resolve => setTimeout(resolve, delay)); } attempt++; } } if (!batchResult && lastBatchError) { // Handle final failure after retries (log, increment counters as before) logger.error(`Error sending SMS batch starting at index ${i} after ${MAX_RETRIES} attempts:`, lastBatchError); // ... update failedSends and failedNumbersDetails for the whole batch ... continue; // Move to next batch } // Process the successful batchResult as before... if (batchResult.Successful) { /* ... */ } if (batchResult.Failed) { /* ... */ }Consideration: Adding retries increases complexity. Evaluate if the potential benefits outweigh the added code, especially given SNS's own reliability. For critical messages, retrying might be necessary. Also consider idempotency if retrying.
6. Creating Database Schema and Data Layer
This was largely covered during the setup and core functionality implementation.
Schema Definition (
api/db/schema.prisma): We defined theContactmodel and thedbAuthmodels.Data Access Layer:
dbobject imported fromsrc/lib/dbprovides typed access to your database models.messaging.js:await db.contact.findMany(...)contactsservice (api/src/services/contacts/contacts.js) generated by the scaffold command provides standard CRUD operations.Migrations:
yarn rw prisma migrate devto apply schema changes. This command:schema.prismafile to the database state.api/db/migrations.yarn rw prisma migrate deploy.Sample Data Population (Seeding): To easily add test contacts, you can use Prisma's seeding feature.
Create a seed file:
api/db/seed.js// api/db/seed.js import { db } from 'src/lib/db' // Import any other libraries you need // Use ""+COUNTRYCODE AREACODE NUMBER"" format (E.164) const CONTACTS_TO_SEED = [ { name: 'Test User One', phone: '+15551234567' }, { name: 'Test User Two', phone: '+15559876543' }, { name: 'Test User Three', phone: '+447700900123' }, // Example UK number { name: 'Invalid Number User', phone: '555-INVALID'}, // To test validation ] export default async () => { try { console.log('Seeding contacts...') // Use Prisma Promise API for potential performance improvement with many records await db.$transaction( CONTACTS_TO_SEED.map((contact) => db.contact.upsert({ where: { phone: contact.phone }, // Avoid duplicates based on phone update: { name: contact.name }, // Update name if phone exists create: contact, }) ) ) console.log(`Seeded ${CONTACTS_TO_SEED.length} contacts (or updated existing).`); // Add other seed data if needed (e.g., default UserRoles) } catch (error) { console.error('Error seeding database:', error) // Avoid exiting with error code 1 in dev to prevent Prisma migrate prompts // process.exit(1) } }Run the seed command (this automatically runs after
migrate devor can be run manually):7. Adding Security Features
Security is paramount, especially when dealing with potentially sensitive contact information and incurring costs via AWS services.