code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / sinch

Build Bulk SMS Broadcasting with Sinch, RedwoodJS, and Node.js: Complete Implementation Guide

Step-by-step tutorial for building scalable bulk SMS broadcast systems using Sinch SMS API, RedwoodJS full-stack framework, Prisma ORM, and GraphQL. Includes contact management, batch messaging with 1,000+ recipients, error handling, webhooks, and production deployment strategies for marketing campaigns.

Building Bulk SMS Broadcast Systems with Sinch, RedwoodJS, and Node.js

This comprehensive guide walks you through building a production-ready bulk SMS broadcasting system using the Sinch SMS API integrated with RedwoodJS. You'll implement contact management, batch messaging for thousands of recipients, GraphQL APIs, Prisma database modeling, error handling, and deployment strategies for scalable SMS marketing campaigns, alerts, and notifications.

The final application enables you to manage contact lists and send custom broadcast messages to thousands of recipients efficiently via Sinch's batch API. Use this for marketing campaigns, emergency alerts, appointment reminders, and transactional notifications.

Prerequisites: Node.js v18+, Yarn, a Sinch account with API credentials, and a provisioned Sinch virtual number. This tutorial requires familiarity with JavaScript and full-stack frameworks.

Core Technologies:

  • RedwoodJS: Full-stack JavaScript/TypeScript framework combining React, GraphQL, Prisma ORM, and modern development tooling in an opinionated structure optimized for startups.
  • Sinch SMS API: Enterprise messaging platform for sending and receiving SMS globally with batch processing, delivery reports, and webhook support.
  • Node.js: Runtime environment powering RedwoodJS's API side and Sinch SDK integration.
  • Prisma ORM: Type-safe database toolkit for PostgreSQL, MySQL, and SQLite with automatic migrations and client generation.
  • GraphQL: API query language providing flexible, efficient data fetching for React components.

System Architecture:

text
[ User (Browser) ] <--> [ Redwood Web (React UI) ]
       |
       | (GraphQL Request: Send Broadcast)
       V
[ Redwood API (GraphQL Server) ]
       |
       | (Calls Service Function)
       V
[ Redwood Service (broadcasts.ts) ]
       |        |
       |        | (Reads Contacts/Writes Status)
       |        V
       |   [ Prisma ORM ] <--> [ Database (Contacts, Broadcasts) ]
       |
       | (Calls Sinch SDK)
       V
[ Sinch Node.js SDK (@sinch/sdk-core) ]
       |
       | (Sends API Request)
       V
[ Sinch SMS API ] --> [ SMS Delivered to Recipients ]

By completing this guide, you'll build a RedwoodJS application with:

  1. Contact Management – Database-backed storage with E.164 phone number validation
  2. Broadcast Creation – GraphQL mutations for composing campaign messages
  3. Bulk SMS Sending – Integration with Sinch batch API for efficient mass messaging
  4. Status Tracking – Real-time broadcast status monitoring (PENDING, SENDING, SENT, FAILED)
  5. Recipient Tracking – Per-contact delivery status for detailed reporting

Note: This guide emphasizes backend API implementation with RedwoodJS Services and GraphQL. Section 11 provides a frontend structure overview.


1. RedwoodJS Project Setup and Sinch API Configuration

Initialize your RedwoodJS project and configure environment variables for Sinch.

1.1. Create RedwoodJS Project

Open your terminal and run:

bash
yarn create redwood-app ./redwood-sinch-broadcast
cd redwood-sinch-broadcast

Follow the prompts. Choose TypeScript if preferred (examples use JavaScript). Select PostgreSQL for production or SQLite for development.

1.2. Install Sinch SDK

Navigate to the API workspace and add the Sinch Node.js SDK:

bash
yarn workspace api add @sinch/sdk-core

1.3. Configure Environment Variables

Create or open the .env file in your project root and add your Sinch credentials:

plaintext
# .env

# Database URL (Redwood adds this automatically based on your choice)
# Example for PostgreSQL:
# DATABASE_URL="postgresql://postgres:password@localhost:5432/sinch_broadcast_dev?schema=public"
# Example for SQLite:
# DATABASE_URL="file:./dev.db"

# Sinch API Credentials
# Obtain these from your Sinch Dashboard under API Credentials or Access Keys
# Project ID: Found on your Sinch Dashboard homepage or project settings.
SINCH_PROJECT_ID="YOUR_SINCH_PROJECT_ID" # Replace with your actual Project ID
# Key ID (Access Key ID): Generated under Access Keys in your Sinch Dashboard.
SINCH_KEY_ID="YOUR_SINCH_KEY_ID" # Replace with your actual Key ID
# Key Secret (Access Key Secret): Only shown once upon generation. Store it securely.
SINCH_KEY_SECRET="YOUR_SINCH_KEY_SECRET" # Replace with your actual Key Secret
# Sinch Phone Number: The virtual number provisioned in your Sinch account for sending SMS.
SINCH_FROM_NUMBER="YOUR_SINCH_VIRTUAL_NUMBER" # Replace with your provisioned number (e.g., +12345678900)

How to obtain Sinch credentials:

  1. Log in to your Sinch Customer Dashboard
  2. Note your Project ID displayed on the homepage
  3. Navigate to Access Keys in the left menu
  4. Generate a new Access Key – you'll receive a Key ID and Key Secret (the Key Secret displays only once, so save it immediately)
  5. Navigate to NumbersYour Virtual Numbers to find your SMS-enabled Sinch Phone Number for SINCH_FROM_NUMBER

Security: Never commit your .env file to version control. Redwood adds .env to .gitignore by default. Use your deployment provider's secure environment variable management for production.

1.4. Initialize Database

Set up your database connection string in .env and run the initial migration command:

bash
yarn rw prisma migrate dev
# Follow prompts to name the initial migration (e.g., "initial setup")

This ensures your database is ready for the schemas we'll define next.


2. Database Schema Design with Prisma for SMS Broadcasting

Define your contacts and broadcasts models in the database.

2.1. Define Prisma Schema

Open api/db/schema.prisma and define the models:

prisma
// api/db/schema.prisma

datasource db {
  provider = "postgresql" // Or "sqlite"
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
  binaryTargets = "native"
}

// Contact model to store recipient information
model Contact {
  id          Int      @id @default(autoincrement())
  phoneNumber String   @unique // E.164 format recommended (e.g., +14155552671)
  name        String?  // Optional name
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  // Relation to BroadcastRecipient (optional, for detailed tracking)
  broadcastRecipients BroadcastRecipient[]
}

// Broadcast model to store message details and status
model Broadcast {
  id        Int      @id @default(autoincrement())
  message   String
  status    String   @default("PENDING") // PENDING, SENDING, SENT, FAILED
  sentAt    DateTime? // Timestamp when sending was initiated/completed
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relation to BroadcastRecipient (optional, for detailed tracking)
  recipients BroadcastRecipient[]
}

// Join table for detailed per-recipient status (Optional but recommended for large scale)
model BroadcastRecipient {
  id          Int       @id @default(autoincrement())
  broadcast   Broadcast @relation(fields: [broadcastId], references: [id])
  broadcastId Int
  contact     Contact   @relation(fields: [contactId], references: [id])
  contactId   Int
  status      String    @default("PENDING") // PENDING, SENT, FAILED
  sinchBatchId String?   // Store the Sinch Batch ID if available
  deliveredAt DateTime? // Timestamp when delivery confirmed (requires webhooks)
  failedReason String?   // Reason for failure

  @@unique([broadcastId, contactId]) // Ensure a contact is only linked once per broadcast
}

Why these models?

  • Contact: Stores essential recipient information. Use E.164 format for phoneNumber for international compatibility.
  • Broadcast: Tracks message content and overall broadcast status.
  • BroadcastRecipient (Optional but Recommended): Join table tracking status of each individual recipient within a broadcast. Essential for retries, reporting, and debugging failures with large lists.

2.2. Apply Database Migrations

Run the migration command again to apply these schema changes to your database:

bash
yarn rw prisma migrate dev
# Name the migration (e.g., "add contacts and broadcasts")

This updates your database schema and generates the corresponding Prisma Client types.


3. GraphQL API Layer: SDL Schema and Service Implementation

Define the GraphQL interface and implement the backend logic in Redwood Services.

3.1. Define GraphQL Schema (SDL)

Define GraphQL types, queries, and mutations for managing contacts and broadcasts.

api/src/graphql/contacts.sdl.ts:

typescript
// api/src/graphql/contacts.sdl.ts

export const schema = gql`
  type Contact {
    id: Int!
    phoneNumber: String!
    name: String
    createdAt: DateTime!
    updatedAt: DateTime!
  }

  type Query {
    contacts: [Contact!]! @requireAuth
    contact(id: Int!): Contact @requireAuth
  }

  input CreateContactInput {
    phoneNumber: String!
    name: String
  }

  input UpdateContactInput {
    phoneNumber: String
    name: String
  }

  type Mutation {
    createContact(input: CreateContactInput!): Contact! @requireAuth
    updateContact(id: Int!, input: UpdateContactInput!): Contact! @requireAuth
    deleteContact(id: Int!): Contact! @requireAuth
  }
`

api/src/graphql/broadcasts.sdl.ts:

typescript
// api/src/graphql/broadcasts.sdl.ts

export const schema = gql`
  type Broadcast {
    id: Int!
    message: String!
    status: String!
    sentAt: DateTime
    createdAt: DateTime!
    updatedAt: DateTime!
    recipientCount: Int # Add a field to easily get the count
  }

  type Query {
    broadcasts: [Broadcast!]! @requireAuth
    broadcast(id: Int!): Broadcast @requireAuth
  }

  input CreateBroadcastInput {
    message: String!
  }

  type Mutation {
    # Mutation to create a broadcast record (doesn't send yet)
    createBroadcast(input: CreateBroadcastInput!): Broadcast! @requireAuth

    # Mutation to trigger the sending of a specific broadcast
    sendBroadcast(id: Int!): Broadcast! @requireAuth
  }
`
  • @requireAuth: Ensures only authenticated users can access these operations. Remove this for internal tools without authentication, but securing API endpoints is recommended.
  • Separation of Concerns: createBroadcast saves the message content. sendBroadcast triggers the Sinch API interaction.

3.2. Implement Services

Generate the corresponding service files:

bash
yarn rw g service contact
yarn rw g service broadcast

Now, implement the logic within these services.

api/src/services/contacts/contacts.ts:

typescript
// api/src/services/contacts/contacts.ts

import type { QueryResolvers, MutationResolvers } from 'types/graphql'
import { validate } from '@redwoodjs/api'

import { db } from 'src/lib/db'

export const contacts: QueryResolvers['contacts'] = () => {
  return db.contact.findMany()
}

export const contact: QueryResolvers['contact'] = ({ id }) => {
  return db.contact.findUnique({
    where: { id },
  })
}

export const createContact: MutationResolvers['createContact'] = ({
  input,
}) => {
  // Basic validation example
  validate(input.phoneNumber, 'Phone Number', {
    presence: true,
    // TODO: Add robust E.164 format validation here (e.g., using a dedicated library)
    // Ensure the number starts with '+' and contains only digits after that.
  })
  return db.contact.create({
    data: input,
  })
}

export const updateContact: MutationResolvers['updateContact'] = ({
  id,
  input,
}) => {
  // Add validation if necessary (e.g., for phoneNumber format if changed)
  return db.contact.update({
    data: input,
    where: { id },
  })
}

export const deleteContact: MutationResolvers['deleteContact'] = ({ id }) => {
  // Consider implications: Should deleting a contact remove them from past broadcasts?
  // Add logic here if needed (e.g., anonymize, check dependencies).
  // For simplicity, we just delete the contact record.
   return db.contact.delete({
    where: { id },
  })
}

api/src/services/broadcasts/broadcasts.ts:

typescript
// api/src/services/broadcasts/broadcasts.ts

import type { QueryResolvers, MutationResolvers, BroadcastResolvers } from 'types/graphql'
import { SinchClient } from '@sinch/sdk-core'
import { logger } from 'src/lib/logger'

import { db } from 'src/lib/db'

// Initialize Sinch Client
// TODO: Best practice – Move SinchClient initialization to a dedicated lib file (e.g., api/src/lib/sinch.ts) for better organization, reusability, and testability.
// Ensure SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET are set in .env
const sinchClient = new SinchClient({
  projectId: process.env.SINCH_PROJECT_ID,
  keyId: process.env.SINCH_KEY_ID,
  keySecret: process.env.SINCH_KEY_SECRET,
})

// --- Queries ---

export const broadcasts: QueryResolvers['broadcasts'] = () => {
  return db.broadcast.findMany({ orderBy: { createdAt: 'desc' } })
}

export const broadcast: QueryResolvers['broadcast'] = ({ id }) => {
  return db.broadcast.findUnique({
    where: { id },
  })
}

// --- Mutations ---

export const createBroadcast: MutationResolvers['createBroadcast'] = ({
  input,
}) => {
   // Validate message length, content, etc.
   if (!input.message || input.message.trim().length === 0) {
     throw new Error('Broadcast message cannot be empty.')
   }
   // Add check for message length if needed (SMS limits)
  return db.broadcast.create({
    data: {
      message: input.message,
      status: 'PENDING', // Initial status
    },
  })
}

export const sendBroadcast: MutationResolvers['sendBroadcast'] = async ({
  id,
}) => {
  logger.info(`Attempting to send broadcast ID: ${id}`)

  const broadcastToSend = await db.broadcast.findUnique({ where: { id } })

  if (!broadcastToSend) {
    throw new Error(`Broadcast with ID ${id} not found.`)
  }

  if (broadcastToSend.status !== 'PENDING' && broadcastToSend.status !== 'FAILED') {
     logger.warn(`Broadcast ${id} is not in PENDING or FAILED state (current: ${broadcastToSend.status}). Skipping send.`)
    throw new Error(`Broadcast already processed or in progress (Status: ${broadcastToSend.status}).`)
  }

  // Fetch all contacts.
  // WARNING: This fetches all contacts at once and causes performance issues with large lists (>1,000).
  // Implement database-level batching (Prisma's skip/take) or move to background jobs (Section 7) for production scale.
  const contacts = await db.contact.findMany({
    select: { id: true, phoneNumber: true },
    // TODO: Add filtering for opt-outs if implemented (e.g., where: { subscribed: true })
  })

  if (contacts.length === 0) {
     logger.warn(`No contacts found to send broadcast ${id}.`)
     await db.broadcast.update({
       where: { id },
       data: { status: 'FAILED', sentAt: new Date(), updatedAt: new Date() }, // Mark as failed if no recipients
     })
    throw new Error('No contacts available to send the broadcast.')
  }

  const recipientPhoneNumbers = contacts.map((c) => c.phoneNumber)

  // Update broadcast status to SENDING
  await db.broadcast.update({
    where: { id },
    data: { status: 'SENDING', sentAt: new Date() },
  })

  // --- Sinch API Call ---
  try {
    logger.info(`Sending broadcast ${id} to ${recipientPhoneNumbers.length} recipients via Sinch.`)

    const sendRequest = {
      sendSMSRequestBody: {
        to: recipientPhoneNumbers,
        from: process.env.SINCH_FROM_NUMBER, // Ensure this is set in .env and is E.164
        body: broadcastToSend.message,
        // Optional parameters (e.g., delivery_report, expire_at) can be added here
        // delivery_report: 'summary', // Example: Request basic delivery report status via webhooks
      },
    }

    // Validate FROM number format (basic check)
    if (!process.env.SINCH_FROM_NUMBER || !/^\+\d+$/.test(process.env.SINCH_FROM_NUMBER)) {
        throw new Error('Invalid or missing SINCH_FROM_NUMBER in environment variables. Must be E.164 format (e.g., +1234567890).');
    }

    const response = await sinchClient.sms.batches.send(sendRequest)

    logger.info(`Sinch batch send initiated for broadcast ${id}. Batch ID: ${response.id}`)

    // Update broadcast status to SENT
    // IMPORTANT: 'SENT' status here means the batch was successfully accepted by the Sinch API.
    // It does not guarantee delivery to the recipient's handset.
    // Actual delivery status requires configuring Sinch Delivery Report Webhooks (See Section 9).
     const updatedBroadcast = await db.broadcast.update({
      where: { id },
      data: { status: 'SENT' },
    })

     // OPTIONAL: Create BroadcastRecipient records for detailed tracking
     const recipientData = contacts.map(contact => ({
       broadcastId: id,
       contactId: contact.id,
       status: 'SENT', // Initial status after sending to Sinch API
       sinchBatchId: response.id, // Store batch ID for potential future correlation
     }));

     await db.broadcastRecipient.createMany({
       data: recipientData,
       skipDuplicates: true, // In case of retries on the same broadcast ID
     });


    logger.info(`Broadcast ${id} marked as SENT.`)
    return updatedBroadcast; // Return the updated broadcast record

  } catch (error) {
    logger.error(`Error sending broadcast ${id} via Sinch: ${error.message}`)
    // Check for specific Sinch error details if available
    if (error.response?.data) {
      logger.error(`Sinch API Error Details: ${JSON.stringify(error.response.data)}`)
    } else {
       logger.error(error.stack) // Log full stack trace for non-API errors
    }

    // Update broadcast status to FAILED
     await db.broadcast.update({
      where: { id },
      data: { status: 'FAILED' },
    })

     // OPTIONAL: Update BroadcastRecipient records to FAILED
     // Note: This marks all recipients as failed if the batch request failed.
     // Individual failures after successful submission require webhooks.
     await db.broadcastRecipient.updateMany({
       where: { broadcastId: id },
       data: { status: 'FAILED', failedReason: `Batch send failed: ${error.message}` },
     });

    // Re-throw the error to be caught by GraphQL error handling
    throw new Error(`Failed to send broadcast via Sinch: ${error.message}`)
  }
}


// --- Field Resolver for recipientCount ---
// This calculates the count dynamically when querying a Broadcast
export const Broadcast: BroadcastResolvers = {
  recipientCount: (_obj, { root }) => {
    // Counts associated recipients using the join table
    return db.broadcastRecipient.count({ where: { broadcastId: root.id } })
  },
}
  • Sinch Client Initialization: The SinchClient is instantiated using credentials from environment variables. (Recommendation: Move to lib for larger projects).
  • Error Handling: Includes checks for existing broadcast status, fetching contacts, validating the SINCH_FROM_NUMBER, and a try…catch block around the sinchClient.sms.batches.send call. Failures update the broadcast status to FAILED and log detailed errors.
  • Logging: Uses Redwood's built-in logger for informative messages. Logs Sinch API errors if available.
  • Batch Sending: Leverages sinchClient.sms.batches.send, which is designed for sending the same message to multiple recipients efficiently.
  • Status Updates: Updates the Broadcast status (PENDINGSENDINGSENT/FAILED). The SENT status indicates acceptance by Sinch, not final delivery.
  • BroadcastRecipient Creation (Optional): Creates records linking contacts to the broadcast, storing the Sinch batch_id.
  • Field Resolver (recipientCount): Dynamically calculates the recipient count for a broadcast query using the join table.
  • Scalability Warning: Added a strong warning about findMany() without batching for large lists.

4. Error Handling, Logging, and Retry Mechanisms

Error Handling Strategy:

  • Service-level validation (e.g., checking broadcast status, message content, phone number format)
  • try...catch around the critical Sinch API call
  • Update database status (FAILED) on errors
  • Throw errors from services to let Redwood's GraphQL layer handle formatting the response to the client
  • Use specific error messages for clarity
  • Log detailed error info (including Sinch API responses if available)

Logging:

  • Use logger.info, logger.warn, logger.error within services (Redwood configures Pino logger by default)
  • Log key events: start of broadcast send, number of recipients, Sinch API call initiation, Sinch response (batch ID), success/failure status updates, and detailed error messages with stack traces or API error details
  • In production, configure log shipping to a centralized logging service (e.g., Datadog, Logtail, Papertrail) for easier analysis

Retry Mechanisms:

  • Simple Retry: The current implementation allows manually retrying a FAILED broadcast by calling the sendBroadcast mutation again.
  • Automated Retries (Advanced): For more robustness against transient network issues or brief Sinch API hiccups:
    1. Modify sendBroadcast mutation: Queue a background job instead of sending directly (see Section 7)
    2. Use a background job processor (like BullMQ with Redis, or Redwood's exec command with Faktory/Temporal) that supports automatic retries with backoff
    3. Configure the job queue to retry the Sinch API call a few times (e.g., 3 retries with delays of 10s, 60s, 300s) if it fails with specific error types (e.g., network errors, 5xx errors from Sinch, 429 rate limit errors)
    4. If all retries fail, mark the broadcast as FAILED

5. Security Best Practices for SMS Broadcasting

Authentication & Authorization:

  • Setup: Use Redwood's auth generators. dbAuth is a simple starting point:
    bash
    yarn rw setup auth dbAuth
    # Follow prompts (generate User model, etc.)
    yarn rw prisma migrate dev # Apply auth-related schema changes
  • Enforcement: The @requireAuth directive added to the SDLs in Section 3 now enforces that only logged-in users can perform contact/broadcast operations.
  • Role-Based Access (Optional): Implement roles (e.g., ADMIN, USER) using Redwood's RBAC features (@requireAuth(roles: 'ADMIN')) to restrict who can send broadcasts. See Redwood RBAC Docs.

Input Validation & Sanitization:

  • Services: Perform validation within service functions before database operations or API calls (as shown in createContact and createBroadcast).
  • Phone Numbers: Use a dedicated library for robust E.164 phone number format validation and parsing to ensure correctness before saving or sending (as noted in createContact TODO).
  • Message Content: Sanitize message content if it includes user-generated input. Limit message length based on SMS standards (typically 160 GSM-7 characters or 70 UCS-2 characters per segment). Add checks in createBroadcast.

Rate Limiting:

  • API Gateway: Implement rate limiting at the infrastructure level (e.g., Vercel, Netlify edge functions, AWS API Gateway).
  • Application Level (Advanced): For self-hosted or more granular control, use middleware with libraries like rate-limiter-flexible and Redis to limit requests per user or IP to your GraphQL endpoint, especially the sendBroadcast mutation.

Sinch Security: Rely on secure storage of Sinch API keys (environment variables). Do not expose keys on the client-side. Validate the SINCH_FROM_NUMBER format.


6. Handling SMS Special Cases and Compliance

Phone Number Formatting: Strictly enforce E.164 format (+ followed by country code and number, no spaces or dashes) for all phone numbers stored and sent to Sinch. Validate on input (see Section 5).

Character Encoding & Message Length: Be mindful of SMS character limits. Standard GSM-7 allows 160 chars per segment. Using non-GSM characters (like emojis, some accented letters) switches to UCS-2, reducing the limit to 70 chars per segment. Long messages are split into multiple segments by carriers, potentially increasing costs. Inform users or truncate messages if necessary (validate in createBroadcast). The Sinch API handles segmentation, but costs are per segment.

Opt-Outs/Consent Management: Regulations like TCPA (US) and GDPR (EU) require managing user consent and honoring opt-out requests.

  • Implementation Suggestion: Add a subscribed boolean field (defaulting to true) to the Contact model. Modify the findMany query in sendBroadcast to filter contacts (where: { subscribed: true }). Implement a mechanism (e.g., handling replies like "STOP" via Sinch Inbound SMS webhooks) to update the subscribed status to false.

Duplicate Phone Numbers: The Contact model has @unique on phoneNumber to prevent duplicates. Handle potential errors during createContact if a number already exists (Prisma will throw an error; catch it and provide a user-friendly message).

Internationalization: E.164 format handles country codes. Be aware of varying regulations and costs for sending SMS to different countries. Ensure your Sinch account is enabled for the destination countries.


7. Performance Optimization and Background Job Processing

Database Queries:

  • Use select in Prisma queries (findMany, findUnique) to fetch only the necessary fields (as done in sendBroadcast for contacts)
  • Add database indexes (@@index([field]) in schema.prisma) to frequently queried fields (e.g., status on Broadcast). The @unique on phoneNumber already creates an index

Batching: The current implementation uses Sinch's batch send (sms.batches.send), which is efficient for the API call itself. The bottleneck for large lists is often the database query (db.contact.findMany()).

Background Jobs (Crucial for Large Scale):

  • Problem: Sending to thousands of contacts within a single serverless function invocation (common in Vercel/Netlify) can exceed timeout limits (e.g., 10–60 seconds) due to database fetch time and processing overhead. The current sendBroadcast implementation fetching all contacts at once is not scalable.
  • Solution: Decouple the broadcast trigger from the actual sending process:
    1. Modify sendBroadcast mutation: Mark the broadcast as QUEUED or PENDING and enqueue a background job, passing the broadcastId
    2. Set up a background job processor:
      • Redwood exec: Use yarn rw exec <script-name> with a task queue like Faktory or Temporal
      • Standalone Queue: Use libraries like BullMQ (requires Redis) running in a separate Node.js process (e.g., on Render, Fly.io, or a small VM)
    3. Create a worker script/function that:
      • Receives the broadcastId
      • Fetches the broadcast details
      • Fetches contacts in batches from the database (e.g., using Prisma's skip and take)
      • For each batch of contacts, calls the Sinch API
      • Updates overall broadcast status (SENDING, SENT, FAILED) and potentially individual BroadcastRecipient statuses
      • Handles retries (as discussed in Section 4)

Caching: Not typically a major factor for this specific workflow unless contact lists are extremely static and queried very frequently on the frontend.


8. Monitoring, Observability, and Analytics for SMS Campaigns

Health Checks: Add a simple health check endpoint to your Redwood API (e.g., using a custom function or a simple GraphQL query) that verifies database connectivity. Monitor this endpoint using uptime monitoring services (e.g., UptimeRobot, Better Uptime).

Performance Metrics:

  • Logging: Log the duration of the sendBroadcast service function execution, especially the database query and the Sinch API call times
  • APM: Integrate an Application Performance Monitoring tool (e.g., Datadog APM, Sentry Performance, New Relic) to automatically track transaction times (GraphQL resolvers, database queries, external API calls like Sinch)

Error Tracking:

  • Use Sentry (Redwood Sentry Integration), Bugsnag, or similar services for automatic exception capture with stack traces, context, and alerting
  • Ensure errors logged via logger.error (including Sinch API error details) are captured or shipped to your logging platform

Sinch Dashboard & Analytics:

  • Monitor SMS delivery rates, costs, and potential errors directly within the Sinch Customer Dashboard. It provides analytics on sent messages, delivery status (if delivery reports are configured), and usage

Custom Dashboards (Optional): Use tools like Grafana (with Prometheus or Loki for logs/metrics) or your logging/APM provider's dashboarding features to visualize:

  • Number of broadcasts sent over time
  • Broadcast success/failure rates (SENT vs FAILED status in your DB)
  • Average broadcast processing time
  • Error counts and types
  • Number of contacts over time

9. Troubleshooting Common Sinch Integration Issues

Common Errors:

  • 401 Unauthorized from Sinch: Incorrect SINCH_PROJECT_ID, SINCH_KEY_ID, or SINCH_KEY_SECRET. Double-check values in .env or production environment variables. Ensure the key is active in the Sinch dashboard.
  • 403 Forbidden from Sinch: The API key might lack permissions for the SMS API, or the SINCH_FROM_NUMBER might not be provisioned correctly, enabled for the destination country, or properly formatted (needs E.164). Check Sinch dashboard settings (API keys, Number configuration, Allowed Countries).
  • 400 Bad Request from Sinch: Invalid phone number format in the to list (ensure all are E.164), message too long, missing required parameters (from, to, body), or invalid SINCH_FROM_NUMBER format. Check the error details logged from the Sinch SDK/API response.
  • Database Connection Errors: Verify DATABASE_URL is correct and the database server is running and accessible. Check firewall rules.
  • Timeout Errors (Function/Server): If sendBroadcast takes too long (usually due to fetching/processing large contact lists), implement background jobs (Section 7). This is the most common scaling issue.

Sinch Platform Limitations:

  • Rate Limits: Sinch applies rate limits (messages per second per number/account). Check your account limits. High-volume sending might require contacting Sinch support for increases. The SDK/API might return 429 Too Many Requests. Implement delays or use background job queues with rate limiting features if hitting limits.
  • Batch Size Limits: Per Sinch's official API documentation, each batch request supports a maximum of 1,000 recipients in the to array. For larger campaigns, split recipients into multiple batches.
  • Message Body Length: Maximum 2,000 characters per message body. Longer messages are automatically split into segments (GSM-7: 160 chars/segment; UCS-2: 70 chars/segment for Unicode).
  • Batch Retention: Batches are stored in Sinch's system for 14 days only. Query batch status or delivery reports within this window.
  • Expiration Default: Messages expire 3 days after send_at by default (configurable via expire_at parameter, max 3 days).

Development vs Production:

  • Environment Variables: Ensure Sinch credentials differ between development and production environments. Use your deployment platform's secure environment variable management (Vercel, Netlify, Render, Fly.io environment settings).
  • Database Migrations: Always run yarn rw prisma migrate deploy (not migrate dev) in production to apply migrations without prompts.
  • Function Timeouts: Serverless platforms (Vercel, Netlify) typically have 10–60 second timeout limits on free/pro tiers. Implement background jobs for large broadcasts to avoid timeouts.

Testing Considerations:

  • Test with small batches (2–3 recipients) first using test phone numbers
  • Verify E.164 formatting by attempting sends to numbers with/without proper formatting and checking error messages
  • Use Sinch's dry run endpoint (POST /xms/v1/{service_plan_id}/batches/dry_run) to validate batch requests without sending actual SMS messages. This returns the number of recipients, message parts, and encoding information.

10. Production Deployment Strategies for RedwoodJS

Deploy your RedwoodJS application to a hosting provider that supports both static frontend (Web side) and serverless/Node.js backend (API side).

Recommended Platforms:

  • Vercel: Native RedwoodJS support. Automatic deployment from Git. Configure environment variables in project settings.
    bash
    yarn rw setup deploy vercel
    # Follow prompts and deploy
  • Netlify: First-class RedwoodJS support. Simple Git-based deployment.
    bash
    yarn rw setup deploy netlify
    # Follow prompts and deploy
  • Render: Supports both web services and background workers. Ideal if implementing background job queues (Section 7).
  • Fly.io: Full control with containerized deployments. Suitable for custom requirements.

Deployment Checklist:

  1. Environment Variables: Set SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET, SINCH_FROM_NUMBER, and DATABASE_URL in your platform's environment settings
  2. Database Setup: Provision a production database (e.g., Railway, Supabase, Neon for PostgreSQL). Update DATABASE_URL
  3. Run Migrations: Apply schema to production database:
    bash
    yarn rw prisma migrate deploy
  4. Build and Deploy: Use platform-specific commands or connect your Git repository for automatic deployments
  5. Monitor Logs: Check platform logs immediately after deployment to verify Sinch API connectivity and catch configuration errors

11. Frontend Implementation Overview with React and GraphQL

While this guide focuses on backend implementation, here's a brief overview of the frontend structure:

Contact Management UI (web/src/pages/ContactsPage/ContactsPage.tsx):

  • Display contacts in a table using Redwood's Cell pattern (automatically handles loading/error states)
  • Implement GraphQL queries (contacts) and mutations (createContact, deleteContact)
  • Form for adding new contacts with E.164 phone number validation

Broadcast Management UI (web/src/pages/BroadcastsPage/BroadcastsPage.tsx):

  • List past broadcasts with status indicators (PENDING, SENDING, SENT, FAILED)
  • Form to create new broadcast (text area for message content)
  • "Send Now" button that triggers the sendBroadcast mutation for a selected broadcast

Example Cell Pattern for Broadcasts:

typescript
// web/src/components/BroadcastsCell/BroadcastsCell.tsx
export const QUERY = gql`
  query BroadcastsQuery {
    broadcasts {
      id
      message
      status
      recipientCount
      sentAt
      createdAt
    }
  }
`

export const Loading = () => <div>Loading broadcasts...</div>
export const Empty = () => <div>No broadcasts yet.</div>
export const Failure = ({ error }) => <div>Error: {error.message}</div>
export const Success = ({ broadcasts }) => {
  return (
    <ul>
      {broadcasts.map((broadcast) => (
        <li key={broadcast.id}>
          {broadcast.message} - Status: {broadcast.status}
          {broadcast.status === 'PENDING' && (
            <button onClick={() => triggerSend(broadcast.id)}>Send Now</button>
          )}
        </li>
      ))}
    </ul>
  )
}

For detailed frontend implementation, refer to the RedwoodJS Tutorial which covers Cells, Forms, and GraphQL integration comprehensively.


FAQ

How do I get Sinch API credentials for my RedwoodJS bulk SMS application?

Log in to your Sinch Customer Dashboard, locate your Project ID on the homepage, then navigate to Access Keys in the left menu. Generate a new access key to receive your Key ID and Key Secret (displayed only once – save immediately to your password manager). Find your SMS-enabled virtual number under NumbersYour Virtual Numbers. Add these credentials to your RedwoodJS .env file as SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET, and SINCH_FROM_NUMBER (in E.164 format like +14155552671).

What is the maximum batch size for Sinch SMS API in RedwoodJS?

Sinch's official /batches API endpoint supports a maximum of 1,000 recipients per batch request in the to array parameter. For campaigns exceeding 1,000 contacts, implement database pagination using Prisma's skip and take methods to fetch contacts in chunks of 1,000. Process each chunk as a separate Sinch batch request. For production scale (10,000+ contacts), use background job queues (BullMQ with Redis) to handle batching without serverless timeout issues.

How do I validate E.164 phone numbers in RedwoodJS services before sending SMS?

Install the libphonenumber-js validation library with yarn workspace api add libphonenumber-js. In your RedwoodJS service function, use parsePhoneNumber() to validate and format numbers:

typescript
import { parsePhoneNumber } from 'libphonenumber-js'

export const createContact = ({ input }) => {
  try {
    const phoneNumber = parsePhoneNumber(input.phoneNumber)
    if (!phoneNumber.isValid()) {
      throw new Error('Invalid phone number format. Use E.164 format like +14155552671')
    }
    return db.contact.create({
      data: { ...input, phoneNumber: phoneNumber.format('E.164') }
    })
  } catch (error) {
    throw new Error(`Phone validation failed: ${error.message}`)
  }
}

This ensures all phone numbers are stored in standardized E.164 format (+[country code][number]) for international SMS delivery compatibility.

What are SMS character encoding limits with Sinch batch messaging?

Sinch supports message bodies up to 2,000 characters total. However, SMS carriers automatically split messages into segments: GSM-7 encoding (standard Latin alphabet) allows 160 characters per segment, while UCS-2 encoding (Unicode characters, emojis, accented letters) reduces this to 70 characters per segment. A 320-character GSM-7 message sends as 3 segments and is billed as 3 SMS messages. Use Sinch's dry run endpoint (POST /xms/v1/{service_plan_id}/batches/dry_run) to preview message segmentation and character encoding before sending production campaigns.

How do I debug Sinch API authentication errors in RedwoodJS?

401 Unauthorized responses indicate credential mismatches. Verify your .env file contains exact values from Sinch Dashboard: SINCH_PROJECT_ID, SINCH_KEY_ID, and SINCH_KEY_SECRET with no extra whitespace or quotes. Check the access key status in your Sinch Dashboard under Access Keys – ensure it's active and has SMS API permissions enabled. The @sinch/sdk-core automatically handles Bearer token authentication, so verify your credentials grant access to the SMS service specifically (not just other Sinch products like Voice or Verification).

How can I prevent serverless function timeouts when sending bulk SMS in RedwoodJS?

Serverless platforms (Vercel, Netlify) enforce 10–60 second execution limits per function invocation. For broadcasts exceeding a few hundred contacts:

  1. Install BullMQ job queue: yarn workspace api add bullmq redis
  2. Modify sendBroadcast mutation to enqueue a background job instead of sending directly
  3. Create a separate worker process that fetches contacts in batches (1,000 at a time using Prisma skip/take)
  4. Deploy the worker to platforms supporting long-running processes (Render, Fly.io, Railway)

This architecture decouples broadcast triggering from actual SMS sending, preventing timeouts while processing large contact lists. The GraphQL mutation returns immediately with "QUEUED" status while the worker handles Sinch API calls asynchronously.

Why use the BroadcastRecipient join table in the Prisma schema?

The BroadcastRecipient model enables granular per-recipient tracking within each broadcast campaign. It stores individual delivery status (PENDING, SENT, FAILED), Sinch batch IDs for webhook correlation, delivery timestamps, and specific failure reasons. This is essential for:

  • Targeted Retries: Resend to only failed recipients instead of the entire broadcast
  • Detailed Reporting: Generate per-contact delivery analytics and success rates
  • Debugging: Identify problematic phone numbers or carrier-specific issues
  • Compliance: Maintain audit trails required for TCPA/GDPR regulations
  • Webhook Integration: Update individual recipient status from Sinch delivery reports

Without this join table, you only track overall broadcast status, losing visibility into which specific contacts succeeded or failed.

How do I configure Sinch delivery reports in RedwoodJS to track SMS delivery?

Set the delivery_report parameter when calling sinchClient.sms.batches.send in your service:

  • none: No delivery reports (default, fastest)
  • summary: Single webhook callback with aggregate statistics
  • full: Single webhook with array of per-recipient delivery statuses
  • per_recipient: Individual webhook per message status change (use cautiously for large batches – generates many callbacks)

Configure a webhook URL in your Sinch Dashboard (Service Plan settings) pointing to a RedwoodJS function endpoint like /api/sinch-delivery-webhook. Create the function to parse incoming DeliveryReport payloads and update BroadcastRecipient records with final delivery status. See Sinch Delivery Reports API Reference for webhook payload schemas.

How do I implement STOP keyword opt-out handling for SMS compliance in RedwoodJS?

Add a subscribed boolean field to your Prisma Contact model:

prisma
model Contact {
  id          Int      @id @default(autoincrement())
  phoneNumber String   @unique
  subscribed  Boolean  @default(true) // New field
  name        String?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

Run yarn rw prisma migrate dev to apply the schema change. Update your sendBroadcast service to filter: db.contact.findMany({ where: { subscribed: true } }). Implement a separate inbound SMS webhook handler (using Sinch Inbound SMS API) to detect "STOP", "UNSUBSCRIBE", or "QUIT" keywords and execute: db.contact.update({ where: { phoneNumber }, data: { subscribed: false } }). This ensures TCPA (US), GDPR (EU), and PDPA (Singapore) compliance by honoring opt-out requests automatically.

What are the best practices for testing Sinch SMS integration in RedwoodJS before production?

Follow this 6-step testing strategy:

  1. Test Numbers: Start with 2–3 team phone numbers in your development database
  2. Dry Run Validation: Call Sinch's POST /batches/dry_run endpoint with per_recipient: true to preview message segmentation without sending
  3. Mock SDK in Tests: Create Jest mocks for @sinch/sdk-core:
    typescript
    jest.mock('@sinch/sdk-core')
    const mockSend = jest.fn().mockResolvedValue({ id: 'test-batch-id' })
  4. Separate Environments: Use different SINCH_PROJECT_ID values for dev/staging to prevent accidental production sends
  5. Log Monitoring: Verify logger.info output shows correct API parameters before enabling real sends
  6. Production Smoke Test: Send first production broadcast to a single internal number, confirm delivery, then scale up gradually

This staged approach catches configuration errors, validates message formatting, and ensures proper error handling before exposing the system to customers.

Frequently Asked Questions

How to install the Sinch SDK for a RedwoodJS project?

Navigate to your RedwoodJS api directory using your terminal, then run 'yarn workspace api add @sinch/sdk-core'. This command adds the Sinch Node.js SDK to your project's API side, allowing you to interact with the Sinch SMS API.

How to send bulk SMS messages using RedwoodJS?

Integrate the Sinch SMS API into your RedwoodJS application. This involves setting up your project, configuring your database with Prisma, creating necessary services and GraphQL endpoints, and then leveraging the Sinch Node.js SDK to send messages via the API. This setup allows you to manage contacts and broadcast messages efficiently.

What is RedwoodJS used for in this tutorial?

RedwoodJS is the core framework for building the bulk SMS application. It provides structure for the backend API using GraphQL and services, connects to a database via Prisma, and offers a frontend framework with React. This allows for a streamlined development process.

Why use Sinch for SMS broadcasting?

Sinch provides a reliable and powerful SMS API along with an official Node.js SDK. The API allows for efficient bulk messaging by enabling sending to multiple recipients in a single API call, simplifying the integration and improving performance.

What is the role of Prisma in a RedwoodJS SMS app?

Prisma acts as the Object-Relational Mapper (ORM) connecting your RedwoodJS application to your chosen database (PostgreSQL or SQLite). It simplifies database interactions by defining models (like Contact and Broadcast) in a schema file, allowing you to query and manage data using JavaScript.

How to setup environment variables in RedwoodJS?

RedwoodJS uses a .env file in the project root. Create or open this file and add your Sinch credentials like Project ID, Key ID, Key Secret, and your Sinch virtual number. Make sure to add .env to your .gitignore file to protect your secrets.

How to handle Sinch API errors in my application?

Implement a try-catch block around the sinchClient.sms.batches.send API call. Inside the catch block, update the broadcast status to 'FAILED', log detailed error messages including the Sinch API response if available, and throw an error to notify the user.

How to handle large contact lists for SMS broadcasts?

Fetching all contacts at once using db.contact.findMany() is not scalable. Implement background jobs with Redwood's exec command or a task queue like BullMQ. The job should process contacts in batches, ensuring the application doesn't timeout when sending to thousands of recipients.

How to create contacts in RedwoodJS with Sinch SMS?

Create GraphQL SDL and corresponding service functions to handle contact creation. Implement input validation, specifically for phone numbers using E.164 formatting, and use Prisma to save the contact data to the database.

How to obtain Sinch API credentials?

Login to the Sinch Customer Dashboard. Note your Project ID, navigate to Access Keys, and generate a new key pair (Key ID and Key Secret). Save the Key Secret securely as it's only displayed once. Find your provisioned Sinch phone number under Numbers > Your Virtual Numbers.

When to use the BroadcastRecipient model?

Use the BroadcastRecipient model when you need detailed tracking of individual message statuses within a broadcast. This join table helps manage retries, provide detailed reporting, and understand specific delivery failures, especially for large-scale SMS campaigns.

What does the 'SENT' status mean for a broadcast?

In this implementation, 'SENT' signifies that the Sinch API has *accepted* the batch send request. It doesn't guarantee delivery to the recipient. To track actual delivery, you must configure Sinch Delivery Report Webhooks.

Can I retry failed broadcasts in this system?

Yes, the current design allows retrying a 'FAILED' broadcast by calling the sendBroadcast mutation again. For robust automated retries against transient issues, consider setting up a background job queue system with exponential backoff.

How can I improve performance for sending many SMS messages?

Use background jobs, database batching, and select specific data fields in queries. Instead of pulling all contacts at once, which can lead to timeouts, process them in smaller groups in the background and use Prisma's 'select' feature to only retrieve necessary data.

What security measures are included in the RedwoodJS and Sinch integration?

The guide emphasizes storing API keys securely as environment variables, validating phone number format using E.164, using Redwood's @requireAuth directive for access control, and handling errors robustly to prevent information leakage. It also recommends using additional measures like rate limiting for production deployments.