code examples

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

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.

Integrate Sinch Batch SMS API with RedwoodJS for Marketing Campaigns

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).

System Architecture:

text
+-------------------+      +-----------------------+      +-----------------------+      +---------------------+
| RedwoodJS Web UI  | ---> | RedwoodJS GraphQL API | ---> | Sinch API Client      | ---> | Sinch Batch SMS API |
| (React Components)|      | (api side)            |      | (api/src/lib/sinchClient) |      | (REST)              |
+-------------------+      +----------+------------+      +----------+------------+      +----------+----------+
       |                           ^    |                           ^                           |
       | User Interaction          |    | GraphQL Query/Mutation    | Sinch API Calls           | SMS Sending
       v                           |    v                           |                           v
+-------------------+      +----------+------------+      +----------+------------+
| RedwoodJS Router  |      | RedwoodJS Services    |      | Prisma ORM            |
| (Routes.tsx)      |      | (api/src/services)    |      | (schema.prisma)       |
+-------------------+      +----------+------------+      +----------+------------+
                                   |                           ^
                                   | Database Operations       |
                                   v                           |
                               +----------+------------+
                               | Database (e.g., PostgreSQL) |
                               +-----------------------+

What You'll Build:

  1. RedwoodJS application configured for Sinch Batch SMS API
  2. Database models for Campaign and Contact data
  3. RedwoodJS services managing campaigns and contacts
  4. Node.js module encapsulating Sinch API interactions
  5. Secure Sinch API credential handling
  6. Error handling and logging for the integration
  7. 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.

  1. Create RedwoodJS App: Open your terminal and create the project named redwood-sinch-campaigns:

    bash
    yarn create redwood-app redwood-sinch-campaigns --typescript

    The --typescript flag initializes the project with TypeScript for type safety.

  2. Navigate to Project Directory:

    bash
    cd redwood-sinch-campaigns
  3. 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:

    dotenv
    # .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
  4. 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
  5. Project Structure Overview:

    Backend (api/):

    • api/db/schema.prisma: Database schema definition
    • api/src/functions/: Serverless function handlers (GraphQL endpoint)
    • api/src/graphql/: GraphQL schema definitions (*.sdl.ts)
    • api/src/services/: Business logic implementations
    • api/src/lib/: Utility functions and third-party clients

    Frontend (web/):

    • web/src/pages/: Page components
    • web/src/components/: Reusable UI components (including Cells)
    • web/src/layouts/: Layout components
    • web/src/Routes.tsx: Frontend routing

    Configuration:

    • .env: Environment variables (Git-ignored)
    • redwood.toml: Project configuration

2. Database Schema and Service Implementation

Design Prisma database models for contacts and campaigns, then implement RedwoodJS services for business logic.

  1. 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
  2. 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.

  3. 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.

  4. Implement Service Logic (Basic CRUD): Open the generated service files and add basic CRUD operations.

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

    typescript
    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 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
    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 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.

  5. Data Layer Summary:

    ERD (Simplified):

    text
    +-------------+       +--------------+
    |   Contact   |       |   Campaign   |
    |-------------|       |--------------|
    | PK id       |       | PK id        |
    | phoneNumber |       | name         |
    | firstName   |       | messageBody  |
    | lastName    |       | status       |
    | createdAt   |       | sinchBatchId?|
    | updatedAt   |       | scheduledAt? |
    |             |       | sentAt?      |
    |             |       | createdAt    |
    |             |       | updatedAt    |
    +-------------+       +--------------+
          |                     |
          (Many-to-Many possible but not shown)
    • 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.
  6. Seed Sample Data (Optional): Use Prisma seeds to populate your development database.

    Create seed file:

    bash
    touch api/db/seed.ts

    Add seed logic:

    typescript
    // 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 prisma section of root package.json if not present:

    json
    // package.json (root)
     "prisma": {
       "seed": "yarn rw exec seed"
     }

    Run seeding:

    bash
    yarn rw prisma db seed

3. GraphQL API Layer Configuration

Define GraphQL schemas (SDL) to expose campaign and contact operations through RedwoodJS's type-safe API layer.

  1. Generate GraphQL SDL: Generate SDL files based on your Prisma schema models:

    bash
    yarn rw g sdl contact --crud
    yarn rw g sdl campaign --crud

    The --crud flag generates standard GraphQL types, queries, and mutations based on the model and corresponding service functions.

  2. Review and Customize SDL: Inspect the generated files (api/src/graphql/contacts.sdl.ts and api/src/graphql/campaigns.sdl.ts). Customize as needed:

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

    typescript
    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:

    typescript
    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 & sendCampaign Mutation: Custom mutation to trigger campaign sending logic in the service.

  3. 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.

  1. Create Sinch Client Library: Create a new file for Sinch API interaction logic:

    bash
    mkdir -p api/src/lib/sinchClient
    touch api/src/lib/sinchClient/index.ts
  2. 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.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:

    • 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.

  3. Obtaining Sinch Credentials:

    StepAction
    1Log in to your Sinch Customer Dashboard
    2Navigate to SMSAPIs section
    3Find your Service Plan ID
    4Under API Credentials, generate or copy an API Token
    5Note your registered Sender ID (under Numbers or Sender IDs) – must be approved for sending
    6Copy these values into your .env file
    7Confirm 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
  4. 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.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 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:

  1. Implement Complete sendCampaign Function: Add error handling, database transactions, contact fetching, and Sinch API integration
  2. Build Frontend UI: Create React components, pages, and cells for campaign management
  3. Add Testing: Write unit tests for services, integration tests for API endpoints, and end-to-end tests for critical workflows
  4. Configure Deployment: Set up environment variables for production, configure database migrations, and deploy to your hosting platform
  5. Implement Monitoring: Add logging, error tracking (e.g., Sentry), and performance monitoring
  6. Ensure Compliance: Implement GDPR-compliant opt-in/opt-out mechanisms, consent tracking, and data retention policies
  7. Add Analytics: Track campaign performance, delivery rates, and user engagement metrics

Frequently Asked Questions

How to send SMS marketing campaigns with RedwoodJS?

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.

What is the Sinch Batch SMS API used for in RedwoodJS?

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.

How to integrate Sinch API with RedwoodJS services?

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.

Why use Prisma in the RedwoodJS and Sinch integration?

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.

What RedwoodJS services are needed for SMS campaigns?

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.

How to set up Sinch API credentials in RedwoodJS?

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.

What is the role of GraphQL in RedwoodJS SMS campaigns?

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.

How to create a new RedwoodJS project for Sinch integration?

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>`.

When should I use node-fetch in a RedwoodJS project?

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`.

What database is used in this RedwoodJS Sinch example?

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"`.

How to handle Sinch API errors in RedwoodJS?

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.

Can I use a custom sender ID with the Sinch API?

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.

What is the purpose of the Sinch service plan ID?

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.