code examples

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

Sinch Two-Way SMS with RedwoodJS: Complete Webhook Integration Guide

Build inbound and outbound SMS messaging in RedwoodJS using Sinch Conversation API. Complete guide with webhook security, GraphQL mutations, and database integration.

Sinch Two-Way SMS with RedwoodJS: Complete Webhook Integration Guide

You'll integrate Sinch Short Message Service (SMS) capabilities into a RedwoodJS application in this step-by-step guide, enabling both outbound and inbound (two-way) messaging. You'll build a system where your application can send SMS messages via Sinch and receive incoming messages sent to your Sinch virtual number through webhooks.

Project Goals:

  • Send SMS messages programmatically from a RedwoodJS backend service.
  • Receive incoming SMS messages sent to a designated Sinch virtual number.
  • Securely store message history in a database.
  • Provide a basic GraphQL Application Programming Interface (API) for sending messages.
  • Establish a robust webhook handler for inbound messages.

Core Technologies:

  • RedwoodJS: A full-stack JavaScript/TypeScript framework for building modern web applications. It provides structure with its API (GraphQL, Node.js), Web (React), Prisma Object-Relational Mapping (ORM), and testing setup.
  • Sinch: A cloud communications platform offering APIs for SMS, Voice, Video, and more. You'll use their SMS and Conversation APIs.
  • Node.js: The runtime environment for RedwoodJS's API side.
  • Prisma: The default ORM in RedwoodJS, used for database interaction.
  • GraphQL: RedwoodJS's default API query language, used to expose messaging functionality.
  • ngrok (for development): A tool to expose local development servers to the internet, necessary for testing Sinch webhooks.

System Architecture:

mermaid
graph LR
    subgraph RedwoodJS Application
        subgraph Web Side (React)
            A[React Component] -- GraphQL Mutation --> B(GraphQL Server);
        end
        subgraph API Side (Node.js)
            B -- Calls --> C{Message Service};
            C -- Stores/Retrieves --> D[(Database)];
            E{Webhook Function} -- Receives POST --> F[Webhook Handler Logic];
            F -- Stores --> D;
        end
    end

    subgraph Sinch Cloud
        G[Sinch API]
        H[Sinch Virtual Number]
        I[Sinch Webhook Service]
    end

    C -- Sends SMS via API --> G;
    UserMobile -- Sends SMS --> H;
    H -- Triggers Webhook --> I;
    I -- POST Request --> E;
    G -- Sends SMS --> UserMobile;

    style D fill:#f9f,stroke:#333,stroke-width:2px

Prerequisites:

  • Node.js (v18 or later recommended) and Yarn installed.
  • A Sinch account with API credentials (Project ID, Key ID, Key Secret) and a provisioned virtual phone number capable of sending/receiving SMS.
  • ngrok installed globally (npm install -g ngrok or yarn global add ngrok) for local webhook testing.
  • Basic familiarity with RedwoodJS concepts (cells, services, functions, GraphQL).
  • Access to a PostgreSQL (or other Prisma-compatible) database.

Final Outcome:

By the end of this guide, you'll have a functional RedwoodJS application capable of:

  1. Sending SMS messages through a GraphQL mutation.
  2. Receiving incoming SMS messages via a webhook.
  3. Storing both outbound and inbound messages in your database.
  4. A foundation for building more complex messaging features.

How to Set Up Your RedwoodJS Project and Configure Sinch

Start by creating a new RedwoodJS project and setting up the necessary environment variables and dependencies.

1. Create RedwoodJS Project:

Open your terminal and run the following command:

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

Follow the prompts. Choose JavaScript or TypeScript based on your preference (this guide uses JavaScript examples, but the concepts apply equally to TypeScript). Select your preferred database (PostgreSQL recommended).

2. Install Sinch SDK:

Install the Sinch Node.js SDK to interact with their API.

bash
yarn workspace api add @sinch/sdk-core cross-fetch
  • @sinch/sdk-core: The official Sinch SDK for Node.js.
  • cross-fetch: Often required as a peer dependency or for environments where fetch isn't native.

3. Environment Variables:

RedwoodJS uses a .env file for environment variables. Create one in the project root (redwood-sinch-messaging/.env):

dotenv
# .env

# Database Connection (IMPORTANT: Replace with your actual DB connection string)
DATABASE_URL="postgresql://postgres:password@localhost:5432/redwood_sinch?schema=public"

# Sinch API Credentials (IMPORTANT: Replace with your values from the Sinch Dashboard)
# Go to Sinch Dashboard -> Access Keys -> Create API Key
SINCH_PROJECT_ID="YOUR_SINCH_PROJECT_ID"
SINCH_KEY_ID="YOUR_SINCH_KEY_ID"
SINCH_KEY_SECRET="YOUR_SINCH_KEY_SECRET"

# Sinch Virtual Number (IMPORTANT: Replace with your rented number in E.164 format, e.g., +12035551212)
# Go to Sinch Dashboard -> Numbers -> Active Numbers
SINCH_NUMBER="YOUR_SINCH_VIRTUAL_NUMBER"

# Webhook Security (IMPORTANT: Replace with a strong random string you generate)
# You can use `openssl rand -base64 32` or an online generator
SINCH_WEBHOOK_SECRET="YOUR_STRONG_RANDOM_WEBHOOK_SECRET"

# Sinch API Region (Optional, defaults typically work, but specify if needed: 'us' or 'eu')
# SINCH_REGION="us"
  • DATABASE_URL: Crucially, update this with the connection string for your chosen database (PostgreSQL, MySQL, SQLite, etc.). The example provided is for a local PostgreSQL setup.
  • SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET: Replace the YOUR_… placeholders with your actual credentials found in your Sinch Dashboard under Access Keys. You might need to create a new API key pair. The SINCH_KEY_SECRET is only shown once upon creation – save it securely.
  • SINCH_NUMBER: Replace YOUR_SINCH_VIRTUAL_NUMBER with the virtual phone number you acquired from Sinch, formatted in E.164 (e.g., +12223334444). Find it under Numbers > Your virtual numbers in the dashboard. Ensure this number is SMS-enabled and configured for the correct region/campaign if necessary (e.g., 10DLC in the US).
  • SINCH_WEBHOOK_SECRET: Replace YOUR_STRONG_RANDOM_WEBHOOK_SECRET with a unique, cryptographically strong random string that you generate. This secures your webhook endpoint.

4. Initialize Sinch Client (Utility):

Create a utility function to avoid repeating client initialization.

Create api/src/lib/sinch.js:

javascript
// api/src/lib/sinch.js
import { SinchClient } from '@sinch/sdk-core'
import { logger } from 'src/lib/logger'

let sinchClientInstance = null

export const getSinchClient = () => {
  if (sinchClientInstance) {
    return sinchClientInstance
  }

  const projectId = process.env.SINCH_PROJECT_ID
  const keyId = process.env.SINCH_KEY_ID
  const keySecret = process.env.SINCH_KEY_SECRET

  if (!projectId || !keyId || !keySecret || projectId === 'YOUR_SINCH_PROJECT_ID') {
    logger.error('Sinch API credentials missing or still using placeholder values in environment variables. Check your .env file.')
    // In a real app, you might throw an error or handle this more gracefully
    // depending on whether Sinch is critical at startup.
    return null // Prevent using invalid client
  }

  try {
    sinchClientInstance = new SinchClient({
      projectId,
      keyId,
      keySecret,
      // Optional: Specify region if needed, e.g., smsRegion: 'EU'
      // smsRegion: process.env.SINCH_REGION || 'US', // Default to US if not set
    })
    logger.info('Sinch client initialized successfully.')
    return sinchClientInstance
  } catch (error) {
    logger.error({ error }, 'Failed to initialize Sinch client')
    return null // Return null on initialization failure
  }
}

// Optional: Pre-initialize on server start to catch config errors early
// getSinchClient()

This utility safely initializes the Sinch client using environment variables and ensures only one instance is created (singleton pattern). It includes basic error handling for missing or placeholder credentials.


Design Your Database Schema for Message Storage

Define a Prisma schema to store messages.

1. Define Prisma Schema:

Open api/db/schema.prisma and add a Message model:

prisma
// api/db/schema.prisma

datasource db {
  provider = "postgresql" // Or your chosen DB provider
  url      = env("DATABASE_URL")
}

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

// Add this Message model
model Message {
  id           String    @id @default(cuid())
  externalId   String?   @unique // Sinch's message ID
  body         String
  fromNumber   String    // Sender's number (E.164)
  toNumber     String    // Recipient's number (E.164)
  direction    Direction // INBOUND or OUTBOUND
  status       String?   // Optional: Status from Sinch (e.g., 'DELIVERED', 'FAILED')
  sentAt       DateTime? // Timestamp when sent (for outbound)
  receivedAt   DateTime? // Timestamp when received (for inbound)
  createdAt    DateTime  @default(now())
  updatedAt    DateTime  @updatedAt
}

enum Direction {
  INBOUND
  OUTBOUND
}
  • externalId: Stores the unique ID assigned by Sinch to the message. Useful for correlating status updates later.
  • direction: Tracks whether the message was sent from your app (OUTBOUND) or received by your app (INBOUND).
  • status, sentAt, receivedAt: Timestamps and status information.

2. Create Database Migration:

Apply the schema changes to your database:

bash
yarn rw prisma migrate dev

Enter a name for the migration when prompted (e.g., add_message_model). This command generates SQL migration files and applies them to your database.


Implement Outbound SMS Sending with GraphQL

Build the service and GraphQL mutation to send SMS messages.

1. Create Redwood Service:

Generate a service to handle message logic:

bash
yarn rw g service messages

This creates api/src/services/messages/messages.js, messages.scdl.js, and test files.

2. Implement sendSms Service Function:

Edit api/src/services/messages/messages.js:

javascript
// api/src/services/messages/messages.js
import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'
import { getSinchClient } from 'src/lib/sinch'
import { requireAuth } from 'src/lib/auth' // Example auth import

export const messages = () => {
  // Example: Retrieve all messages (implement pagination later)
  // requireAuth() // Secure this endpoint if needed
  return db.message.findMany({ orderBy: { createdAt: 'desc' } })
}

export const message = ({ id }) => {
  // requireAuth() // Secure this endpoint if needed
  return db.message.findUnique({
    where: { id },
  })
}

// Function to send an SMS
export const sendSms = async ({ input }) => {
  // Ensure user is authenticated/authorized before sending
  // requireAuth({ roles: ['admin', 'sender'] }) // Example authorization

  const sinchClient = getSinchClient()
  if (!sinchClient) {
    throw new Error('Sinch client not available. Check configuration.')
  }

  const { to, body } = input
  const fromNumber = process.env.SINCH_NUMBER

  if (!to || !body) {
    throw new Error('Recipient number (to) and message body are required.')
  }
  if (!fromNumber || fromNumber === 'YOUR_SINCH_VIRTUAL_NUMBER') {
    throw new Error('Sinch sender number (SINCH_NUMBER) is not configured or is using placeholder. Check .env file.')
  }

  // Basic validation (improve as needed, e.g., check E.164 format)
  if (!/^\+\d+$/.test(to) || !/^\+\d+$/.test(fromNumber)) {
      logger.warn(`Invalid E.164 format detected. To: ${to}, From: ${fromNumber}`);
      // Depending on strictness, you might throw an error here
      // throw new Error('Invalid phone number format. Use E.164 (e.g., +12223334444).')
  }

  logger.info(`Attempting to send SMS from ${fromNumber} to ${to}`)

  let messageRecord // To store the DB record

  try {
    // 1. Record the attempt in the database BEFORE sending
    messageRecord = await db.message.create({
      data: {
        fromNumber: fromNumber,
        toNumber: to,
        body: body,
        direction: 'OUTBOUND',
        status: 'PENDING', // Initial status
      },
    })

    // 2. Send the message via Sinch SMS API
    // Note: The Conversation API can also send SMS, but the dedicated SMS API
    // might be simpler for just sending. Use the SMS API here via sdk-core.
    const response = await sinchClient.sms.batches.send({
      sendSMSRequestBody: {
        to: [to], // Expects an array of recipients
        from: fromNumber,
        body: body,
        // Optional parameters: delivery_report, client_reference, etc.
        // client_reference: messageRecord.id // Link Sinch message to DB record
      },
    })

    logger.info({ sinchResponse: response }, 'SMS sent successfully via Sinch')

    // 3. Update the database record with Sinch ID and potentially status
    // Note: The 'send' response confirms acceptance by Sinch, not final delivery.
    // Final status comes via webhooks (Delivery Reports – not covered in this basic guide).
    const updatedMessage = await db.message.update({
      where: { id: messageRecord.id },
      data: {
        externalId: response.id, // Store the Sinch batch ID
        status: 'SENT', // Or 'ACCEPTED' – indicates Sinch accepted it
        sentAt: response.created_at ? new Date(response.created_at) : new Date(), // Use Sinch timestamp if available
      },
    })

    return updatedMessage // Return the updated DB record

  } catch (error) {
    logger.error({ error, messageInput: input }, 'Failed to send SMS')

    // 4. Update DB record on failure
    if (messageRecord) {
      await db.message.update({
        where: { id: messageRecord.id },
        data: {
          status: 'FAILED',
          // Optionally store error details if your schema allows
        },
      }).catch(dbError => logger.error({ dbError }, 'Failed to update message status after send error'));
    }

    // Re-throw a user-friendly error or handle specific Sinch errors
    if (error.response?.data) {
      // Try to get specific Sinch error details
      throw new Error(`Sinch API Error: ${JSON.stringify(error.response.data)}`)
    }
    throw new Error(`Failed to send SMS: ${error.message}`)
  }
}
  • Authorization: Uses requireAuth (you'll need to set up Redwood Auth if you haven't). Customize roles as needed.
  • Input Validation: Basic checks for required fields and E.164 format. Added check for placeholder SINCH_NUMBER. Add more robust validation.
  • Error Handling: Uses try…catch to handle potential errors during DB operations or Sinch API calls. Logs errors using Redwood's logger.
  • Database Logging: Creates a Message record before sending and updates it with the Sinch ID (response.id from the batch send) and status upon success or failure. This ensures you have a record even if the Sinch call fails.
  • Sinch SDK Usage: Calls sinchClient.sms.batches.send to send the message.
  • Status: Sets initial status to PENDING, updates to SENT (or ACCEPTED) if Sinch accepts the request, and FAILED on error. Note that SENT here means "accepted by Sinch," not necessarily "delivered to handset." True delivery status requires setting up Delivery Report webhooks (an advanced topic).

3. Define GraphQL Schema:

Edit api/src/graphql/messages.sdl.js to define the types and mutation:

graphql
# api/src/graphql/messages.sdl.js
export const schema = gql`
  type Message {
    id: String!
    externalId: String
    body: String!
    fromNumber: String!
    toNumber: String!
    direction: Direction!
    status: String
    sentAt: DateTime
    receivedAt: DateTime
    createdAt: DateTime!
    updatedAt: DateTime!
  }

  enum Direction {
    INBOUND
    OUTBOUND
  }

  type Query {
    messages: [Message!]! @requireAuth
    message(id: String!): Message @requireAuth
  }

  input SendSmsInput {
    to: String!       # Recipient phone number in E.164 format
    body: String!     # Message content
  }

  type Mutation {
    sendSms(input: SendSmsInput!): Message! @requireAuth # Add appropriate role checks if needed
  }
`
  • Defines the Message type mirroring the Prisma model.
  • Defines the Direction enum.
  • Includes basic Query types (protected by @requireAuth).
  • Defines the SendSmsInput type for the mutation payload.
  • Defines the sendSms mutation, also protected by @requireAuth.

4. Test Sending SMS (GraphQL Playground):

  1. Start your development server: yarn rw dev

  2. Navigate to http://localhost:8911/graphql (or your configured GraphQL endpoint).

  3. Use the following mutation (replace the placeholder to number with a real E.164 formatted number you can check):

    graphql
    mutation SendTestSms {
      sendSms(input: {
        to: "+1RECIPIENTNUMBER",  # IMPORTANT: Replace with a valid E.164 number
        body: "Hello from RedwoodJS + Sinch!"
      }) {
        id
        externalId
        status
        body
        toNumber    # Corrected field name
        fromNumber  # Corrected field name
        sentAt
      }
    }
  4. Execute the mutation. Check your console logs (api side) for success or error messages. Verify the recipient phone received the SMS. Check your database to see the new Message record.


Build Secure Webhook Handler for Inbound SMS

Set up the webhook handler to receive incoming messages. Sinch uses its Conversation API webhooks for inbound messages across various channels, including SMS.

1. Create Redwood Function:

Redwood Functions are ideal for handling webhooks. Generate one:

bash
yarn rw g function inboundSms --typescript=false # or true if using TS

This creates api/src/functions/inboundSms.js.

2. Implement Webhook Handler Logic:

Edit api/src/functions/inboundSms.js:

javascript
// api/src/functions/inboundSms.js
import { logger } from 'src/lib/logger'
import { db } from 'src/lib/db'
import crypto from 'crypto' // Node.js crypto module for verification

// --- Webhook Security Verification ---
const verifySinchSignature = (req) => {
  const sinchSignature = req.headers['x-sinch-webhook-signature']
  const nonce = req.headers['x-sinch-webhook-signature-nonce']
  const timestamp = req.headers['x-sinch-webhook-signature-timestamp']
  const webhookSecret = process.env.SINCH_WEBHOOK_SECRET

  // Check for missing headers or secret (including placeholder value)
  if (!sinchSignature || !nonce || !timestamp || !webhookSecret || webhookSecret === 'YOUR_STRONG_RANDOM_WEBHOOK_SECRET') {
    logger.error('Missing Sinch signature headers, webhook secret, or secret is still placeholder.')
    return false
  }

  // Check if timestamp is recent (e.g., within 5 minutes) to prevent replay attacks
  const FIVE_MINUTES_IN_MS = 5 * 60 * 1000
  const requestTime = new Date(timestamp).getTime()
  const currentTime = new Date().getTime()

  if (isNaN(requestTime) || Math.abs(currentTime - requestTime) > FIVE_MINUTES_IN_MS) {
    logger.warn(`Sinch webhook timestamp is invalid or too old (${timestamp}). Possible replay attack or clock skew.`)
    return false
  }

  // **CRITICAL NOTE on Raw Body Access:**
  // Signature verification requires the *exact raw request body* as sent by Sinch.
  // According to Sinch documentation, the signature is computed as:
  // HMAC-SHA256(rawBody + '.' + nonce + '.' + timestamp, webhookSecret)
  // Standard serverless environments (like default Redwood deployments on Netlify/Vercel)
  // often parse JSON payloads *before* your function runs, making the raw body inaccessible.
  // The workaround below attempts to use the request body string if available.
  // **This workaround is NOT guaranteed to be reliable in production.**
  // For robust production verification, you **MUST** ensure access to the raw request body.
  // This typically requires:
  //   a) A custom server file setup in Redwood where you can use middleware before JSON parsing.
  //   b) Deployment platforms/configurations that explicitly provide the raw body.
  const requestBody = req.body // This is the potentially unreliable stringified body from handler
  if (typeof requestBody !== 'string') {
      logger.error('Webhook body for signature verification was not a string. Raw body likely unavailable.')
      return false;
  }

  // Sinch signature calculation: rawBody + '.' + nonce + '.' + timestamp
  const stringToSign = `${requestBody}.${nonce}.${timestamp}`

  try {
    const expectedSignature = crypto
      .createHmac('sha256', webhookSecret)
      .update(stringToSign)
      .digest('base64')

    // Use crypto.timingSafeEqual for security against timing attacks
    if (crypto.timingSafeEqual(Buffer.from(sinchSignature, 'base64'), Buffer.from(expectedSignature, 'base64'))) {
      logger.debug('Sinch webhook signature verified successfully.')
      return true
    } else {
      logger.error('Invalid Sinch webhook signature.')
      // Log details for debugging (avoid logging secret or full body in prod if sensitive)
      // logger.debug({ sinchSignature, nonce, timestamp, calculatedSignature: expectedSignature, stringToSign }, 'Signature mismatch details');
      return false
    }
  } catch (error) {
      logger.error({ error }, 'Error during signature comparison.');
      return false;
  }
}


export const handler = async (event, context) => {
  logger.info('inboundSms function invoked')

  // --- 1. Security First: Verify the webhook signature ---
  // Attempt verification using the potentially unreliable re-stringified body.
  // See critical note in verifySinchSignature function above.
  let pseudoRawBody
  try {
      // Redwood/Lambda often provides parsed JSON in event.body for application/json
      pseudoRawBody = JSON.stringify(event.body)
  } catch (stringifyError) {
      logger.error({ stringifyError }, 'Could not stringify event.body for signature check.');
       return {
          statusCode: 400, // Bad Request
          body: JSON.stringify({ error: 'Invalid request body format' }),
          headers: { 'Content-Type': 'application/json' },
       }
  }

  const pseudoReq = { headers: event.headers, body: pseudoRawBody }

  if (!verifySinchSignature(pseudoReq)) {
    logger.error('Webhook signature verification failed.')
    return {
      statusCode: 401, // Unauthorized
      body: JSON.stringify({ error: 'Invalid signature' }),
      headers: { 'Content-Type': 'application/json' },
    }
  }

  // --- 2. Process the Inbound Message Event ---
  const payload = event.body // Use the already parsed body

  // Check if it's an inbound message event from Sinch Conversation API
  // Structure might vary slightly; adapt based on actual Sinch payloads you receive.
  if (payload?.event === 'MESSAGE_INBOUND' && payload?.message?.direction === 'INCOMING') {
    const message = payload.message
    const contactMessage = message.contact_message

    // Ensure it's a text message (ignore MMS (Multimedia Messaging Service), etc. for this guide)
    if (contactMessage?.text_message) {
      const text = contactMessage.text_message.text
      const from = message.contact_id // Sender's ID (usually phone number for SMS)
      const to = message.channel_identity?.identity // Recipient ID (your Sinch number)
      const externalId = message.id // Sinch's ID for this message
      const receivedTimestamp = payload.accepted_time ? new Date(payload.accepted_time) : new Date()

      if (!from || !to || !externalId) {
          logger.error({ payload }, 'Received inbound message payload missing critical fields (contact_id, channel_identity.identity, message.id).')
          // Return 400 as the payload is malformed for our needs
          return { statusCode: 400, body: JSON.stringify({ error: 'Malformed payload' }) }
      }

      logger.info(`Received inbound SMS from ${from} to ${to}`)

      try {
        // Optional: Check if message already exists using externalId to ensure idempotency
        const existingMessage = await db.message.findUnique({ where: { externalId } });
        if (existingMessage) {
            logger.warn(`Received duplicate webhook for message ${externalId}. Skipping storage.`);
            return { statusCode: 200, body: JSON.stringify({ message: 'Duplicate webhook ignored' }) };
        }

        // Store the message
        await db.message.create({
          data: {
            externalId: externalId,
            body: text,
            fromNumber: from, // Assuming contact_id is E.164 number
            toNumber: to,     // Assuming channel_identity is E.164 number
            direction: 'INBOUND',
            status: 'RECEIVED',
            receivedAt: receivedTimestamp,
          },
        })

        logger.info(`Inbound message ${externalId} stored successfully.`)

        // Optional: Add logic here to *reply* to the message
        // e.g., call the sendSms service function you created earlier
        // Be careful not to create infinite loops!
        // Example reply:
        // if (text.toLowerCase().includes('help')) {
        //   await sendSms({ input: { to: from, body: 'Help is on the way!' }})
        // }

      } catch (error) {
        logger.error({ error, externalId, payload }, 'Failed to store inbound message')
        // Return 500 so Sinch might retry (configure retry policy in Sinch dashboard)
        return {
          statusCode: 500,
          body: JSON.stringify({ error: 'Failed to process message internally' }),
          headers: { 'Content-Type': 'application/json' },
        }
      }
    } else {
      logger.warn({ messageId: message.id }, 'Received non-text contact message type, skipping storage.')
    }
  } else {
    // Log other event types if needed for debugging, but don't treat as errors
    logger.info({ eventType: payload?.event, direction: payload?.message?.direction }, 'Received non-inbound message event type or unexpected structure, skipping.')
  }

  // --- 3. Acknowledge Receipt ---
  // Respond to Sinch quickly to acknowledge receipt of the webhook.
  return {
    statusCode: 200,
    body: JSON.stringify({ message: 'Webhook received and processed' }),
    headers: { 'Content-Type': 'application/json' },
  }
}
  • Security: Includes verifySinchSignature. This is critical. It uses the correct Sinch signature format: HMAC-SHA256(rawBody + '.' + nonce + '.' + timestamp) based on research. Strongly emphasized the unreliability of the JSON.stringify workaround for raw body access in serverless environments and recommended alternatives for production. Added checks for placeholder secret and invalid timestamps.
  • Event Parsing: Checks for MESSAGE_INBOUND, INCOMING direction, and presence of text_message. Extracts relevant fields. Added checks for missing critical fields.
  • Idempotency: Added an optional check using externalId to prevent storing duplicate messages if Sinch retries a webhook.
  • Database Storage: Creates a new Message record with direction: 'INBOUND'.
  • Error Handling: Logs errors if database storage fails and returns 500 status code, potentially prompting Sinch to retry. Returns 400 for malformed payloads.
  • Acknowledgement: Returns 200 OK quickly.

3. Expose Local Endpoint with ngrok:

Let Sinch send webhooks to your local development machine using ngrok.

  1. Make sure your Redwood dev server is running (yarn rw dev). The API server typically runs on port 8911.

  2. Open a new terminal window and run:

    bash
    ngrok http 8911
  3. ngrok will display forwarding URLs. Copy the https:// URL (e.g., https://<random-string>.ngrok-free.app).

4. Configure Sinch Webhook:

  1. Go to your Sinch Customer Dashboard.
  2. Navigate to Conversation APIApps.
  3. Select the App associated with your SMS number (or create one if needed and link the number).
  4. Find the Webhooks section (or Callbacks).
  5. Target URL: Paste the ngrok https:// URL, appending the path to your function: /.redwood/functions/inboundSms. The full URL will look like: https://<random-string>.ngrok-free.app/.redwood/functions/inboundSms
  6. Webhook Secret: Paste the same SINCH_WEBHOOK_SECRET you defined in your .env file (make sure it's not the placeholder!).
  7. Triggers: Select the events you want to receive. For basic inbound SMS, you must select:
    • MESSAGE_INBOUND (from the CONTACT section)
    • You might also want MESSAGE_DELIVERY for outbound status updates (advanced).
    • Other potentially useful ones: CONVERSATION_START, EVENT_INBOUND. Start with MESSAGE_INBOUND.
  8. Authentication: Select Signed (HMAC-SHA256).
  9. Save the webhook configuration.

5. Test Receiving SMS:

  1. Ensure yarn rw dev and ngrok http 8911 are running.
  2. Using a real phone, send an SMS message to your SINCH_NUMBER.
  3. Watch the console output of your Redwood API server (yarn rw dev). You should see logs from the inboundSms function. Look for "Sinch webhook signature verified successfully" and "Inbound message stored successfully."
  4. Check your database to confirm a new Message record with direction: 'INBOUND' has been created.
  5. If it fails, check the ngrok console (http://localhost:4040) for incoming requests and responses (especially the status code returned by your function). Check the Redwood API logs for detailed errors (pay close attention to signature verification failures or errors during database operations).

Error Handling, Logging, and Retry Mechanisms

  • Error Handling: The provided code includes basic try…catch blocks. For production, expand this:

    • Catch specific Sinch API errors (check error codes/formats from Sinch docs).
    • Implement specific error handling for database constraint violations (e.g., duplicate externalId).
    • Use custom error classes for better error categorization.
  • Logging: Redwood's built-in logger is used. Configure log levels (trace, debug, info, warn, error, fatal) appropriately for different environments in api/src/lib/logger.js. Ensure sensitive data (like full message bodies if required by privacy rules) is redacted or handled carefully in logs. Structure logs as JSON (JavaScript Object Notation) for easier parsing by log aggregation tools.

  • Retries (Outbound): For transient network errors when calling the Sinch API, implement a simple retry mechanism. Libraries like async-retry can help.

    javascript
    // Example using async-retry (install: yarn workspace api add async-retry)
    // Inside sendSms service function:
    import retry from 'async-retry';
    
    // ... inside try block, before calling Sinch...
    const response = await retry(async (bail, attemptNumber) => {
        // Note: The 'bail' function stops retries immediately if called.
        try {
            logger.debug(`Attempt ${attemptNumber} to send SMS via Sinch…`);
            const apiResponse = await sinchClient.sms.batches.send({
               sendSMSRequestBody: { /* ... */ }
            });
            return apiResponse; // Success! Return result.
        } catch (error) {
            // Don't retry on certain errors (e.g., 4xx client errors)
            if (error.response && error.response.status >= 400 && error.response.status < 500) {
                logger.error(`Sinch API client error (${error.response.status}), not retrying.`);
                bail(new Error(`Sinch API client error: ${error.message}`)); // Stop retrying
                return; // bail throws the error passed to it
            }
            // For other errors (network, 5xx), let retry handle it
            logger.warn({ error: error.message, attempt: attemptNumber }, 'Sinch API call failed, will retry…');
            throw error; // Throw error to trigger retry by the library
        }
    }, {
        retries: 3, // Number of retries
        factor: 2, // Exponential backoff factor
        minTimeout: 1000, // Initial delay 1s
        onRetry: (error, attempt) => {
          logger.warn(`Retrying Sinch call (Attempt ${attempt}) after error: ${error.message}`);
        }
    });
    // ... rest of the logic after successful send ...
  • Retries (Inbound): Sinch handles webhook retries based on the HTTP status code you return. Returning 5xx signals an error and prompts Sinch to retry (check Sinch docs for their exact retry policy). Returning 2xx confirms receipt. Returning 4xx (like 401 for bad signature or 400 for bad payload) usually tells Sinch not to retry. Ensure your handler is idempotent – processing the same webhook multiple times should not cause duplicate data or unintended side effects (the externalId check helps with this).


Security Features and Best Practices

  • Webhook Signature Verification: Implemented and paramount. Never process webhooks without verifying their signature using your shared secret. This prevents attackers from sending fake requests to your endpoint.

Frequently Asked Questions About Sinch Two-Way SMS with RedwoodJS

What's the difference between Sinch SMS API and Conversation API for webhooks?

The Sinch SMS API provides dedicated SMS webhooks for Mobile Originated (MO) messages and delivery reports, storing inbound messages for 14 days. The Conversation API offers unified webhooks across multiple channels (SMS, WhatsApp, Messenger) with the MESSAGE_INBOUND event. For SMS-only applications, the SMS API webhooks are simpler. For multi-channel messaging, use the Conversation API webhooks. This guide uses Conversation API webhooks for future extensibility.

How do I verify Sinch webhook signatures correctly in serverless environments?

Sinch webhooks use HMAC-SHA256 signature verification with the format: HMAC-SHA256(rawBody + '.' + nonce + '.' + timestamp, secret). The signature appears in the x-sinch-webhook-signature header, with nonce in x-sinch-webhook-signature-nonce and timestamp in x-sinch-webhook-signature-timestamp. Standard serverless platforms (Netlify, Vercel) parse JSON payloads before your function runs, making the raw body inaccessible. For production, configure your deployment to provide raw request bodies or use a custom server setup with middleware that preserves the raw body before JSON parsing.

What ngrok alternatives work for testing webhooks in 2025?

Popular ngrok alternatives for webhook testing include Localtunnel (simple, free, no signup required), Pinggy (QR codes, built-in request inspection), Cloudflare Tunnel (enterprise-grade security, no bandwidth limits on free tier), and localhost.run (client-less, instant SSH tunneling). Open-source options include frp (Fast Reverse Proxy) for flexibility, Expose (PHP/Laravel-focused), and Tunnelmole (self-hosted option). Choose based on your security requirements, protocol support needs, and whether you prefer managed services or self-hosted solutions.

How do I prevent duplicate message storage from webhook retries?

Implement idempotency checks using the externalId field (Sinch's unique message ID). Before storing an inbound message, query your database with db.message.findUnique({ where: { externalId } }). If a record exists, return HTTP 200 with a "duplicate ignored" message without creating a new database entry. This ensures Sinch's automatic retries (triggered by 5xx responses or timeouts) don't create duplicate records. The unique constraint on the externalId column provides database-level protection as a fallback.

Do I need authentication for GraphQL mutations in production?

Yes, always require authentication for production GraphQL mutations that send SMS. Use RedwoodJS's @requireAuth directive on your sendSms mutation and implement role-based access control with requireAuth({ roles: ['admin', 'sender'] }) in your service function. This prevents unauthorized users from sending SMS messages through your application, which could lead to unexpected costs or abuse. Configure RedwoodJS Auth with your preferred provider (Auth0, Netlify Identity, Supabase Auth, etc.) before deploying to production.


Summary: Production-Ready Two-Way SMS Integration

This guide provides a comprehensive walkthrough for integrating Sinch SMS capabilities into a RedwoodJS application, enabling both outbound and inbound (two-way) messaging. You've learned how to:

  • Set up a RedwoodJS project and configure Sinch API credentials.
  • Design a database schema for storing SMS messages.
  • Implement GraphQL mutations for sending SMS messages.
  • Build a secure webhook handler for receiving inbound messages.
  • Implement error handling, logging, and retry mechanisms.
  • Ensure webhook signature verification in production.

This foundation is ready for production use, with proper authentication, error handling, and webhook security in place. You can now expand this basic system with features like message templates, delivery reports, and enhanced security measures as needed for your application.

Frequently Asked Questions

How to send SMS messages with RedwoodJS and Sinch?

You can send SMS messages by creating a RedwoodJS service that uses the Sinch SMS API. This service will handle the logic for sending messages and interacting with your database to store message history. A GraphQL mutation will expose this functionality to your RedwoodJS frontend.

What is the role of ngrok in Sinch webhook development?

Ngrok creates a secure tunnel from a public URL to your local development environment. This is essential for testing webhooks locally because Sinch needs to deliver messages to a publicly accessible URL. Without ngrok, Sinch cannot reach your local server during development.

Why does RedwoodJS use a .env file for Sinch credentials?

The .env file stores sensitive configuration details such as API keys and database URLs, keeping them separate from your codebase. This approach enhances security and prevents accidental exposure of credentials in version control systems like Git. The file should *not* be committed to your repository.

When should I verify the Sinch webhook signature?

Verification is the first step after the inboundSms function is invoked. Always verify the webhook's signature to confirm its origin and integrity. This process prevents malicious actors from spoofing requests, protecting your data and application.

Can I store Sinch message history in my database?

Yes, this guide describes setting up a Prisma schema and migrations to store message data like body, sender/receiver numbers, timestamps, and Sinch's external ID for each message. This provides a robust record of all sent and received SMS activity for your application.

How to receive SMS messages in RedwoodJS with Sinch?

Set up a Redwood Function as a webhook handler. Configure the webhook in the Sinch dashboard and point its target URL to your Redwood function's publicly accessible endpoint (using ngrok during development). The webhook will then deliver incoming messages to your application as they arrive.

What is the purpose of a Redwood Function for inbound SMS?

Redwood Functions handle serverless logic, perfect for webhook endpoints. The `inboundSms` function receives data from Sinch when an SMS is sent to your Sinch virtual number. The function then stores the message information in the database and can initiate logic based on the incoming message (like automated replies).

Why does the Sinch integration use the Conversation API for inbound messages?

Sinch's Conversation API provides a unified approach for handling messages across multiple channels (SMS, chat, etc.). While the dedicated SMS API can handle outbound messages, the Conversation API's webhooks are the standard way to receive inbound SMS through Sinch.

When should I use Sinch's SMS API versus the Conversation API?

For simple outbound-only SMS, the SMS API (used via `@sinch/sdk-core` here) might be simpler. For inbound messages or scenarios requiring more advanced Conversation features (e.g., multiple channels), use the Conversation API's webhooks and associated functionalities.

Can I test Sinch SMS integration locally?

Yes, ngrok creates a tunnel to your local development environment, enabling Sinch to send webhook requests to your `inboundSms` function locally. Ensure both your Redwood server and ngrok are running during local tests.

How to configure webhook retries with Sinch?

Sinch automatically retries webhook deliveries based on the status code your function returns. Return 5xx status codes (e.g., 500 Internal Server Error) for errors you want Sinch to retry. Return a 2xx code (e.g., 200 OK) when successful.

How to handle Sinch SMS sending errors in RedwoodJS?

The provided `sendSms` service includes a `try...catch` block. Enhance it for production by implementing retries for transient errors, catching Sinch-specific errors, and logging error details appropriately.

What is the significance of the SINCH_WEBHOOK_SECRET?

It's crucial for securing your webhook. It's used to verify the signature of incoming requests, ensuring they originate from Sinch. This prevents unauthorized actors from sending fake webhook requests.

How to set up the Prisma schema for storing SMS messages?

Add a `Message` model to your `schema.prisma` file. This model should include fields like `body`, `fromNumber`, `toNumber`, `direction`, `status`, `externalId`, and timestamps. Run `yarn rw prisma migrate dev` to apply the changes to your database.