Build SMS Marketing Campaigns with RedwoodJS and Infobip API
Complete guide to building a RedwoodJS application for SMS marketing campaigns using Infobip API. Covers GraphQL, Prisma, TCPA compliance, error handling, retry logic, and production deployment with Node.js 20.
This guide provides a step-by-step walkthrough for building a RedwoodJS application capable of sending SMS marketing campaigns using the Infobip API. We will cover project setup, core SMS sending functionality, API integration, database modeling, error handling, security, testing, and deployment.
By the end of this tutorial, you will have a functional RedwoodJS application that can define simple SMS campaigns and send messages to a list of recipients via Infobip, along with the foundational knowledge to extend its capabilities.
Project Overview and Goals
What We're Building:
We are building a full-stack application using RedwoodJS that allows users (potentially administrators or marketers) to:
Associate recipients (phone numbers) with campaigns.
Trigger the sending of SMS messages for a campaign via the Infobip API.
Problem Solved:
This application provides a basic framework for businesses needing to programmatically send targeted SMS messages for marketing or notifications, leveraging a robust provider like Infobip within the productive RedwoodJS ecosystem.
Technologies:
RedwoodJS: A full-stack JavaScript/TypeScript framework for the modern web.
Infobip: A global cloud communications platform offering various APIs, including a powerful SMS API.
Prisma: A next-generation Node.js and TypeScript ORM used by RedwoodJS.
GraphQL: A query language for APIs used by RedwoodJS.
Node.js: The JavaScript runtime environment.
Yarn: Package manager for Node.js.
PostgreSQL (Recommended for Production) / SQLite (Default for Dev): Relational database systems.
System Architecture:
graph LR
A[User Browser] -- GraphQL --> B(RedwoodJS API Side / GraphQL Server);
B -- Prisma Client --> C(Database - PostgreSQL/SQLite);
B -- REST API Call --> D(Infobip SMS API);
C -- Stores --> E(Campaigns & Recipients Data);
D -- Sends --> F(SMS Messages);
subgraph RedwoodJS App
direction LR
G(RedwoodJS Web Side / React UI) --- A;
B --- G;
end
User Browser: Interacts with the React frontend (RedwoodJS Web Side).
RedwoodJS Web Side: Handles UI rendering and sends GraphQL requests to the API side.
RedwoodJS API Side: Contains the GraphQL server, business logic (services), and database access layer (Prisma).
Database: Stores application data like campaign details and recipient lists.
Infobip SMS API: External service responsible for actually sending the SMS messages.
Prerequisites:
Node.js (=20.x required for RedwoodJS v7+)
Yarn (>=1.22.21, managed via Corepack built into Node.js v18+)
Basic understanding of JavaScript/TypeScript, React, GraphQL, and databases.
Access to a terminal or command prompt.
Minimum RedwoodJS version 7.0.0
Expected Outcome:
A RedwoodJS application where you can trigger an API mutation (e.g., via the GraphQL playground) to send a predefined SMS message to a list of phone numbers using your Infobip account.
1. Setting up the Project
Let's initialize our RedwoodJS project and configure the essential components.
Create RedwoodJS App
Open your terminal and run the following command to create a new RedwoodJS project. We'll use TypeScript.
RedwoodJS uses .env files for environment variables. Create a .env file in the project root.
.env (Git-ignored, for sensitive local development keys):
.env
# .env
# Replace placeholder values with your actual Infobip credentials
# Obtain these from your Infobip account dashboard
INFOBIP_API_KEY=""PASTE_YOUR_INFOBIP_API_KEY_HERE""
INFOBIP_BASE_URL=""https://youruniqueid.api.infobip.com"" # Example: Replace with your actual Base URL
INFOBIP_SENDER_ID=""MyBrand"" # Example: Replace with your registered Sender ID or number
# Database URL (Example for PostgreSQL - replace user, password, host, dbname)
DATABASE_URL=""postgresql://db_user:db_password@localhost:5432/redwood_infobip_db?schema=public""
# Database URL (Example for SQLite - default for Redwood dev)
# DATABASE_URL=""file:./dev.db""
# Add a secure session secret for auth (generate a random string)
SESSION_SECRET=""replace-this-with-a-very-long-random-string-32-chars-or-more""
.env.defaults (Committed to Git, for non-sensitive defaults):
.env
# .env.defaults
# Default sender ID if not overridden in .env (optional)
INFOBIP_SENDER_ID=""InfoSMS""
Purpose: Using .env keeps sensitive credentials like API keys out of your codebase and version control. .env.defaults provides non-sensitive defaults. Redwood automatically loads these. Ensure .env is listed in your .gitignore.
Database Configuration (Prisma)
The database connection is defined by DATABASE_URL in .env. The schema is in api/db/schema.prisma. We'll define models later.
Initialize Database
Apply the initial Prisma schema to create the database (for SQLite) or ensure connection (for PostgreSQL).
Bash
yarn rw prisma migrate dev
# You will be prompted to name the migration, e.g., ""initial setup""
Purpose: This command synchronizes your database schema with schema.prisma and generates the Prisma Client.
2. Implementing Core Functionality (SMS Service)
The core logic for interacting with Infobip will reside in a RedwoodJS service.
Install HTTP Client
We need axios to make HTTP requests to the Infobip API.
Bash
yarn workspace api add axios
Generate Campaign Service
Use Redwood's generator. We use --crud to quickly scaffold the service file, tests, and SDL, even though we'll replace the initial CRUD functions with our custom logic first.
Bash
yarn rw g service campaign --crud
This creates api/src/services/campaigns/campaigns.ts, campaigns.test.ts, campaigns.sdl.ts, and related files. We will modify these.
Implement SMS Sending Logic
Open api/src/services/campaigns/campaigns.ts. Replace the generated CRUD function placeholders with a function to send SMS via Infobip. We will add database-interacting functions later.
TypeScript
// api/src/services/campaigns/campaigns.tsimport axios from'axios'importtype { Prisma } from'@prisma/client'// Will be used laterimport { db } from'src/lib/db'// Will be used laterimport { logger } from'src/lib/logger'interfaceSendSmsPayload {
to: string[] // Array of recipient phone numbers in E.164 formattext: stringfrom?: string// Optional sender ID, defaults to env var
}
// Note: This function will be renamed to `sendSms` to match the GraphQL mutation laterexportconstsendSmsViaInfobip = async ({ to, text, from }: SendSmsPayload) => {
const apiKey = process.env.INFOBIP_API_KEYconst baseUrl = process.env.INFOBIP_BASE_URL// Use the provided 'from', fallback to env var, then a defaultconst senderId = from || process.env.INFOBIP_SENDER_ID || 'InfoSMS'if (!apiKey || !baseUrl) {
logger.error('Infobip API Key or Base URL missing in environment variables.')
thrownewError('Infobip configuration is incomplete.')
}
// Basic validationif (!to || to.length === 0 || !text) {
logger.error('Missing required parameters: to (list of numbers) and text.')
thrownewError('Recipient numbers and message text are required.')
}
// Add more validation here (e.g., phone number format, text length) if neededconst infobipUrl = `${baseUrl}/sms/3/messages`// Updated to current SMS API v3 endpoint// Structure the payload according to Infobip API V3 documentation// Ref: https://www.infobip.com/docs/sms/sms-over-apiconst payload = {
messages: to.map((recipient) => ({
destinations: [{ to: recipient }],
from: senderId,
text: text,
})),
}
try {
logger.info(`Sending SMS via Infobip to ${to.length} recipients.`)
logger.debug({ payload: { messages: payload.messages.map(m => ({...m, text: '[REDACTED]'})) } }, 'Infobip Request Payload (Text Redacted):') // Avoid logging full textconst response = await axios.post(infobipUrl, payload, {
headers: {
Authorization: `App ${apiKey}`, // Use 'App' prefix for API Key method'Content-Type': 'application/json',
Accept: 'application/json',
},
timeout: 10000, // 10 second timeout
})
logger.info(
{ bulkId: response.data.bulkId, status: response.status },
'Infobip SMS submitted successfully.'
)
// Return relevant info, like the bulk ID for trackingreturn {
success: true,
bulkId: response.data.bulkId,
messages: response.data.messages, // Contains status for each message
}
} catch (error) {
logger.error({ error }, 'Failed to send SMS via Infobip.')
if (axios.isAxiosError(error)) {
logger.error(
{
status: error.response?.status,
data: error.response?.data,
// headers: error.response?.headers, // Avoid logging potentially sensitive headers
},
'Infobip API Error Details'
)
// Provide a more user-friendly error message if possibleconst errorMessage = error.response?.data?.requestError?.serviceException?.text || error.message;
thrownewError(`Infobip API request failed: ${errorMessage}`)
} else {
thrownewError(`An unexpected error occurred: ${error.message}`)
}
}
}
// --- Placeholder for Campaign CRUD functions (to be added in Section 6) ---// export const campaigns = () => { ... }// export const campaign = ({ id }: Prisma.CampaignWhereUniqueInput) => { ... }// export const createCampaign = ({ input }: { input: Prisma.CampaignCreateInput }) => { ... }// export const updateCampaign = ({ id, input }: { id: number; input: Prisma.CampaignUpdateInput }) => { ... }// export const deleteCampaign = ({ id }: Prisma.CampaignWhereUniqueInput) => { ... }// --- Placeholder for triggerCampaignSend function (to be added in Section 6) ---// export const triggerCampaignSend = async ({ input }: { input: TriggerCampaignSendInput }) => { ... }
Why this approach? Encapsulates Infobip logic, uses environment variables, axios for HTTP, follows Infobip API structure, includes basic validation, logging, and error handling.
3. Building the API Layer (GraphQL)
Expose backend functionality via GraphQL.
Define GraphQL Schema (SDL)
Modify api/src/graphql/campaigns.sdl.ts to define the mutation and its input/output types.
GraphQL
# api/src/graphql/campaigns.sdl.ts
export const schema= gql`
# Type returned after attempting to send SMStype SendSmsResponse {success: Boolean!bulkId: String # Infobip's ID for the batch submissionmessage: String # Optional message for non-bulk results# Optional: Add individual message statuses if needed# messages: [InfobipMessageStatus]}# Input for the sendSms mutationinput SendSmsInput {to:[String!]!# Array of phone numbers (E.164 format required)text: String!from: String # Optional: Override default sender ID}# Define Mutations (actions that change data or trigger processes)typeMutation{""""""
Triggers sending an SMS message to multiple recipients via Infobip.
Requires authentication. Use for ad-hoc sends not tied to a stored campaign.
""""""
sendSms(input: SendSmsInput!): SendSmsResponse!@requireAuth}# --- Placeholder Types/Queries/Mutations for Campaigns (added in Section 6) ---# type Campaign { ... }# type Recipient { ... }# type Query { ... }# input CreateCampaignInput { ... }# input UpdateCampaignInput { ... }# input TriggerCampaignSendInput { ... }# type Mutation {# createCampaign(...)# updateCampaign(...)# deleteCampaign(...)# triggerCampaignSend(...)# }# --- End Placeholders ---
`
@requireAuth: Ensures only authenticated users can call this. Set up Redwood Auth (e.g., dbAuth) or remove temporarily for initial testing without auth.
Link Service to Mutation
Rename the service function to match the mutation name (sendSms).
In api/src/services/campaigns/campaigns.ts, rename sendSmsViaInfobip to sendSms.
TypeScript
// api/src/services/campaigns/campaigns.ts// ... imports ...interfaceSendSmsInput { // Renamed from SendSmsPayload to match SDLto: string[]
text: stringfrom?: string
}
// Renamed function to match SDL mutation name 'sendSms'// The input argument is automatically destructured by Redwood { input }exportconstsendSms = async ({ input }: { input: SendSmsInput }) => {
const { to, text, from } = input // Destructure from the input objectconst apiKey = process.env.INFOBIP_API_KEYconst baseUrl = process.env.INFOBIP_BASE_URLconst senderId = from || process.env.INFOBIP_SENDER_ID || 'InfoSMS'// ... (rest of the validation and payload logic remains the same) ...const infobipUrl = `${baseUrl}/sms/3/messages`// Updated to current SMS API v3 endpointconst payload = {
messages: to.map((recipient) => ({
destinations: [{ to: recipient }],
from: senderId,
text: text,
})),
}
try {
logger.info(`Sending SMS via Infobip to ${to.length} recipients.`)
logger.debug({ payload: { messages: payload.messages.map(m => ({...m, text: '[REDACTED]'})) } }, 'Infobip Request Payload (Text Redacted):')
const response = await axios.post(infobipUrl, payload, {
headers: {
Authorization: `App ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
timeout: 10000,
})
logger.info(
{ bulkId: response.data.bulkId, status: response.status },
'Infobip SMS submitted successfully.'
)
// Ensure the return matches SendSmsResponse type in SDLreturn {
success: true,
bulkId: response.data.bulkId,
// messages: response.data.messages, // Optional detailed statusesmessage: `SMS submitted successfully to ${to.length} numbers.`// Add optional message
}
} catch (error) {
logger.error({ error }, 'Failed to send SMS via Infobip.')
if (axios.isAxiosError(error)) {
logger.error(
{
status: error.response?.status,
data: error.response?.data,
},
'Infobip API Error Details'
)
const errorMessage = error.response?.data?.requestError?.serviceException?.text || error.message;
thrownewError(`Infobip API request failed: ${errorMessage}`)
} else {
thrownewError(`An unexpected error occurred: ${error.message}`)
}
// Re-throw the error after logging so GraphQL reports it// throw error; // Original code had this commented out, but re-throwing is usually correct for GraphQL
}
}
// ... rest of the file ...
Testing with GraphQL Playground
Start the dev server: yarn rw dev
Open http://localhost:8911/graphql.
Execute the mutation (replace placeholders with real, E.164 formatted numbers for testing):
GraphQL
mutation SendTestSms {# Note: This will fail if @requireAuth is active and you haven't set up auth
sendSms(input:{# Replace with actual E.164 numbers you can test withto:[""+15551234567"", ""+15559876543""]text:""Hello from RedwoodJS and Infobip!""# from: ""OptionalSender"" # Optionally override sender from .env}){
success
bulkId
message
}}
Check your terminal logs for success/errors and the Infobip portal for message status.
4. Integrating with Infobip (Configuration Details)
Details on obtaining and managing Infobip credentials.
Obtaining Infobip Credentials
API Key:
Log in to your Infobip account portal.
Navigate to API Key management (often under developer settings or account).
Create a new API key, name it descriptively.
Copy the key immediately and store it securely.
Place this key into your .env file as INFOBIP_API_KEY.
Base URL:
Find your account-specific Base URL in the Infobip portal/documentation (e.g., https://your-unique-id.api.infobip.com).
Place this URL into your .env file as INFOBIP_BASE_URL.
Sender ID (from number/name):
This is the identifier shown to recipients.
Regulations vary greatly by country (alphanumeric, short code, long code, toll-free).
You may need to register/purchase Sender IDs/numbers via the Infobip portal.
Crucially, consult Infobip documentation or support regarding Sender ID requirements and registration processes for your target countries.
Set a default in .env or .env.defaults (INFOBIP_SENDER_ID).
Secure Storage
Never commit .env files or API keys to Git. Ensure .env is in .gitignore.
Use your hosting provider's secrets management for production environments (Vercel, Netlify, AWS Secrets Manager, etc.).
Environment Variables Summary
INFOBIP_API_KEY: Your secret API key. Obtain: Infobip Portal.
INFOBIP_BASE_URL: Your account-specific API URL. Obtain: Infobip Portal/Docs.
INFOBIP_SENDER_ID: Default sender ID/number. Configure: Infobip Portal (subject to regulations).
SESSION_SECRET: Secure random string for session management (used by Redwood auth). Generate one.
Fallback Mechanisms
Consider retries (Section 5), circuit breakers (opossum), or alternative providers for critical systems, though this adds complexity.
5. Error Handling, Logging, and Retry Mechanisms
Building resilience into the application.
Consistent Error Handling Strategy
Our service uses try...catch, logs errors with context (logger.error), and re-throws errors for GraphQL to handle.
Logging
Use Redwood's Pino logger (src/lib/logger.ts).
Use levels appropriately: info (success), warn (recoverable issues, retries), error (failures), debug (verbose dev info).
Redwood's JSON logs are ideal for aggregation services (Datadog, Logflare, etc.).
Filter/search logs by bulkId, error messages, status codes.
Retry Mechanism (Simple Example)
Add basic retries with exponential backoff to sendSms for transient network or server errors.
TypeScript
// api/src/services/campaigns/campaigns.tsimport axios from'axios'// ... other imports ...import { logger } from'src/lib/logger'// Helper function for async delayconstdelay = (ms: number) => newPromise(resolve =>setTimeout(resolve, ms));
// ... interface SendSmsInput ...exportconstsendSms = async ({ input }: { input: SendSmsInput }) => {
const { to, text, from } = input
const apiKey = process.env.INFOBIP_API_KEYconst baseUrl = process.env.INFOBIP_BASE_URLconst senderId = from || process.env.INFOBIP_SENDER_ID || 'InfoSMS'// ... validation ...if (!apiKey || !baseUrl) {
logger.error('Infobip API Key or Base URL missing in environment variables.')
thrownewError('Infobip configuration is incomplete.')
}
if (!to || to.length === 0 || !text) {
logger.error('Missing required parameters: to (list of numbers) and text.')
thrownewError('Recipient numbers and message text are required.')
}
const infobipUrl = `${baseUrl}/sms/3/messages`// Updated to current SMS API v3 endpointconst payload = {
messages: to.map((recipient) => ({
destinations: [{ to: recipient }],
from: senderId,
text: text,
})),
}
constMAX_RETRIES = 3;
let attempt = 0;
while (attempt < MAX_RETRIES) {
attempt++;
try {
logger.info(`Sending SMS via Infobip (Attempt ${attempt}/${MAX_RETRIES}) to ${to.length} recipients.`);
// logger.debug({ payload }, 'Infobip Request Payload:'); // Be cautious logging payloadconst response = await axios.post(infobipUrl, payload, {
headers: {
Authorization: `App ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
timeout: 15000// Slightly longer timeout for retries
});
logger.info(
{ bulkId: response.data.bulkId, status: response.status, attempt },
'Infobip SMS submitted successfully.'
);
return {
success: true,
bulkId: response.data.bulkId,
message: `SMS submitted successfully on attempt ${attempt}.`,
// messages: response.data.messages,
};
} catch (error) {
logger.warn({ error: error.message, attempt, maxRetries: MAX_RETRIES }, `Attempt ${attempt} failed for Infobip SMS.`);
let shouldRetry = false;
// Exponential backoff: 1s, 2s, 4s... + some jitterlet waitMs = 1000 * Math.pow(2, attempt - 1) + Math.random() * 500;
if (axios.isAxiosError(error)) {
const status = error.response?.status;
// Retry on network errors or specific server errors (e.g., 5xx)if (!status || (status >= 500 && status <= 599)) {
shouldRetry = true;
logger.warn(`Network or server error (${status || 'N/A'}), scheduling retry.`);
} else {
// Log details for non-retryable client errors (4xx) or others
logger.error({ status: error.response?.status, data: error.response?.data }, 'Non-retryable Infobip API Error Details');
}
} else {
// Non-axios error (e.g., code error before request)
logger.error({ error }, 'Non-Axios error during Infobip request attempt.');
// Generally don't retry these unless specifically handled
}
if (shouldRetry && attempt < MAX_RETRIES) {
logger.info(`Waiting ${Math.round(waitMs)}ms before next retry.`);
awaitdelay(waitMs);
// Continue to next iteration of the loop
} else {
// Last attempt failed or non-retryable error occurred
logger.error({ error: error.message, attempt }, 'Failed to send SMS via Infobip after all retries or due to non-retryable error.');
// Construct a meaningful error to throwlet finalError;
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data?.requestError?.serviceException?.text || error.message;
finalError = newError(`Infobip API request failed after ${attempt} attempts: ${errorMessage}`);
} else {
finalError = newError(`An unexpected error occurred after ${attempt} attempts: ${error.message}`);
}
throw finalError; // Throw the aggregated/final error
}
}
}
// This line should technically be unreachable if the logic above is correct,// as the loop either returns successfully or throws an error.// Included primarily for type safety / exhaustive checks in some setups.// throw new Error('SMS sending failed unexpectedly after retry loop.');
}
// ... rest of file ...
Exponential Backoff: Prevents overwhelming services during transient issues.
Consider libraries like async-retry for more complex retry strategies.
Testing Error Scenarios
Temporarily use an invalid API key (.env) -> Expect 401 error.
Simulate network issues -> Expect retry logic to engage.
Send invalid phone numbers/payload -> Expect 400 error from Infobip.
6. Creating a Database Schema and Data Layer
Define models for campaigns and recipients.
Define Prisma Schema
Update api/db/schema.prisma.
Prisma
// api/db/schema.prisma
datasource db {
provider = ""postgresql"" // Or ""sqlite""
url = env(""DATABASE_URL"")
}
generator client {
provider = ""prisma-client-js""
binaryTargets = ""native"" // Or specific targets like ""debian-openssl-1.1.x"" for deployment
}
model Recipient {
id Int @id @default(autoincrement())
phoneNumber String @unique // E.164 format REQUIRED
firstName String?
lastName String?
isOptedOut Boolean @default(false) // Added for opt-out tracking
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Many-to-Many relationship with Campaign
campaigns Campaign[] @relation(""CampaignRecipients"")
@@index([isOptedOut]) // Index for filtering active recipients
}
model Campaign {
id Int @id @default(autoincrement())
name String @unique
message String
senderId String? // Campaign-specific sender override
// Suggested statuses: DRAFT, SCHEDULED, SENDING, SENT, FAILED, COMPLETE_NO_RECIPIENTS
status String @default(""DRAFT"")
scheduledAt DateTime?
sentAt DateTime? // Timestamp when sending was initiated/completed
infobipBulkId String? // Store Infobip's bulk ID for tracking
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Many-to-Many relationship with Recipient
recipients Recipient[] @relation(""CampaignRecipients"")
@@index([status])
@@index([scheduledAt])
}
// Prisma implicitly handles the join table (_CampaignRecipients)
// based on the @relation definition above.
isOptedOut field added to Recipient.
Status list expanded in comments.
Indexes added for common query fields.
Database Migrations
Apply schema changes.
Bash
yarn rw prisma migrate dev
# Provide migration name, e.g., ""add campaign recipient models optout""
This updates the DB schema and regenerates the Prisma Client.
Implement Data Access (CRUD Operations)
Add CRUD functions and the triggerCampaignSend logic to api/src/services/campaigns/campaigns.ts.
TypeScript
// api/src/services/campaigns/campaigns.tsimport axios from'axios'importtype { Prisma, Campaign, Recipient } from'@prisma/client'// Import typesimport { db } from'src/lib/db'import { logger } from'src/lib/logger'import { requireAuth } from'src/lib/auth'// Assuming auth is set up// --- Helper: delay function (from Section 5) ---constdelay = (ms: number) => newPromise(resolve =>setTimeout(resolve, ms));
// --- Interface: SendSmsInput (from Section 3) ---interfaceSendSmsInput { to: string[]; text: string; from?: string; }
// --- Service Function: sendSms (with retries, from Section 5) ---exportconstsendSms = async ({ input }: { input: SendSmsInput }) => {
// ... existing sendSms logic with retries ...const { to, text, from } = input
const apiKey = process.env.INFOBIP_API_KEYconst baseUrl = process.env.INFOBIP_BASE_URLconst senderId = from || process.env.INFOBIP_SENDER_ID || 'InfoSMS'if (!apiKey || !baseUrl) {
logger.error('Infobip API Key or Base URL missing in environment variables.')
thrownewError('Infobip configuration is incomplete.')
}
if (!to || to.length === 0 || !text) {
logger.error('Missing required parameters: to (list of numbers) and text.')
thrownewError('Recipient numbers and message text are required.')
}
const infobipUrl = `${baseUrl}/sms/3/messages`// Updated to current SMS API v3 endpointconst payload = {
messages: to.map((recipient) => ({
destinations: [{ to: recipient }],
from: senderId,
text: text,
})),
}
constMAX_RETRIES = 3;
let attempt = 0;
while (attempt < MAX_RETRIES) {
attempt++;
try {
logger.info(`Sending SMS via Infobip (Attempt ${attempt}/${MAX_RETRIES}) to ${to.length} recipients.`);
const response = await axios.post(infobipUrl, payload, {
headers: {
Authorization: `App ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
timeout: 15000
});
logger.info(
{ bulkId: response.data.bulkId, status: response.status, attempt },
'Infobip SMS submitted successfully.'
);
return {
success: true,
bulkId: response.data.bulkId,
message: `SMS submitted successfully on attempt ${attempt}.`,
};
} catch (error) {
logger.warn({ error: error.message, attempt, maxRetries: MAX_RETRIES }, `Attempt ${attempt} failed for Infobip SMS.`);
let shouldRetry = false;
let waitMs = 1000 * Math.pow(2, attempt - 1) + Math.random() * 500;
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (!status || (status >= 500 && status <= 599)) {
shouldRetry = true;
logger.warn(`Network or server error (${status || 'N/A'}), scheduling retry.`);
} else {
logger.error({ status: error.response?.status, data: error.response?.data }, 'Non-retryable Infobip API Error Details');
}
} else {
logger.error({ error }, 'Non-Axios error during Infobip request attempt.');
}
if (shouldRetry && attempt < MAX_RETRIES) {
logger.info(`Waiting ${Math.round(waitMs)}ms before next retry.`);
awaitdelay(waitMs);
} else {
logger.error({ error: error.message, attempt }, 'Failed to send SMS via Infobip after all retries or due to non-retryable error.');
let finalError;
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data?.requestError?.serviceException?.text || error.message;
finalError = newError(`Infobip API request failed after ${attempt} attempts: ${errorMessage}`);
} else {
finalError = newError(`An unexpected error occurred after ${attempt} attempts: ${error.message}`);
}
throw finalError;
}
}
}
// Should be unreachablethrownewError('SMS sending failed unexpectedly after retry loop.');
}
// --- Campaign CRUD Functions ---/** Returns a list of campaigns (consider pagination for production). */exportconstcampaigns = () => {
requireAuth({ roles: ['admin', 'marketer'] }) // Example role checkreturn db.campaign.findMany({ orderBy: { createdAt: 'desc' } })
}
/** Returns a single campaign by ID, including its recipients. */exportconstcampaign = ({ id }: Prisma.CampaignWhereUniqueInput) => {
requireAuth()
return db.campaign.findUnique({
where: { id },
include: {
recipients: { // Optionally filter/paginate recipients here tooorderBy: { createdAt: 'asc' }
}
},
})
}
// Use Prisma generated types for better safetyinterfaceCreateCampaignServiceInput {
input: Omit<Prisma.CampaignCreateInput, 'recipients'> & { // Base Campaign datarecipientIds?: number[] // Accept array of Recipient IDs
}
}
/** Creates a new campaign. Optionally connects recipients by ID. */exportconstcreateCampaign = async ({ input }: CreateCampaignServiceInput) => {
requireAuth({ roles: ['admin', 'marketer'] })
const { recipientIds, ...campaignData } = input
return db.campaign.create({
data: {
...campaignData, // Spread basic fields like name, message, statusstatus: campaignData.status || 'DRAFT', // Ensure default status// Connect recipients if IDs are providedrecipients: recipientIds && recipientIds.length > 0
? { connect: recipientIds.map((id) => ({ id })) }
: undefined,
},
})
}
interfaceUpdateCampaignServiceInput {
id: number;
input: Omit<Prisma.CampaignUpdateInput, 'recipients'> & {
recipientIds?: number[] // Allow updating connected recipients
}
}
/** Updates an existing campaign. Allows replacing the recipient list. */exportconstupdateCampaign = ({ id, input }: UpdateCampaignServiceInput) => {
requireAuth({ roles: ['admin', 'marketer'] })
const { recipientIds, ...campaignData } = input
return db.campaign.update({
where: { id },
data: {
...campaignData,
// Use 'set' to replace the entire list of connected recipients// Use connect/disconnect for adding/removing specific ones if neededrecipients: recipientIds
? { set: recipientIds.map((id) => ({ id })) }
: undefined, // If recipientIds is omitted, don't change recipients
},
})
}
/** Deletes a campaign by its ID. */exportconstdeleteCampaign = ({ id }: Prisma.CampaignWhereUniqueInput) => {
requireAuth({ roles: ['admin'] }) // Stricter role for deletion// Add checks: prevent deletion of 'SENDING' or recently 'SENT' campaigns?return db.campaign.delete({
where: { id },
})
}
// --- Trigger Campaign Sending Logic ---interfaceTriggerCampaignSendInput {
campaignId: number
}
/** Fetches campaign details and sends SMS to its ACTIVE recipients. */exportconsttriggerCampaignSend = async ({ input }: { input: TriggerCampaignSendInput }) => {
requireAuth({ roles: ['admin', 'marketer'] })
const { campaignId } = input;
logger.info({ campaignId }, ""Attempting to trigger campaign send."");
// Fetch campaign WITH recipientsconst camp = await db.campaign.findUnique({
where: { id: campaignId },
include: { recipients: true } // Include related recipients
});
if (!camp) {
logger.error({ campaignId }, ""Campaign not found for triggering send."");
thrownewError(`Campaign with ID ${campaignId} not found.`);
}
// Filter out opted-out recipientsconst activeRecipients = camp.recipients.filter(r => !r.isOptedOut);
if (activeRecipients.length === 0) {
logger.warn({ campaignId }, ""Campaign has no active (opted-in) recipients. Nothing to send."");
// Update status to reflect this outcomeawait db.campaign.update({
where: { id: campaignId },
data: { status: 'COMPLETE_NO_RECIPIENTS', sentAt: newDate() } // Mark as completed
});
return { success: true, message: ""Campaign has no active recipients."" };
}
// Prevent re-sending or sending while already in progressif (camp.status === 'SENDING' || camp.status === 'SENT') {
logger.warn({ campaignId, status: camp.status }, ""Campaign is already sending or has been sent. Send trigger ignored."");
// Return success:false or throw an error depending on desired behaviorreturn { success: false, message: `Campaign status is already ${camp.status}. Send trigger ignored.` };
}
// Update campaign status to SENDING before initiating sendawait db.campaign.update({
where: { id: campaignId },
data: { status: 'SENDING', sentAt: newDate() } // Mark start time
});
let sendResult;
try {
const recipientNumbers = activeRecipients.map(r => r.phoneNumber);
const sender = camp.senderId; // Use campaign-specific sender if set// Call the core SMS sending function (which includes retries)
sendResult = awaitsendSms({
input: {
to: recipientNumbers,
text: camp.message,
from: sender // Pass campaign sender override if available
}
});
// Update campaign status to SENT (or potentially FAILED_PARTIAL if needed)await db.campaign.update({
where: { id: campaignId },
data: {
status: 'SENT', // Mark as sent after successful submission to InfobipinfobipBulkId: sendResult.bulkId// Store the bulk ID
}
});
logger.info({ campaignId, bulkId: sendResult.bulkId, recipientCount: recipientNumbers.length }, ""CampaignSMS successfully submitted to Infobip."");
return { success: true, bulkId: sendResult.bulkId, message: `Campaign ${campaignId} sent to ${recipientNumbers.length} recipients.` };
} catch (error) {
logger.error({ campaignId, error: error.message }, ""Failed to send campaign SMS via Infobip."");
// Update campaign status to FAILEDawait db.campaign.update({
where: { id: campaignId },
data: { status: 'FAILED' } // Mark as failed
});
// Re-throw the error so the GraphQL mutation reports failurethrownewError(`Failed to send campaign ${campaignId}: ${error.message}`);
}
}
---
## 7.SMSMarketingCompliance (TCPA & FCCRegulations)
**CRITICAL: Legal requirements forSMS marketing in the UnitedStates.**
### TCPARequirements (UnitedStates)
TheTelephoneConsumerProtectionAct (TCPA) governs commercial SMS messaging. **Non-compliance can result in penalties of $500 per violation, or $1,500 per willful violation, with no cap on total damages.**
#### PriorExpressWrittenConsent (Required)
Before sending any marketing SMS messages, you **must** obtain prior express written consent from recipients. Thismeans:
* **ClearAuthorization**: Consumers must explicitly agree to receive SMS messages
* **WrittenRecord**: Maintain documentation ofconsent (date, time, method, exact language used)
* **ScopeDisclosure**: Clearly state what types of messages they'll receive and frequency
**Example Consent Language:**
```text
By providing your phone number and checking this box, you agree to receive
marketing text messages from [Business Name] at the number provided, including
messages sent by autodialer. Consent is not a condition of purchase. Message
and data rates may apply. Message frequency varies. Reply HELP for help or
STOP to cancel.
New FCC Opt-Out Rules (Effective April 11, 2025)
The FCC adopted new opt-out rules that take effect April 11, 2025:
Any Reasonable Method: Consumers can revoke consent through ANY reasonable method – not just texting "STOP". Examples include:
"Please stop texting me"
"Unsubscribe"
"Remove me"
Misspellings like "STPO" or "SOTP"
10-Business-Day Processing: You must honor opt-out requests within 10 business days of receipt (previously no specific timeline)
One Confirmation Only: You may send ONE confirmation message acknowledging the opt-out request. No additional messages are permitted.
Cross-Channel Revocation: If a consumer revokes consent via phone call, that revocation also applies to text messages, and vice versa.
Time Restrictions
Do not send SMS messages before 8:00 AM or after 9:00 PM in the recipient's local time zone.
Implementation Recommendations
Enhance the Recipient model to track compliance (already included in Section 6 schema):
TypeScript
model Recipient {
id Int@id@default(autoincrement())
phoneNumber String@unique
firstName String?
lastName String?
isOptedOut Boolean@default(false)
consentDate DateTime? // When consent was obtained
consentMethod String? // How consent was obtained (e.g., "web_form", "checkout")
optOutDate DateTime? // When user opted out
optOutMethod String? // How user opted out (e.g., "SMS_STOP", "web_preferences")
createdAt DateTime@default(now())
updatedAt DateTime@updatedAt
campaigns Campaign[] @relation("CampaignRecipients")
@@index([isOptedOut])
}
Add time validation to triggerCampaignSend:
TypeScript
// Add this check before sending in triggerCampaignSend functionconst now = newDate();
const hour = now.getHours(); // 0-23 in server's local time// NOTE: For production, use recipient's timezone via a timezone libraryif (hour < 8 || hour >= 21) {
logger.warn({ campaignId, hour }, "Campaign send attempted outside allowed hours (8 AM - 9 PM).");
thrownewError("SMS messages cannot be sent before 8:00 AM or after 9:00 PM per TCPA regulations.");
}
Implement robust opt-out recognition:
Create a webhook endpoint to handle incoming SMS responses via Infobip's delivery reports. Process various opt-out phrases beyond just "STOP":
This foundation provides everything needed to build a compliant, scalable SMS marketing platform with RedwoodJS and Infobip.
Frequently asked questions
You can send SMS messages with RedwoodJS by integrating with an SMS API provider like Infobip. This involves setting up a RedwoodJS project, installing an HTTP client like Axios, creating a service to handle SMS sending logic, and exposing this functionality through a GraphQL API. This setup allows you to trigger SMS messages programmatically within your RedwoodJS application.
RedwoodJS acts as the full-stack framework for building the SMS marketing application. It handles the user interface, API interactions, business logic, and database connections. RedwoodJS offers a productive environment for developing and deploying such applications by streamlining common web development tasks.
Infobip is a global cloud communication platform with a powerful SMS API that enables sending messages programmatically. It provides infrastructure and tools for reliable SMS delivery, making it a suitable choice for businesses needing scalable SMS solutions.
Integrate Infobip by obtaining your API Key and Base URL from the Infobip portal and placing them into a .env file in your project root, along with the desired sender ID. Then, install an HTTP client library like Axios and use it within a RedwoodJS service to make requests to the Infobip API endpoints.
Environment variables should be used for sensitive data, such as API keys, database connection URLs, and session secrets. Storing these in .env files keeps them out of your codebase and allows for different values across various environments (development, staging, production), enhancing security.
Implement comprehensive error handling using try...catch blocks to trap errors during API calls and other operations. Log errors with Redwood's logger, providing context like status codes and timestamps. Consider retry mechanisms for transient network issues to improve resilience.
RedwoodJS uses Prisma, an ORM, which allows flexibility with different databases. PostgreSQL is recommended for production environments due to its scalability and features. SQLite is used by default for development due to its ease of setup.
Prisma is a next-generation ORM (Object-Relational Mapper) for Node.js and TypeScript. It simplifies database interactions within RedwoodJS applications. Developers define their data models in a schema file (schema.prisma), and Prisma generates type-safe client code for database access, improving developer experience and code safety.
Create a new RedwoodJS project using the command yarn create redwood-app ./redwood-infobip-sms --typescript. This initializes a RedwoodJS application with TypeScript support. Add the --typescript flag to start with TypeScript.
A RedwoodJS service for Infobip integration should handle the core SMS sending logic, including API calls, data validation, error handling, and logging. It should also include appropriate retry mechanisms for resilience and security measures such as safe storage of API credentials.
The GraphQL schema (SDL) defines the API for your RedwoodJS application. It specifies the available queries and mutations, their input types, and the expected output types. This contract between the front-end and back-end facilitates communication and data exchange.
Test GraphQL APIs using Redwood's built-in GraphQL Playground, accessible at http://localhost:8911/graphql in the development environment. You can execute queries and mutations directly within the Playground to verify functionality and data retrieval.
Infobip API keys are generated within your Infobip account portal. Navigate to the API Key management section, usually located within the developer settings or account area. Create a new API key and securely store it for use in your RedwoodJS project.
The tutorial provides a mechanism for overriding the default INFOBIP_SENDER_ID within each campaign. This allows using different Sender IDs (numbers or shortcodes) for various campaigns, as long as they comply with Infobip's registration and compliance requirements.
This guide provides a step-by-step walkthrough for building a RedwoodJS application capable of sending SMS marketing campaigns using the Infobip API. We will cover project setup, core SMS sending functionality, API integration, database modeling, error handling, security, testing, and deployment.
By the end of this tutorial, you will have a functional RedwoodJS application that can define simple SMS campaigns and send messages to a list of recipients via Infobip, along with the foundational knowledge to extend its capabilities.
Project Overview and Goals
What We're Building:
We are building a full-stack application using RedwoodJS that allows users (potentially administrators or marketers) to:
Problem Solved:
This application provides a basic framework for businesses needing to programmatically send targeted SMS messages for marketing or notifications, leveraging a robust provider like Infobip within the productive RedwoodJS ecosystem.
Technologies:
System Architecture:
Prerequisites:
Expected Outcome:
A RedwoodJS application where you can trigger an API mutation (e.g., via the GraphQL playground) to send a predefined SMS message to a list of phone numbers using your Infobip account.
1. Setting up the Project
Let's initialize our RedwoodJS project and configure the essential components.
Create RedwoodJS App
Open your terminal and run the following command to create a new RedwoodJS project. We'll use TypeScript.
Navigate to Project Directory
cd redwood-infobip-smsEnvironment Variables
RedwoodJS uses
.envfiles for environment variables. Create a.envfile in the project root..env(Git-ignored, for sensitive local development keys):.env.defaults(Committed to Git, for non-sensitive defaults):Purpose: Using
.envkeeps sensitive credentials like API keys out of your codebase and version control..env.defaultsprovides non-sensitive defaults. Redwood automatically loads these. Ensure.envis listed in your.gitignore.Database Configuration (Prisma)
The database connection is defined by
DATABASE_URLin.env. The schema is inapi/db/schema.prisma. We'll define models later.Initialize Database
Apply the initial Prisma schema to create the database (for SQLite) or ensure connection (for PostgreSQL).
yarn rw prisma migrate dev # You will be prompted to name the migration, e.g., ""initial setup""schema.prismaand generates the Prisma Client.2. Implementing Core Functionality (SMS Service)
The core logic for interacting with Infobip will reside in a RedwoodJS service.
Install HTTP Client
We need
axiosto make HTTP requests to the Infobip API.Generate Campaign Service
Use Redwood's generator. We use
--crudto quickly scaffold the service file, tests, and SDL, even though we'll replace the initial CRUD functions with our custom logic first.api/src/services/campaigns/campaigns.ts,campaigns.test.ts,campaigns.sdl.ts, and related files. We will modify these.Implement SMS Sending Logic
Open
api/src/services/campaigns/campaigns.ts. Replace the generated CRUD function placeholders with a function to send SMS via Infobip. We will add database-interacting functions later.// api/src/services/campaigns/campaigns.ts import axios from 'axios' import type { Prisma } from '@prisma/client' // Will be used later import { db } from 'src/lib/db' // Will be used later import { logger } from 'src/lib/logger' interface SendSmsPayload { to: string[] // Array of recipient phone numbers in E.164 format text: string from?: string // Optional sender ID, defaults to env var } // Note: This function will be renamed to `sendSms` to match the GraphQL mutation later export const sendSmsViaInfobip = async ({ to, text, from }: SendSmsPayload) => { const apiKey = process.env.INFOBIP_API_KEY const baseUrl = process.env.INFOBIP_BASE_URL // Use the provided 'from', fallback to env var, then a default const senderId = from || process.env.INFOBIP_SENDER_ID || 'InfoSMS' if (!apiKey || !baseUrl) { logger.error('Infobip API Key or Base URL missing in environment variables.') throw new Error('Infobip configuration is incomplete.') } // Basic validation if (!to || to.length === 0 || !text) { logger.error('Missing required parameters: to (list of numbers) and text.') throw new Error('Recipient numbers and message text are required.') } // Add more validation here (e.g., phone number format, text length) if needed const infobipUrl = `${baseUrl}/sms/3/messages` // Updated to current SMS API v3 endpoint // Structure the payload according to Infobip API V3 documentation // Ref: https://www.infobip.com/docs/sms/sms-over-api const payload = { messages: to.map((recipient) => ({ destinations: [{ to: recipient }], from: senderId, text: text, })), } try { logger.info(`Sending SMS via Infobip to ${to.length} recipients.`) logger.debug({ payload: { messages: payload.messages.map(m => ({...m, text: '[REDACTED]'})) } }, 'Infobip Request Payload (Text Redacted):') // Avoid logging full text const response = await axios.post(infobipUrl, payload, { headers: { Authorization: `App ${apiKey}`, // Use 'App' prefix for API Key method 'Content-Type': 'application/json', Accept: 'application/json', }, timeout: 10000, // 10 second timeout }) logger.info( { bulkId: response.data.bulkId, status: response.status }, 'Infobip SMS submitted successfully.' ) // Return relevant info, like the bulk ID for tracking return { success: true, bulkId: response.data.bulkId, messages: response.data.messages, // Contains status for each message } } catch (error) { logger.error({ error }, 'Failed to send SMS via Infobip.') if (axios.isAxiosError(error)) { logger.error( { status: error.response?.status, data: error.response?.data, // headers: error.response?.headers, // Avoid logging potentially sensitive headers }, 'Infobip API Error Details' ) // Provide a more user-friendly error message if possible const errorMessage = error.response?.data?.requestError?.serviceException?.text || error.message; throw new Error(`Infobip API request failed: ${errorMessage}`) } else { throw new Error(`An unexpected error occurred: ${error.message}`) } } } // --- Placeholder for Campaign CRUD functions (to be added in Section 6) --- // export const campaigns = () => { ... } // export const campaign = ({ id }: Prisma.CampaignWhereUniqueInput) => { ... } // export const createCampaign = ({ input }: { input: Prisma.CampaignCreateInput }) => { ... } // export const updateCampaign = ({ id, input }: { id: number; input: Prisma.CampaignUpdateInput }) => { ... } // export const deleteCampaign = ({ id }: Prisma.CampaignWhereUniqueInput) => { ... } // --- Placeholder for triggerCampaignSend function (to be added in Section 6) --- // export const triggerCampaignSend = async ({ input }: { input: TriggerCampaignSendInput }) => { ... }axiosfor HTTP, follows Infobip API structure, includes basic validation, logging, and error handling.3. Building the API Layer (GraphQL)
Expose backend functionality via GraphQL.
Define GraphQL Schema (SDL)
Modify
api/src/graphql/campaigns.sdl.tsto define the mutation and its input/output types.# api/src/graphql/campaigns.sdl.ts export const schema = gql` # Type returned after attempting to send SMS type SendSmsResponse { success: Boolean! bulkId: String # Infobip's ID for the batch submission message: String # Optional message for non-bulk results # Optional: Add individual message statuses if needed # messages: [InfobipMessageStatus] } # Input for the sendSms mutation input SendSmsInput { to: [String!]! # Array of phone numbers (E.164 format required) text: String! from: String # Optional: Override default sender ID } # Define Mutations (actions that change data or trigger processes) type Mutation { """""" Triggers sending an SMS message to multiple recipients via Infobip. Requires authentication. Use for ad-hoc sends not tied to a stored campaign. """""" sendSms(input: SendSmsInput!): SendSmsResponse! @requireAuth } # --- Placeholder Types/Queries/Mutations for Campaigns (added in Section 6) --- # type Campaign { ... } # type Recipient { ... } # type Query { ... } # input CreateCampaignInput { ... } # input UpdateCampaignInput { ... } # input TriggerCampaignSendInput { ... } # type Mutation { # createCampaign(...) # updateCampaign(...) # deleteCampaign(...) # triggerCampaignSend(...) # } # --- End Placeholders --- `@requireAuth: Ensures only authenticated users can call this. Set up Redwood Auth (e.g.,dbAuth) or remove temporarily for initial testing without auth.Link Service to Mutation
Rename the service function to match the mutation name (
sendSms).api/src/services/campaigns/campaigns.ts, renamesendSmsViaInfobiptosendSms.// api/src/services/campaigns/campaigns.ts // ... imports ... interface SendSmsInput { // Renamed from SendSmsPayload to match SDL to: string[] text: string from?: string } // Renamed function to match SDL mutation name 'sendSms' // The input argument is automatically destructured by Redwood { input } export const sendSms = async ({ input }: { input: SendSmsInput }) => { const { to, text, from } = input // Destructure from the input object const apiKey = process.env.INFOBIP_API_KEY const baseUrl = process.env.INFOBIP_BASE_URL const senderId = from || process.env.INFOBIP_SENDER_ID || 'InfoSMS' // ... (rest of the validation and payload logic remains the same) ... const infobipUrl = `${baseUrl}/sms/3/messages` // Updated to current SMS API v3 endpoint const payload = { messages: to.map((recipient) => ({ destinations: [{ to: recipient }], from: senderId, text: text, })), } try { logger.info(`Sending SMS via Infobip to ${to.length} recipients.`) logger.debug({ payload: { messages: payload.messages.map(m => ({...m, text: '[REDACTED]'})) } }, 'Infobip Request Payload (Text Redacted):') const response = await axios.post(infobipUrl, payload, { headers: { Authorization: `App ${apiKey}`, 'Content-Type': 'application/json', Accept: 'application/json', }, timeout: 10000, }) logger.info( { bulkId: response.data.bulkId, status: response.status }, 'Infobip SMS submitted successfully.' ) // Ensure the return matches SendSmsResponse type in SDL return { success: true, bulkId: response.data.bulkId, // messages: response.data.messages, // Optional detailed statuses message: `SMS submitted successfully to ${to.length} numbers.` // Add optional message } } catch (error) { logger.error({ error }, 'Failed to send SMS via Infobip.') if (axios.isAxiosError(error)) { logger.error( { status: error.response?.status, data: error.response?.data, }, 'Infobip API Error Details' ) const errorMessage = error.response?.data?.requestError?.serviceException?.text || error.message; throw new Error(`Infobip API request failed: ${errorMessage}`) } else { throw new Error(`An unexpected error occurred: ${error.message}`) } // Re-throw the error after logging so GraphQL reports it // throw error; // Original code had this commented out, but re-throwing is usually correct for GraphQL } } // ... rest of the file ...Testing with GraphQL Playground
yarn rw devhttp://localhost:8911/graphql.mutation SendTestSms { # Note: This will fail if @requireAuth is active and you haven't set up auth sendSms( input: { # Replace with actual E.164 numbers you can test with to: [""+15551234567"", ""+15559876543""] text: ""Hello from RedwoodJS and Infobip!"" # from: ""OptionalSender"" # Optionally override sender from .env } ) { success bulkId message } }4. Integrating with Infobip (Configuration Details)
Details on obtaining and managing Infobip credentials.
Obtaining Infobip Credentials
.envfile asINFOBIP_API_KEY.https://your-unique-id.api.infobip.com)..envfile asINFOBIP_BASE_URL.fromnumber/name):.envor.env.defaults(INFOBIP_SENDER_ID).Secure Storage
.envfiles or API keys to Git. Ensure.envis in.gitignore.Environment Variables Summary
INFOBIP_API_KEY: Your secret API key. Obtain: Infobip Portal.INFOBIP_BASE_URL: Your account-specific API URL. Obtain: Infobip Portal/Docs.INFOBIP_SENDER_ID: Default sender ID/number. Configure: Infobip Portal (subject to regulations).DATABASE_URL: Database connection string. Obtain: Database provider/setup.SESSION_SECRET: Secure random string for session management (used by Redwood auth). Generate one.Fallback Mechanisms
opossum), or alternative providers for critical systems, though this adds complexity.5. Error Handling, Logging, and Retry Mechanisms
Building resilience into the application.
Consistent Error Handling Strategy
try...catch, logs errors with context (logger.error), and re-throws errors for GraphQL to handle.Logging
src/lib/logger.ts).info(success),warn(recoverable issues, retries),error(failures),debug(verbose dev info).bulkId, error messages, status codes.Retry Mechanism (Simple Example)
Add basic retries with exponential backoff to
sendSmsfor transient network or server errors.// api/src/services/campaigns/campaigns.ts import axios from 'axios' // ... other imports ... import { logger } from 'src/lib/logger' // Helper function for async delay const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); // ... interface SendSmsInput ... export const sendSms = async ({ input }: { input: SendSmsInput }) => { const { to, text, from } = input const apiKey = process.env.INFOBIP_API_KEY const baseUrl = process.env.INFOBIP_BASE_URL const senderId = from || process.env.INFOBIP_SENDER_ID || 'InfoSMS' // ... validation ... if (!apiKey || !baseUrl) { logger.error('Infobip API Key or Base URL missing in environment variables.') throw new Error('Infobip configuration is incomplete.') } if (!to || to.length === 0 || !text) { logger.error('Missing required parameters: to (list of numbers) and text.') throw new Error('Recipient numbers and message text are required.') } const infobipUrl = `${baseUrl}/sms/3/messages` // Updated to current SMS API v3 endpoint const payload = { messages: to.map((recipient) => ({ destinations: [{ to: recipient }], from: senderId, text: text, })), } const MAX_RETRIES = 3; let attempt = 0; while (attempt < MAX_RETRIES) { attempt++; try { logger.info(`Sending SMS via Infobip (Attempt ${attempt}/${MAX_RETRIES}) to ${to.length} recipients.`); // logger.debug({ payload }, 'Infobip Request Payload:'); // Be cautious logging payload const response = await axios.post(infobipUrl, payload, { headers: { Authorization: `App ${apiKey}`, 'Content-Type': 'application/json', Accept: 'application/json', }, timeout: 15000 // Slightly longer timeout for retries }); logger.info( { bulkId: response.data.bulkId, status: response.status, attempt }, 'Infobip SMS submitted successfully.' ); return { success: true, bulkId: response.data.bulkId, message: `SMS submitted successfully on attempt ${attempt}.`, // messages: response.data.messages, }; } catch (error) { logger.warn({ error: error.message, attempt, maxRetries: MAX_RETRIES }, `Attempt ${attempt} failed for Infobip SMS.`); let shouldRetry = false; // Exponential backoff: 1s, 2s, 4s... + some jitter let waitMs = 1000 * Math.pow(2, attempt - 1) + Math.random() * 500; if (axios.isAxiosError(error)) { const status = error.response?.status; // Retry on network errors or specific server errors (e.g., 5xx) if (!status || (status >= 500 && status <= 599)) { shouldRetry = true; logger.warn(`Network or server error (${status || 'N/A'}), scheduling retry.`); } else { // Log details for non-retryable client errors (4xx) or others logger.error({ status: error.response?.status, data: error.response?.data }, 'Non-retryable Infobip API Error Details'); } } else { // Non-axios error (e.g., code error before request) logger.error({ error }, 'Non-Axios error during Infobip request attempt.'); // Generally don't retry these unless specifically handled } if (shouldRetry && attempt < MAX_RETRIES) { logger.info(`Waiting ${Math.round(waitMs)}ms before next retry.`); await delay(waitMs); // Continue to next iteration of the loop } else { // Last attempt failed or non-retryable error occurred logger.error({ error: error.message, attempt }, 'Failed to send SMS via Infobip after all retries or due to non-retryable error.'); // Construct a meaningful error to throw let finalError; if (axios.isAxiosError(error)) { const errorMessage = error.response?.data?.requestError?.serviceException?.text || error.message; finalError = new Error(`Infobip API request failed after ${attempt} attempts: ${errorMessage}`); } else { finalError = new Error(`An unexpected error occurred after ${attempt} attempts: ${error.message}`); } throw finalError; // Throw the aggregated/final error } } } // This line should technically be unreachable if the logic above is correct, // as the loop either returns successfully or throws an error. // Included primarily for type safety / exhaustive checks in some setups. // throw new Error('SMS sending failed unexpectedly after retry loop.'); } // ... rest of file ...async-retryfor more complex retry strategies.Testing Error Scenarios
.env) -> Expect 401 error.6. Creating a Database Schema and Data Layer
Define models for campaigns and recipients.
Define Prisma Schema
Update
api/db/schema.prisma.isOptedOutfield added toRecipient.Database Migrations
Apply schema changes.
yarn rw prisma migrate dev # Provide migration name, e.g., ""add campaign recipient models optout""This updates the DB schema and regenerates the Prisma Client.
Implement Data Access (CRUD Operations)
Add CRUD functions and the
triggerCampaignSendlogic toapi/src/services/campaigns/campaigns.ts.// api/src/services/campaigns/campaigns.ts import axios from 'axios' import type { Prisma, Campaign, Recipient } from '@prisma/client' // Import types import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' import { requireAuth } from 'src/lib/auth' // Assuming auth is set up // --- Helper: delay function (from Section 5) --- const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); // --- Interface: SendSmsInput (from Section 3) --- interface SendSmsInput { to: string[]; text: string; from?: string; } // --- Service Function: sendSms (with retries, from Section 5) --- export const sendSms = async ({ input }: { input: SendSmsInput }) => { // ... existing sendSms logic with retries ... const { to, text, from } = input const apiKey = process.env.INFOBIP_API_KEY const baseUrl = process.env.INFOBIP_BASE_URL const senderId = from || process.env.INFOBIP_SENDER_ID || 'InfoSMS' if (!apiKey || !baseUrl) { logger.error('Infobip API Key or Base URL missing in environment variables.') throw new Error('Infobip configuration is incomplete.') } if (!to || to.length === 0 || !text) { logger.error('Missing required parameters: to (list of numbers) and text.') throw new Error('Recipient numbers and message text are required.') } const infobipUrl = `${baseUrl}/sms/3/messages` // Updated to current SMS API v3 endpoint const payload = { messages: to.map((recipient) => ({ destinations: [{ to: recipient }], from: senderId, text: text, })), } const MAX_RETRIES = 3; let attempt = 0; while (attempt < MAX_RETRIES) { attempt++; try { logger.info(`Sending SMS via Infobip (Attempt ${attempt}/${MAX_RETRIES}) to ${to.length} recipients.`); const response = await axios.post(infobipUrl, payload, { headers: { Authorization: `App ${apiKey}`, 'Content-Type': 'application/json', Accept: 'application/json', }, timeout: 15000 }); logger.info( { bulkId: response.data.bulkId, status: response.status, attempt }, 'Infobip SMS submitted successfully.' ); return { success: true, bulkId: response.data.bulkId, message: `SMS submitted successfully on attempt ${attempt}.`, }; } catch (error) { logger.warn({ error: error.message, attempt, maxRetries: MAX_RETRIES }, `Attempt ${attempt} failed for Infobip SMS.`); let shouldRetry = false; let waitMs = 1000 * Math.pow(2, attempt - 1) + Math.random() * 500; if (axios.isAxiosError(error)) { const status = error.response?.status; if (!status || (status >= 500 && status <= 599)) { shouldRetry = true; logger.warn(`Network or server error (${status || 'N/A'}), scheduling retry.`); } else { logger.error({ status: error.response?.status, data: error.response?.data }, 'Non-retryable Infobip API Error Details'); } } else { logger.error({ error }, 'Non-Axios error during Infobip request attempt.'); } if (shouldRetry && attempt < MAX_RETRIES) { logger.info(`Waiting ${Math.round(waitMs)}ms before next retry.`); await delay(waitMs); } else { logger.error({ error: error.message, attempt }, 'Failed to send SMS via Infobip after all retries or due to non-retryable error.'); let finalError; if (axios.isAxiosError(error)) { const errorMessage = error.response?.data?.requestError?.serviceException?.text || error.message; finalError = new Error(`Infobip API request failed after ${attempt} attempts: ${errorMessage}`); } else { finalError = new Error(`An unexpected error occurred after ${attempt} attempts: ${error.message}`); } throw finalError; } } } // Should be unreachable throw new Error('SMS sending failed unexpectedly after retry loop.'); } // --- Campaign CRUD Functions --- /** Returns a list of campaigns (consider pagination for production). */ export const campaigns = () => { requireAuth({ roles: ['admin', 'marketer'] }) // Example role check return db.campaign.findMany({ orderBy: { createdAt: 'desc' } }) } /** Returns a single campaign by ID, including its recipients. */ export const campaign = ({ id }: Prisma.CampaignWhereUniqueInput) => { requireAuth() return db.campaign.findUnique({ where: { id }, include: { recipients: { // Optionally filter/paginate recipients here too orderBy: { createdAt: 'asc' } } }, }) } // Use Prisma generated types for better safety interface CreateCampaignServiceInput { input: Omit<Prisma.CampaignCreateInput, 'recipients'> & { // Base Campaign data recipientIds?: number[] // Accept array of Recipient IDs } } /** Creates a new campaign. Optionally connects recipients by ID. */ export const createCampaign = async ({ input }: CreateCampaignServiceInput) => { requireAuth({ roles: ['admin', 'marketer'] }) const { recipientIds, ...campaignData } = input return db.campaign.create({ data: { ...campaignData, // Spread basic fields like name, message, status status: campaignData.status || 'DRAFT', // Ensure default status // Connect recipients if IDs are provided recipients: recipientIds && recipientIds.length > 0 ? { connect: recipientIds.map((id) => ({ id })) } : undefined, }, }) } interface UpdateCampaignServiceInput { id: number; input: Omit<Prisma.CampaignUpdateInput, 'recipients'> & { recipientIds?: number[] // Allow updating connected recipients } } /** Updates an existing campaign. Allows replacing the recipient list. */ export const updateCampaign = ({ id, input }: UpdateCampaignServiceInput) => { requireAuth({ roles: ['admin', 'marketer'] }) const { recipientIds, ...campaignData } = input return db.campaign.update({ where: { id }, data: { ...campaignData, // Use 'set' to replace the entire list of connected recipients // Use connect/disconnect for adding/removing specific ones if needed recipients: recipientIds ? { set: recipientIds.map((id) => ({ id })) } : undefined, // If recipientIds is omitted, don't change recipients }, }) } /** Deletes a campaign by its ID. */ export const deleteCampaign = ({ id }: Prisma.CampaignWhereUniqueInput) => { requireAuth({ roles: ['admin'] }) // Stricter role for deletion // Add checks: prevent deletion of 'SENDING' or recently 'SENT' campaigns? return db.campaign.delete({ where: { id }, }) } // --- Trigger Campaign Sending Logic --- interface TriggerCampaignSendInput { campaignId: number } /** Fetches campaign details and sends SMS to its ACTIVE recipients. */ export const triggerCampaignSend = async ({ input }: { input: TriggerCampaignSendInput }) => { requireAuth({ roles: ['admin', 'marketer'] }) const { campaignId } = input; logger.info({ campaignId }, ""Attempting to trigger campaign send.""); // Fetch campaign WITH recipients const camp = await db.campaign.findUnique({ where: { id: campaignId }, include: { recipients: true } // Include related recipients }); if (!camp) { logger.error({ campaignId }, ""Campaign not found for triggering send.""); throw new Error(`Campaign with ID ${campaignId} not found.`); } // Filter out opted-out recipients const activeRecipients = camp.recipients.filter(r => !r.isOptedOut); if (activeRecipients.length === 0) { logger.warn({ campaignId }, ""Campaign has no active (opted-in) recipients. Nothing to send.""); // Update status to reflect this outcome await db.campaign.update({ where: { id: campaignId }, data: { status: 'COMPLETE_NO_RECIPIENTS', sentAt: new Date() } // Mark as completed }); return { success: true, message: ""Campaign has no active recipients."" }; } // Prevent re-sending or sending while already in progress if (camp.status === 'SENDING' || camp.status === 'SENT') { logger.warn({ campaignId, status: camp.status }, ""Campaign is already sending or has been sent. Send trigger ignored.""); // Return success:false or throw an error depending on desired behavior return { success: false, message: `Campaign status is already ${camp.status}. Send trigger ignored.` }; } // Update campaign status to SENDING before initiating send await db.campaign.update({ where: { id: campaignId }, data: { status: 'SENDING', sentAt: new Date() } // Mark start time }); let sendResult; try { const recipientNumbers = activeRecipients.map(r => r.phoneNumber); const sender = camp.senderId; // Use campaign-specific sender if set // Call the core SMS sending function (which includes retries) sendResult = await sendSms({ input: { to: recipientNumbers, text: camp.message, from: sender // Pass campaign sender override if available } }); // Update campaign status to SENT (or potentially FAILED_PARTIAL if needed) await db.campaign.update({ where: { id: campaignId }, data: { status: 'SENT', // Mark as sent after successful submission to Infobip infobipBulkId: sendResult.bulkId // Store the bulk ID } }); logger.info({ campaignId, bulkId: sendResult.bulkId, recipientCount: recipientNumbers.length }, ""Campaign SMS successfully submitted to Infobip.""); return { success: true, bulkId: sendResult.bulkId, message: `Campaign ${campaignId} sent to ${recipientNumbers.length} recipients.` }; } catch (error) { logger.error({ campaignId, error: error.message }, ""Failed to send campaign SMS via Infobip.""); // Update campaign status to FAILED await db.campaign.update({ where: { id: campaignId }, data: { status: 'FAILED' } // Mark as failed }); // Re-throw the error so the GraphQL mutation reports failure throw new Error(`Failed to send campaign ${campaignId}: ${error.message}`); } } --- ## 7. SMS Marketing Compliance (TCPA & FCC Regulations) **CRITICAL: Legal requirements for SMS marketing in the United States.** ### TCPA Requirements (United States) The Telephone Consumer Protection Act (TCPA) governs commercial SMS messaging. **Non-compliance can result in penalties of $500 per violation, or $1,500 per willful violation, with no cap on total damages.** #### Prior Express Written Consent (Required) Before sending any marketing SMS messages, you **must** obtain prior express written consent from recipients. This means: * **Clear Authorization**: Consumers must explicitly agree to receive SMS messages * **Written Record**: Maintain documentation of consent (date, time, method, exact language used) * **Scope Disclosure**: Clearly state what types of messages they'll receive and frequency **Example Consent Language:** ```text By providing your phone number and checking this box, you agree to receive marketing text messages from [Business Name] at the number provided, including messages sent by autodialer. Consent is not a condition of purchase. Message and data rates may apply. Message frequency varies. Reply HELP for help or STOP to cancel.New FCC Opt-Out Rules (Effective April 11, 2025)
The FCC adopted new opt-out rules that take effect April 11, 2025:
Any Reasonable Method: Consumers can revoke consent through ANY reasonable method – not just texting "STOP". Examples include:
10-Business-Day Processing: You must honor opt-out requests within 10 business days of receipt (previously no specific timeline)
One Confirmation Only: You may send ONE confirmation message acknowledging the opt-out request. No additional messages are permitted.
Cross-Channel Revocation: If a consumer revokes consent via phone call, that revocation also applies to text messages, and vice versa.
Time Restrictions
Do not send SMS messages before 8:00 AM or after 9:00 PM in the recipient's local time zone.
Implementation Recommendations
Enhance the
Recipientmodel to track compliance (already included in Section 6 schema):model Recipient { id Int @id @default(autoincrement()) phoneNumber String @unique firstName String? lastName String? isOptedOut Boolean @default(false) consentDate DateTime? // When consent was obtained consentMethod String? // How consent was obtained (e.g., "web_form", "checkout") optOutDate DateTime? // When user opted out optOutMethod String? // How user opted out (e.g., "SMS_STOP", "web_preferences") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt campaigns Campaign[] @relation("CampaignRecipients") @@index([isOptedOut]) }Add time validation to
triggerCampaignSend:// Add this check before sending in triggerCampaignSend function const now = new Date(); const hour = now.getHours(); // 0-23 in server's local time // NOTE: For production, use recipient's timezone via a timezone library if (hour < 8 || hour >= 21) { logger.warn({ campaignId, hour }, "Campaign send attempted outside allowed hours (8 AM - 9 PM)."); throw new Error("SMS messages cannot be sent before 8:00 AM or after 9:00 PM per TCPA regulations."); }Implement robust opt-out recognition:
Create a webhook endpoint to handle incoming SMS responses via Infobip's delivery reports. Process various opt-out phrases beyond just "STOP":
// Example: api/src/functions/smsWebhook/smsWebhook.ts export const handler = async (event) => { const body = JSON.parse(event.body); const incomingText = body.message?.text?.toLowerCase().trim(); const senderNumber = body.message?.from; const optOutKeywords = ['stop', 'unsubscribe', 'cancel', 'end', 'quit', 'remove']; const isOptOut = optOutKeywords.some(keyword => incomingText?.includes(keyword) ); if (isOptOut && senderNumber) { // Update recipient in database await db.recipient.updateMany({ where: { phoneNumber: senderNumber }, data: { isOptedOut: true, optOutDate: new Date(), optOutMethod: 'SMS_KEYWORD' } }); logger.info({ senderNumber, keyword: incomingText }, 'Recipient opted out via SMS'); // Send confirmation (ONE time only per FCC rules) // Implementation here... } return { statusCode: 200 }; };Marketing vs. Informational Messages
Documentation Requirements
Maintain detailed records for all campaigns:
International Considerations
If sending SMS internationally:
Resources:
8. Testing and Deployment
Testing Strategy
Unit Tests:
Test your service functions in isolation:
// api/src/services/campaigns/campaigns.test.ts import { sendSms, triggerCampaignSend } from './campaigns'; scenario('sends SMS successfully', async (scenario) => { // Mock axios or Infobip responses const result = await sendSms({ input: { to: ['+15551234567'], text: 'Test message', from: 'TestSender' } }); expect(result.success).toBe(true); expect(result.bulkId).toBeDefined(); }); scenario('respects opt-out status', async (scenario) => { const campaign = scenario.campaign.withOptedOutRecipient; const result = await triggerCampaignSend({ input: { campaignId: campaign.id } }); // Should not send to opted-out recipients expect(result.message).toContain('no active recipients'); });Integration Tests:
Test the full GraphQL API flow:
// Use RedwoodJS testing utilities import { mockGraphQLQuery, mockCurrentUser } from '@redwoodjs/testing/api'; test('sendSms mutation works', async () => { mockCurrentUser({ id: 1, roles: ['admin'] }); const result = await mockGraphQLQuery(SEND_SMS_MUTATION, { variables: { input: { to: ['+15551234567'], text: 'Test' } } }); expect(result.data.sendSms.success).toBe(true); });Deployment
Environment Variables:
Ensure all production environment variables are set:
INFOBIP_API_KEYINFOBIP_BASE_URLINFOBIP_SENDER_IDDATABASE_URLSESSION_SECRETDeployment Platforms:
RedwoodJS deploys well to:
Database Migrations:
Run migrations in production:
Monitoring:
Implement monitoring for:
9. Summary and Next Steps
What We Built
A production-ready RedwoodJS application featuring:
Key Enhancements for Production
luxonto respect recipient timezones for TCPA time restrictionsAdditional Resources
This foundation provides everything needed to build a compliant, scalable SMS marketing platform with RedwoodJS and Infobip.