RedwoodJS SMS Marketing: Sinch Batch API Integration Tutorial (2025)
Learn how to integrate Sinch Batch SMS API with RedwoodJS for marketing campaigns. Complete tutorial with Node.js, GraphQL, Prisma ORM, code examples, and bulk SMS sending implementation.
Learn how to build a complete SMS marketing system by integrating the Sinch Batch SMS API with your RedwoodJS application. This step-by-step tutorial covers campaign creation, contact management, bulk SMS sending, and database setup using GraphQL and Prisma ORM.
This RedwoodJS SMS integration tutorial provides programmatic control over SMS marketing campaigns with automated message creation, targeted bulk sending, and centralized management. We'll use the Sinch Batch SMS API endpoint (/xms/v1/{SERVICE_PLAN_ID}/batches) for sending marketing messages to multiple recipients. For advanced features like audience segmentation or detailed analytics, consult the official Sinch SMS API documentation.
Technologies Used:
RedwoodJS: Full-stack, serverless-first web framework based on React, GraphQL, and Prisma. Provides rapid development structure, built-in API layer, and CLI tools.
Node.js: Runtime environment for the RedwoodJS API side, executing backend logic and Sinch API interactions.
Prisma: Next-generation ORM for Node.js and TypeScript, handling database modeling and access.
Sinch Batch SMS API: Third-party SMS service for marketing message delivery.
GraphQL: Communication layer between frontend (web) and backend (API).
RedwoodJS application configured for Sinch Batch SMS API
Database models for Campaign and Contact data
RedwoodJS services managing campaigns and contacts
Node.js module encapsulating Sinch API interactions
Secure Sinch API credential handling
Error handling and logging for the integration
Deployment and verification instructions
Note: This guide focuses on backend implementation. UI components (pages, cells) for campaign management are outlined but require additional implementation for production use. Consider adding monitoring, analytics tracking, and comprehensive error recovery for production deployments.
Prerequisites:
Node.js v18+ and Yarn v1 installed
Terminal or command prompt access
Sinch account with SMS API access (includes Service Plan ID, API Token, and registered Sender ID)
Basic understanding of RedwoodJS, React, GraphQL, Prisma, and REST APIs
Code editor (e.g., VS Code)
Important: SMS messaging incurs costs. Review Sinch pricing and set up spending alerts before sending production messages. This guide assumes a new RedwoodJS project – adapt steps for existing projects.
1. RedwoodJS Project Setup for SMS Integration
Create a new RedwoodJS application and configure environment variables for Sinch API authentication.
Create RedwoodJS App:
Open your terminal and create the project named redwood-sinch-campaigns:
The --typescript flag initializes the project with TypeScript for type safety.
Navigate to Project Directory:
Bash
cd redwood-sinch-campaigns
Environment Variables Setup:
Create a .env file in the project root for your Sinch credentials:
Bash
touch .env
Add the following variables, replacing placeholders with your actual Sinch credentials:
.env
# .env
# Sinch API Credentials
# Obtain from your Sinch Dashboard -> APIs -> Your SMS API -> API Credentials
SINCH_SERVICE_PLAN_ID="YOUR_SERVICE_PLAN_ID"
SINCH_API_TOKEN="YOUR_API_TOKEN"
# Sinch Sender ID (Registered Number/Short Code/Alphanumeric)
# This MUST be a sender ID approved for your account in the Sinch dashboard
SINCH_SENDER_ID="YOUR_REGISTERED_SENDER_ID"
# Sinch API Endpoint (Confirm the correct region endpoint if needed)
SINCH_API_BASE_URL="https://us.sms.api.sinch.com" # Or eu.sms.api.sinch.com, etc.
# Database URL (Example for local SQLite, replace for PostgreSQL/MySQL)
DATABASE_URL="file:./dev.db"
Variable Explanations:
SINCH_SERVICE_PLAN_ID: Unique identifier for your Sinch service plan
SINCH_API_TOKEN: Secret authentication token (treat like a password)
SINCH_SENDER_ID: Registered phone number, short code, or alphanumeric sender ID approved by Sinch
SINCH_API_BASE_URL: Base URL for Sinch REST API (adjust for your region: us, eu, etc.)
DATABASE_URL: Database connection string (adjust provider in schema.prisma)
Security Best Practices:
Verify .env is in your .gitignore file (RedwoodJS adds this by default)
Never commit credentials to version control
Use environment-specific variables for staging and production
Rotate API tokens regularly
Enable Sinch API IP restrictions if available
Install Dependencies:
Install node-fetch for HTTP requests to the Sinch API. While Node.js includes built-in fetch, explicitly adding node-fetch ensures compatibility across versions:
Bash
yarn workspace api add node-fetch@^2 # Use v2 for CommonJS compatibility with Redwood api side
yarn workspace api add -D @types/node-fetch@^2
yarn workspace api add: Installs packages for the API side only
node-fetch@^2: Version 2 for CommonJS compatibility
@types/node-fetch@^2: TypeScript typings
Project Structure Overview:
Backend (api/):
api/db/schema.prisma: Database schema definition
api/src/functions/: Serverless function handlers (GraphQL endpoint)
Design Prisma database models for contacts and campaigns, then implement RedwoodJS services for business logic.
Define Database Schema:
Open api/db/schema.prisma and add models for Contact and Campaign:
Prisma
// api/db/schema.prisma
datasource db {
provider = "sqlite" // Or "postgresql", "mysql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Contact {
id Int @id @default(autoincrement())
phoneNumber String @unique // Assuming phone number is the unique identifier (E.164 format recommended)
firstName String?
lastName String?
// Add status for opt-outs, etc.
// status String @default("ACTIVE") // e.g., ACTIVE, OPTED_OUT
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add other relevant fields like email, tags, etc.
}
model Campaign {
id Int @id @default(autoincrement())
name String
messageBody String
// Basic status tracking
status String @default("DRAFT") // e.g., DRAFT, SENDING, SENT, FAILED
// Store the Batch ID returned by Sinch API after successful sending
sinchBatchId String? @unique // Renamed for clarity based on API used
scheduledAt DateTime? // Optional: For scheduling campaigns
sentAt DateTime? // Record when sending was initiated/completed
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relation to contacts (can be complex - many-to-many, or simplified for now)
// For simplicity here, we assume campaigns are sent to all contacts or a segment defined elsewhere.
// A many-to-many relation might look like:
// contacts Contact[] @relation("CampaignContacts")
@@index([status]) // Add index for faster status queries
}
// If using many-to-many for contacts per campaign:
// model _CampaignContacts {
// A Int
// B Int
// @@id([A, B])
// campaign Campaign @relation("CampaignContacts", fields: [A], references: [id])
// contact Contact @relation("CampaignContacts", fields: [B], references: [id])
// @@index([A])
// @@index([B])
// }
Schema Notes:
Store phone numbers in E.164 format (e.g., +15551234567)
sinchBatchId stores the ID returned by Sinch for tracking
Index on Campaign.status improves query performance
Consider adding many-to-many relations between contacts and campaigns for advanced segmentation
GDPR Compliance: Add opt-in status, consent timestamp, and unsubscribe tracking fields for production use
Apply Database Migrations:
Create and apply a database migration based on the schema changes:
Bash
yarn rw prisma migrate dev --name create_contacts_campaigns
This command creates a new SQL migration file in api/db/migrations/, applies the migration to your development database, and generates/updates the Prisma Client.
Generate Services:
Generate boilerplate code for services that handle business logic:
Bash
yarn rw g service contact
yarn rw g service campaign
This creates api/src/services/contacts/contacts.ts, api/src/services/campaigns/campaigns.ts, and corresponding test/scenario files.
Implement Service Logic (Basic CRUD):
Open the generated service files and add basic CRUD operations.
api/src/services/contacts/contacts.ts:
TypeScript
importtype { QueryResolvers, MutationResolvers } from'types/graphql'import { db } from'src/lib/db'import { requireAuth } from'src/lib/auth'// Assuming auth will be set upexportconstcontacts: QueryResolvers['contacts'] = () => {
requireAuth()
return db.contact.findMany()
}
exportconstcontact: QueryResolvers['contact'] = ({ id }) => {
requireAuth()
return db.contact.findUnique({
where: { id },
})
}
interfaceCreateContactInput {
phoneNumber: string// Add validation for E.164 format here or in GraphQL layerfirstName?: stringlastName?: string
}
exportconstcreateContact: MutationResolvers['createContact'] = ({ input }: { input: CreateContactInput }) => {
requireAuth()
// TODO: Add validation for phoneNumber format (E.164)return db.contact.create({
data: input,
})
}
// Add updateContact and deleteContact if needed
Production Recommendations: Implement E.164 phone number validation using a library like libphonenumber-js. Add duplicate detection and merge logic for contacts with existing phone numbers.
api/src/services/campaigns/campaigns.ts:
TypeScript
importtype { QueryResolvers, MutationResolvers } from'types/graphql'import { db } from'src/lib/db'import { requireAuth } from'src/lib/auth'// Assuming auth is set up// We will import the Sinch client later// import { sendSinchSmsBatch } from 'src/lib/sinchClient'exportconstcampaigns: QueryResolvers['campaigns'] = () => {
requireAuth()
return db.campaign.findMany({ orderBy: { createdAt: 'desc' } })
}
exportconstcampaign: QueryResolvers['campaign'] = ({ id }) => {
requireAuth()
return db.campaign.findUnique({
where: { id },
})
}
interfaceCreateCampaignInput {
name: stringmessageBody: string
}
exportconstcreateCampaign: MutationResolvers['createCampaign'] = ({ input }: { input: CreateCampaignInput }) => {
requireAuth()
// Just create in DB for now. Sending logic will be added later.return db.campaign.create({
data: { ...input, status: 'DRAFT' },
})
}
// Placeholder for the sending action (Implementation in Section 4)exportconstsendCampaign = async ({ id }: { id: number }) => {
requireAuth()
console.log(`Placeholder: Logic to send campaign ${id} via Sinch would go here.`)
// 1. Fetch campaign details from DB// 2. Fetch target contacts from DB (e.g., all active contacts)// 3. Call the Sinch API client (to be built in Section 4)// 4. Update campaign status in DB (e.g., SENDING, SENT, FAILED)// 5. Store Sinch Batch IDconst tempCampaign = await db.campaign.findUnique({ where: { id } }); // Fetch for return type consistencyreturn { success: true, message: `Campaign ${id} sending initiated (placeholder).`, campaign: tempCampaign }
}
// Add updateCampaign and deleteCampaign if needed
Production Recommendations: Add pagination, filtering, and sorting capabilities for the campaigns query. Implement cursor-based pagination for large datasets.
Why RedwoodJS Services? Services encapsulate business logic, making your SMS marketing code modular, testable, and reusable. They bridge the GraphQL API layer with Prisma database operations and external API integrations like Sinch. For more on RedwoodJS architecture, see the official RedwoodJS documentation.
Data Access: Handled by Prisma Client via Redwood services (db.campaign.findMany(), db.contact.create(), etc.). Redwood's db object is an instance of PrismaClient.
Migrations: Managed by yarn rw prisma migrate dev. For production, use yarn rw prisma migrate deploy.
Seed Sample Data (Optional):
Use Prisma seeds to populate your development database.
@requireAuth: RedwoodJS directive for enforcing authentication on GraphQL operations. Configure RedwoodJS Authentication (yarn rw setup auth ...) before production deployment. During development, you can temporarily remove this directive, but always restore it for security.
SendCampaignResponse & sendCampaign Mutation: Custom mutation to trigger campaign sending logic in the service.
Testing API Endpoints (GraphQL Playground):
Start the development server:
Bash
yarn rw dev
Open your browser to http://localhost:8910/graphql (or the port specified in the console output). Test your queries and mutations.
Example Query (Get Campaigns):
GraphQL
query GetCampaigns {
campaigns {
id
name
status
createdAt
sinchBatchId # Added field}}
Example Mutation (Create Campaign):
GraphQL
mutation CreateNewCampaign {
createCampaign(input:{name:"May Promo Blast"messageBody:"Hey {firstName}, check out our May deals! Limited time only."}){
id
name
messageBody
status
}}
Note:@requireAuth might block requests if auth isn't configured. Remove it from the SDL temporarily for testing if needed, but ensure it's present for production.
4. Sinch Batch SMS API Integration
Implement a Node.js client to send bulk SMS messages using the Sinch API's /batches endpoint.
Create Sinch Client Library:
Create a new file for Sinch API interaction logic:
Implement Sinch API Client Logic:
Open api/src/lib/sinchClient/index.ts and add the code to interact with Sinch:
TypeScript
// api/src/lib/sinchClient/index.tsimport fetch from'node-fetch'// Using node-fetch v2constSINCH_SERVICE_PLAN_ID = process.env.SINCH_SERVICE_PLAN_IDconstSINCH_API_TOKEN = process.env.SINCH_API_TOKENconstSINCH_API_BASE_URL = process.env.SINCH_API_BASE_URL || 'https://us.sms.api.sinch.com'// Default fallbackinterfaceSinchAPIErrorResponse {
request_id?: string; // Optional based on Sinch error formaterror?: {
code: number;
message: string;
reference?: string;
};
// Batch specific errors might have different formatstext_code?: string;
text_message?: string;
}
// Helper function to make authenticated requests to Sinch APIasyncfunction sinchRequest<T>(
endpoint: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
body?: Record<string, any>
): Promise<T> {
if (!SINCH_SERVICE_PLAN_ID || !SINCH_API_TOKEN) {
thrownewError('Sinch API credentials (SINCH_SERVICE_PLAN_ID, SINCH_API_TOKEN) are not configured in .env')
}
const url = `${SINCH_API_BASE_URL}/xms/v1/${SINCH_SERVICE_PLAN_ID}${endpoint}`const headers = {
'Authorization': `Bearer ${SINCH_API_TOKEN}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
}
console.log(`Sinch Request: ${method}${url}`) // Basic loggingtry {
const response = awaitfetch(url, {
method: method,
headers: headers,
body: body ? JSON.stringify(body) : undefined,
})
if (!response.ok) {
// Attempt to parse error details from Sinch, which can varyleterrorDetails: string | SinchAPIErrorResponse = await response.text();
let errorMessage = response.statusText;
try {
const parsedError = JSON.parse(errorDetails) asSinchAPIErrorResponse;
console.error('Sinch API Error Response:', parsedError);
// Try different common error message fields
errorMessage = parsedError.error?.message || parsedError.text_message || errorMessage;
thrownewError(`Sinch API Error (${response.status}): ${errorMessage}. Ref: ${parsedError.error?.reference || parsedError.request_id || 'N/A'}`);
} catch (parseError) {
// If parsing fails, use the raw textconsole.error('Sinch API Error Response (non-JSON or parse error):', errorDetails);
thrownewError(`Sinch API Error (${response.status}): ${response.statusText}. Response: ${errorDetails}`);
}
}
// Handle cases where Sinch might return 204 No Content or similar success without bodyif (response.status === 204 || response.headers.get('content-length') === '0') {
// Return an empty object or specific success indicator if needed// For batch send (POST /batches), Sinch *does* return a body on success (200 or 201)return {} as T; // Adjust as needed for other endpoints
}
// Assuming successful responses are JSONconst data = await response.json() as T
console.log(`Sinch Response (${response.status}):`, data) // Basic loggingreturn data;
} catch (error) {
console.error(`Error during Sinch API request to ${endpoint}:`, error)
// Re-throw a more specific error or handle appropriately// Avoid exposing raw Sinch errors directly to frontend if possiblethrownewError(`Failed to communicate with Sinch API: ${error.message}`);
}
}
// --- Specific Sinch API Functions ---interfaceSendSMSInput {
to: string[]; // Array of phone numbers in E.164 formatfrom: string; // Your Sinch virtual number, Short Code, or Alphanumeric Sender IDbody: string; // The message content// Common optional parameters for batch SMS:delivery_report?: 'none' | 'summary' | 'full'; // default 'none'client_reference?: string; // Your custom ID for trackingparameters?: Record<string, { default?: string; [phoneNumber: string]: string }>; // For message templating/personalization// See: https://developers.sinch.com/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/SendSMS
}
interfaceSendSMSResponse {
id: string; // The Batch ID returned by Sinchto: string[];
from: string;
canceled: boolean;
body: string;
type: string; // e.g., "mt_batch"created_at: string; // ISO 8601 date stringmodified_at: string; // ISO 8601 date stringdelivery_report: string;
// ... other fields like 'send_at', 'expire_at', 'flash_message', 'feedback_enabled' etc.
}
/**
* Sends an SMS batch message using the Sinch API.
* This function uses the standard Sinch Batch SMS endpoint (`/xms/v1/{SERVICE_PLAN_ID}/batches`).
* While suitable for sending marketing messages, check Sinch documentation for any dedicated
* "Marketing Campaign" APIs if you need features beyond batch sending (e.g., advanced list management).
*/exportasyncfunctionsendSinchSmsBatch(input: SendSMSInput): Promise<SendSMSResponse> {
// Input validation (basic)if (!input.to || input.to.length === 0) {
thrownewError('Recipient list (`to`) cannot be empty.');
}
if (!input.from) {
thrownewError('Sender ID (`from`) is required.');
}
if (!input.body) {
thrownewError('Message body (`body`) is required.');
}
// TODO: Add more robust validation (e.g., E.164 format for 'to' numbers, length checks for 'body')const endpoint = '/batches'// Endpoint for sending batchesreturn sinchRequest<SendSMSResponse>(endpoint, 'POST', {
to: input.to,
from: input.from,
body: input.body,
delivery_report: input.delivery_report || 'summary', // Request a summary DLRclient_reference: input.client_reference, // Pass through client reference if providedparameters: input.parameters, // Pass through parameters if provided
})
}
// Add other functions as needed, e.g., getBatchStatus(batchId), cancelBatch(batchId), etc.// Consult the Sinch SMS API documentation for the correct endpoints and payloads.
Key Features:
Reads credentials and base URL from process.env
Includes a sinchRequest helper function handling authentication, headers, request/response logging, and error handling for all Sinch API calls
Provides sendSinchSmsBatch to interact with the /batches endpoint for sending messages to multiple recipients
Includes basic input validation and improved error parsing
API Key Security: Credentials are read from environment variables, never hardcoded
Production Recommendations: Implement retry logic with exponential backoff, rate limiting to respect Sinch API quotas, and circuit breaker patterns for fault tolerance. Consider using libraries like axios-retry or p-retry for robust SMS delivery. For more on SMS API best practices, see our guide on bulk SMS broadcasting.
Obtaining Sinch Credentials:
Step
Action
1
Log in to your Sinch Customer Dashboard
2
Navigate to SMS → APIs section
3
Find your Service Plan ID
4
Under API Credentials, generate or copy an API Token
5
Note your registered Sender ID (under Numbers or Sender IDs) – must be approved for sending
6
Copy these values into your .env file
7
Confirm the correct API Base URL for your account's region (e.g., us.sms.api.sinch.com, eu.sms.api.sinch.com)
Troubleshooting Tips:
Verify sender ID is approved in your Sinch dashboard before testing
Check API token hasn't expired
Confirm you're using the correct regional endpoint
Review Sinch account quotas and spending limits
Connect Sinch Client to Campaign Service:
Update the sendCampaign function in api/src/services/campaigns/campaigns.ts to use the Sinch client:
TypeScript
// api/src/services/campaigns/campaigns.tsimporttype { QueryResolvers, MutationResolvers, CampaignResolvers } from'types/graphql'// Added CampaignResolversimport { db } from'src/lib/db'import { sendSinchSmsBatch } from'src/lib/sinchClient'// ... (rest of the imports and existing code like campaigns, campaign, createCampaign) ...
Note: Complete implementation of sendCampaign function with full error handling, transaction management, delivery tracking, and UI components requires additional sections (5–8) covering frontend implementation, testing strategies, production deployment, monitoring setup, and comprehensive troubleshooting guides. Implement these features before production use.
Next Steps for Production SMS Marketing
To deploy a production-ready RedwoodJS SMS marketing platform:
Implement Complete sendCampaign Function: Add error handling, database transactions, contact fetching, and Sinch API integration
Build Frontend UI: Create React components, pages, and cells for campaign management
Add Testing: Write unit tests for services, integration tests for API endpoints, and end-to-end tests for critical workflows
Configure Deployment: Set up environment variables for production, configure database migrations, and deploy to your hosting platform
Ensure Compliance: Implement GDPR-compliant opt-in/opt-out mechanisms, consent tracking, and data retention policies
Add Analytics: Track campaign performance, delivery rates, and user engagement metrics
Frequently asked questions
Integrate the Sinch Batch SMS API into your RedwoodJS application. This involves setting up environment variables for your Sinch credentials, creating database models for campaigns and contacts, and implementing RedwoodJS services to manage these entities and interact with the Sinch API. This guide provides a step-by-step tutorial for this integration process.
The Sinch Batch SMS API allows you to send bulk SMS messages programmatically within your RedwoodJS app, making it suitable for marketing campaigns. This guide focuses on integrating with the /xms/v1/{SERVICE_PLAN_ID}/batches endpoint, enabling automated campaign creation and targeted sending. Note that Sinch may offer dedicated Marketing Campaign APIs with additional features.
You can create a dedicated Sinch API client within your api/src/lib directory to handle the API interaction logic. Then import and call the client functions from your RedwoodJS services, specifically within the service responsible for managing and sending campaigns.
Prisma acts as an ORM (Object-Relational Mapper), simplifying database interactions within your RedwoodJS application. It allows you to define data models (like Contacts and Campaigns) and easily perform CRUD operations without writing raw SQL queries.
You primarily need services for managing campaigns and contacts. The campaign service handles creating, updating, and sending campaigns, while the contact service manages your contact list. These services interact with Prisma for database access and the Sinch client for sending SMS messages.
Create a .env file in the root of your RedwoodJS project. Add your SINCH_SERVICE_PLAN_ID, SINCH_API_TOKEN, SINCH_SENDER_ID, and SINCH_API_BASE_URL obtained from your Sinch dashboard to this file. Your .env file should already be in .gitignore for security.
GraphQL serves as the communication layer between the RedwoodJS frontend (web side) and backend (api side). You define GraphQL schemas (SDL) to specify data types and queries/mutations. RedwoodJS automatically generates resolvers that map these to your service functions.
Use the command yarn create redwood-app <project-name> --typescript in your terminal. The --typescript flag is recommended for better type safety. Then, navigate into the project directory using cd <project-name>.
While Node.js has a built-in fetch, using node-fetch@^2 explicitly ensures better CommonJS compatibility, particularly with the RedwoodJS api side. Install it with yarn workspace api add node-fetch@^2 and its types with yarn workspace api add -D @types/node-fetch@^2.
The example uses SQLite for simplicity, but you can configure PostgreSQL or MySQL in your schema.prisma file. The DATABASE_URL in your .env file controls the database connection. For local SQLite, use DATABASE_URL="file:./dev.db".
The provided sinchRequest helper function in the Sinch client includes basic error handling and logging. It attempts to parse error responses from Sinch to provide more detailed messages for debugging. Always avoid exposing raw Sinch errors to the frontend if possible.
Yes, you can use a registered phone number, short code, or alphanumeric sender ID approved by Sinch. Set the SINCH_SENDER_ID environment variable in your .env file. This value is then used in the from field of the API request.
The Sinch Service Plan ID is a unique identifier that specifies the service plan you're using within the Sinch platform. It's required to make authenticated requests to the Sinch API and is included in the API endpoint URL.
Learn how to build a complete SMS marketing system by integrating the Sinch Batch SMS API with your RedwoodJS application. This step-by-step tutorial covers campaign creation, contact management, bulk SMS sending, and database setup using GraphQL and Prisma ORM.
This RedwoodJS SMS integration tutorial provides programmatic control over SMS marketing campaigns with automated message creation, targeted bulk sending, and centralized management. We'll use the Sinch Batch SMS API endpoint (
/xms/v1/{SERVICE_PLAN_ID}/batches) for sending marketing messages to multiple recipients. For advanced features like audience segmentation or detailed analytics, consult the official Sinch SMS API documentation.Technologies Used:
System Architecture:
What You'll Build:
CampaignandContactdataNote: This guide focuses on backend implementation. UI components (pages, cells) for campaign management are outlined but require additional implementation for production use. Consider adding monitoring, analytics tracking, and comprehensive error recovery for production deployments.
Prerequisites:
Important: SMS messaging incurs costs. Review Sinch pricing and set up spending alerts before sending production messages. This guide assumes a new RedwoodJS project – adapt steps for existing projects.
1. RedwoodJS Project Setup for SMS Integration
Create a new RedwoodJS application and configure environment variables for Sinch API authentication.
Create RedwoodJS App: Open your terminal and create the project named
redwood-sinch-campaigns:The
--typescriptflag initializes the project with TypeScript for type safety.Navigate to Project Directory:
cd redwood-sinch-campaignsEnvironment Variables Setup: Create a
.envfile in the project root for your Sinch credentials:touch .envAdd the following variables, replacing placeholders with your actual Sinch credentials:
Variable Explanations:
SINCH_SERVICE_PLAN_ID: Unique identifier for your Sinch service planSINCH_API_TOKEN: Secret authentication token (treat like a password)SINCH_SENDER_ID: Registered phone number, short code, or alphanumeric sender ID approved by SinchSINCH_API_BASE_URL: Base URL for Sinch REST API (adjust for your region: us, eu, etc.)DATABASE_URL: Database connection string (adjust provider inschema.prisma)Security Best Practices:
.envis in your.gitignorefile (RedwoodJS adds this by default)Install Dependencies: Install
node-fetchfor HTTP requests to the Sinch API. While Node.js includes built-in fetch, explicitly addingnode-fetchensures compatibility across versions:yarn workspace api add node-fetch@^2 # Use v2 for CommonJS compatibility with Redwood api side yarn workspace api add -D @types/node-fetch@^2yarn workspace api add: Installs packages for the API side onlynode-fetch@^2: Version 2 for CommonJS compatibility@types/node-fetch@^2: TypeScript typingsProject Structure Overview:
Backend (
api/):api/db/schema.prisma: Database schema definitionapi/src/functions/: Serverless function handlers (GraphQL endpoint)api/src/graphql/: GraphQL schema definitions (*.sdl.ts)api/src/services/: Business logic implementationsapi/src/lib/: Utility functions and third-party clientsFrontend (
web/):web/src/pages/: Page componentsweb/src/components/: Reusable UI components (including Cells)web/src/layouts/: Layout componentsweb/src/Routes.tsx: Frontend routingConfiguration:
.env: Environment variables (Git-ignored)redwood.toml: Project configuration2. Database Schema and Service Implementation
Design Prisma database models for contacts and campaigns, then implement RedwoodJS services for business logic.
Define Database Schema: Open
api/db/schema.prismaand add models forContactandCampaign:Schema Notes:
+15551234567)sinchBatchIdstores the ID returned by Sinch for trackingCampaign.statusimproves query performanceApply Database Migrations: Create and apply a database migration based on the schema changes:
This command creates a new SQL migration file in
api/db/migrations/, applies the migration to your development database, and generates/updates the Prisma Client.Generate Services: Generate boilerplate code for services that handle business logic:
This creates
api/src/services/contacts/contacts.ts,api/src/services/campaigns/campaigns.ts, and corresponding test/scenario files.Implement Service Logic (Basic CRUD): Open the generated service files and add basic CRUD operations.
api/src/services/contacts/contacts.ts:import type { QueryResolvers, MutationResolvers } from 'types/graphql' import { db } from 'src/lib/db' import { requireAuth } from 'src/lib/auth' // Assuming auth will be set up export const contacts: QueryResolvers['contacts'] = () => { requireAuth() return db.contact.findMany() } export const contact: QueryResolvers['contact'] = ({ id }) => { requireAuth() return db.contact.findUnique({ where: { id }, }) } interface CreateContactInput { phoneNumber: string // Add validation for E.164 format here or in GraphQL layer firstName?: string lastName?: string } export const createContact: MutationResolvers['createContact'] = ({ input }: { input: CreateContactInput }) => { requireAuth() // TODO: Add validation for phoneNumber format (E.164) return db.contact.create({ data: input, }) } // Add updateContact and deleteContact if neededProduction Recommendations: Implement E.164 phone number validation using a library like
libphonenumber-js. Add duplicate detection and merge logic for contacts with existing phone numbers.api/src/services/campaigns/campaigns.ts:import type { QueryResolvers, MutationResolvers } from 'types/graphql' import { db } from 'src/lib/db' import { requireAuth } from 'src/lib/auth' // Assuming auth is set up // We will import the Sinch client later // import { sendSinchSmsBatch } from 'src/lib/sinchClient' export const campaigns: QueryResolvers['campaigns'] = () => { requireAuth() return db.campaign.findMany({ orderBy: { createdAt: 'desc' } }) } export const campaign: QueryResolvers['campaign'] = ({ id }) => { requireAuth() return db.campaign.findUnique({ where: { id }, }) } interface CreateCampaignInput { name: string messageBody: string } export const createCampaign: MutationResolvers['createCampaign'] = ({ input }: { input: CreateCampaignInput }) => { requireAuth() // Just create in DB for now. Sending logic will be added later. return db.campaign.create({ data: { ...input, status: 'DRAFT' }, }) } // Placeholder for the sending action (Implementation in Section 4) export const sendCampaign = async ({ id }: { id: number }) => { requireAuth() console.log(`Placeholder: Logic to send campaign ${id} via Sinch would go here.`) // 1. Fetch campaign details from DB // 2. Fetch target contacts from DB (e.g., all active contacts) // 3. Call the Sinch API client (to be built in Section 4) // 4. Update campaign status in DB (e.g., SENDING, SENT, FAILED) // 5. Store Sinch Batch ID const tempCampaign = await db.campaign.findUnique({ where: { id } }); // Fetch for return type consistency return { success: true, message: `Campaign ${id} sending initiated (placeholder).`, campaign: tempCampaign } } // Add updateCampaign and deleteCampaign if neededProduction Recommendations: Add pagination, filtering, and sorting capabilities for the campaigns query. Implement cursor-based pagination for large datasets.
Why RedwoodJS Services? Services encapsulate business logic, making your SMS marketing code modular, testable, and reusable. They bridge the GraphQL API layer with Prisma database operations and external API integrations like Sinch. For more on RedwoodJS architecture, see the official RedwoodJS documentation.
Data Layer Summary:
ERD (Simplified):
db.campaign.findMany(),db.contact.create(), etc.). Redwood'sdbobject is an instance ofPrismaClient.yarn rw prisma migrate dev. For production, useyarn rw prisma migrate deploy.Seed Sample Data (Optional): Use Prisma seeds to populate your development database.
Create seed file:
touch api/db/seed.tsAdd seed logic:
// api/db/seed.ts import { PrismaClient } from '@prisma/client' const db = new PrismaClient() async function main() { console.log('Start seeding…') // Seed Contacts await db.contact.upsert({ where: { phoneNumber: '+15551112222' }, update: {}, create: { phoneNumber: '+15551112222', firstName: 'Alice' }, // Use valid E.164 formats }) await db.contact.upsert({ where: { phoneNumber: '+15553334444' }, update: {}, create: { phoneNumber: '+15553334444', firstName: 'Bob', lastName: 'Smith' }, }) console.log('Seeded contacts.') // Seed Campaigns await db.campaign.upsert({ where: { name: 'Welcome Campaign Draft' }, // Use a unique field if possible update: {}, create: { name: 'Welcome Campaign Draft', messageBody: 'Welcome to our service!', status: 'DRAFT' } }) console.log('Seeded campaigns.') console.log('Seeding finished.') } main() .catch((e) => { console.error(e) process.exit(1) }) .finally(async () => { await db.$disconnect() })Configure the seed command in
prismasection of rootpackage.jsonif not present:// package.json (root) "prisma": { "seed": "yarn rw exec seed" }Run seeding:
3. GraphQL API Layer Configuration
Define GraphQL schemas (SDL) to expose campaign and contact operations through RedwoodJS's type-safe API layer.
Generate GraphQL SDL: Generate SDL files based on your Prisma schema models:
The
--crudflag generates standard GraphQL types, queries, and mutations based on the model and corresponding service functions.Review and Customize SDL: Inspect the generated files (
api/src/graphql/contacts.sdl.tsandapi/src/graphql/campaigns.sdl.ts). Customize as needed:api/src/graphql/contacts.sdl.ts:export const schema = gql` type Contact { id: Int! phoneNumber: String! firstName: String lastName: String createdAt: DateTime! updatedAt: DateTime! } type Query { contacts: [Contact!]! @requireAuth contact(id: Int!): Contact @requireAuth } input CreateContactInput { phoneNumber: String! # Consider adding scalar type for E.164 validation firstName: String lastName: String } input UpdateContactInput { phoneNumber: String firstName: String lastName: String } type Mutation { createContact(input: CreateContactInput!): Contact! @requireAuth # updateContact and deleteContact mutations will also be generated if using --crud # updateContact(id: Int!, input: UpdateContactInput!): Contact! @requireAuth # deleteContact(id: Int!): Contact! @requireAuth } `Enhancement Opportunity: Implement custom GraphQL scalar types for E.164 phone number validation. Add field-level documentation using GraphQL description strings.
api/src/graphql/campaigns.sdl.ts:export const schema = gql` type Campaign { id: Int! name: String! messageBody: String! status: String! sinchBatchId: String # Renamed field scheduledAt: DateTime sentAt: DateTime createdAt: DateTime! updatedAt: DateTime! } type Query { campaigns: [Campaign!]! @requireAuth campaign(id: Int!): Campaign @requireAuth } input CreateCampaignInput { name: String! messageBody: String! } input UpdateCampaignInput { name: String messageBody: String status: String sinchBatchId: String # Renamed field scheduledAt: DateTime sentAt: DateTime } # Add a mutation for our custom send action type SendCampaignResponse { success: Boolean! message: String campaign: Campaign # Return updated campaign } type Mutation { createCampaign(input: CreateCampaignInput!): Campaign! @requireAuth # updateCampaign, deleteCampaign mutations... # updateCampaign(id: Int!, input: UpdateCampaignInput!): Campaign! @requireAuth # deleteCampaign(id: Int!): Campaign! @requireAuth sendCampaign(id: Int!): SendCampaignResponse! @requireAuth # Custom mutation } `@requireAuth: RedwoodJS directive for enforcing authentication on GraphQL operations. Configure RedwoodJS Authentication (yarn rw setup auth ...) before production deployment. During development, you can temporarily remove this directive, but always restore it for security.SendCampaignResponse&sendCampaignMutation: Custom mutation to trigger campaign sending logic in the service.Testing API Endpoints (GraphQL Playground):
Start the development server:
Open your browser to
http://localhost:8910/graphql(or the port specified in the console output). Test your queries and mutations.Example Query (Get Campaigns):
query GetCampaigns { campaigns { id name status createdAt sinchBatchId # Added field } }Example Mutation (Create Campaign):
mutation CreateNewCampaign { createCampaign(input: { name: "May Promo Blast" messageBody: "Hey {firstName}, check out our May deals! Limited time only." }) { id name messageBody status } }Note:
@requireAuthmight block requests if auth isn't configured. Remove it from the SDL temporarily for testing if needed, but ensure it's present for production.4. Sinch Batch SMS API Integration
Implement a Node.js client to send bulk SMS messages using the Sinch API's
/batchesendpoint.Create Sinch Client Library: Create a new file for Sinch API interaction logic:
mkdir -p api/src/lib/sinchClient touch api/src/lib/sinchClient/index.tsImplement Sinch API Client Logic: Open
api/src/lib/sinchClient/index.tsand add the code to interact with Sinch:// api/src/lib/sinchClient/index.ts import fetch from 'node-fetch' // Using node-fetch v2 const SINCH_SERVICE_PLAN_ID = process.env.SINCH_SERVICE_PLAN_ID const SINCH_API_TOKEN = process.env.SINCH_API_TOKEN const SINCH_API_BASE_URL = process.env.SINCH_API_BASE_URL || 'https://us.sms.api.sinch.com' // Default fallback interface SinchAPIErrorResponse { request_id?: string; // Optional based on Sinch error format error?: { code: number; message: string; reference?: string; }; // Batch specific errors might have different formats text_code?: string; text_message?: string; } // Helper function to make authenticated requests to Sinch API async function sinchRequest<T>( endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE', body?: Record<string, any> ): Promise<T> { if (!SINCH_SERVICE_PLAN_ID || !SINCH_API_TOKEN) { throw new Error('Sinch API credentials (SINCH_SERVICE_PLAN_ID, SINCH_API_TOKEN) are not configured in .env') } const url = `${SINCH_API_BASE_URL}/xms/v1/${SINCH_SERVICE_PLAN_ID}${endpoint}` const headers = { 'Authorization': `Bearer ${SINCH_API_TOKEN}`, 'Content-Type': 'application/json', 'Accept': 'application/json', } console.log(`Sinch Request: ${method} ${url}`) // Basic logging try { const response = await fetch(url, { method: method, headers: headers, body: body ? JSON.stringify(body) : undefined, }) if (!response.ok) { // Attempt to parse error details from Sinch, which can vary let errorDetails: string | SinchAPIErrorResponse = await response.text(); let errorMessage = response.statusText; try { const parsedError = JSON.parse(errorDetails) as SinchAPIErrorResponse; console.error('Sinch API Error Response:', parsedError); // Try different common error message fields errorMessage = parsedError.error?.message || parsedError.text_message || errorMessage; throw new Error(`Sinch API Error (${response.status}): ${errorMessage}. Ref: ${parsedError.error?.reference || parsedError.request_id || 'N/A'}`); } catch (parseError) { // If parsing fails, use the raw text console.error('Sinch API Error Response (non-JSON or parse error):', errorDetails); throw new Error(`Sinch API Error (${response.status}): ${response.statusText}. Response: ${errorDetails}`); } } // Handle cases where Sinch might return 204 No Content or similar success without body if (response.status === 204 || response.headers.get('content-length') === '0') { // Return an empty object or specific success indicator if needed // For batch send (POST /batches), Sinch *does* return a body on success (200 or 201) return {} as T; // Adjust as needed for other endpoints } // Assuming successful responses are JSON const data = await response.json() as T console.log(`Sinch Response (${response.status}):`, data) // Basic logging return data; } catch (error) { console.error(`Error during Sinch API request to ${endpoint}:`, error) // Re-throw a more specific error or handle appropriately // Avoid exposing raw Sinch errors directly to frontend if possible throw new Error(`Failed to communicate with Sinch API: ${error.message}`); } } // --- Specific Sinch API Functions --- interface SendSMSInput { to: string[]; // Array of phone numbers in E.164 format from: string; // Your Sinch virtual number, Short Code, or Alphanumeric Sender ID body: string; // The message content // Common optional parameters for batch SMS: delivery_report?: 'none' | 'summary' | 'full'; // default 'none' client_reference?: string; // Your custom ID for tracking parameters?: Record<string, { default?: string; [phoneNumber: string]: string }>; // For message templating/personalization // See: https://developers.sinch.com/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/SendSMS } interface SendSMSResponse { id: string; // The Batch ID returned by Sinch to: string[]; from: string; canceled: boolean; body: string; type: string; // e.g., "mt_batch" created_at: string; // ISO 8601 date string modified_at: string; // ISO 8601 date string delivery_report: string; // ... other fields like 'send_at', 'expire_at', 'flash_message', 'feedback_enabled' etc. } /** * Sends an SMS batch message using the Sinch API. * This function uses the standard Sinch Batch SMS endpoint (`/xms/v1/{SERVICE_PLAN_ID}/batches`). * While suitable for sending marketing messages, check Sinch documentation for any dedicated * "Marketing Campaign" APIs if you need features beyond batch sending (e.g., advanced list management). */ export async function sendSinchSmsBatch(input: SendSMSInput): Promise<SendSMSResponse> { // Input validation (basic) if (!input.to || input.to.length === 0) { throw new Error('Recipient list (`to`) cannot be empty.'); } if (!input.from) { throw new Error('Sender ID (`from`) is required.'); } if (!input.body) { throw new Error('Message body (`body`) is required.'); } // TODO: Add more robust validation (e.g., E.164 format for 'to' numbers, length checks for 'body') const endpoint = '/batches' // Endpoint for sending batches return sinchRequest<SendSMSResponse>(endpoint, 'POST', { to: input.to, from: input.from, body: input.body, delivery_report: input.delivery_report || 'summary', // Request a summary DLR client_reference: input.client_reference, // Pass through client reference if provided parameters: input.parameters, // Pass through parameters if provided }) } // Add other functions as needed, e.g., getBatchStatus(batchId), cancelBatch(batchId), etc. // Consult the Sinch SMS API documentation for the correct endpoints and payloads.Key Features:
process.envsinchRequesthelper function handling authentication, headers, request/response logging, and error handling for all Sinch API callssendSinchSmsBatchto interact with the/batchesendpoint for sending messages to multiple recipientsProduction Recommendations: Implement retry logic with exponential backoff, rate limiting to respect Sinch API quotas, and circuit breaker patterns for fault tolerance. Consider using libraries like
axios-retryorp-retryfor robust SMS delivery. For more on SMS API best practices, see our guide on bulk SMS broadcasting.Obtaining Sinch Credentials:
.envfileus.sms.api.sinch.com,eu.sms.api.sinch.com)Troubleshooting Tips:
Connect Sinch Client to Campaign Service: Update the
sendCampaignfunction inapi/src/services/campaigns/campaigns.tsto use the Sinch client:// api/src/services/campaigns/campaigns.ts import type { QueryResolvers, MutationResolvers, CampaignResolvers } from 'types/graphql' // Added CampaignResolvers import { db } from 'src/lib/db' import { sendSinchSmsBatch } from 'src/lib/sinchClient' // ... (rest of the imports and existing code like campaigns, campaign, createCampaign) ...Note: Complete implementation of
sendCampaignfunction with full error handling, transaction management, delivery tracking, and UI components requires additional sections (5–8) covering frontend implementation, testing strategies, production deployment, monitoring setup, and comprehensive troubleshooting guides. Implement these features before production use.Next Steps for Production SMS Marketing
To deploy a production-ready RedwoodJS SMS marketing platform: