messaging channels

Sent logo
Sent TeamMar 8, 2026 / messaging channels / Sinch

Sinch SMS Delivery Status Callbacks in RedwoodJS: Complete Implementation Guide

Learn how to implement Sinch SMS delivery status callbacks in RedwoodJS. Step-by-step webhook setup, HMAC validation, Prisma database integration, and real-time SMS message tracking with TypeScript.

Integrate Sinch SMS delivery status callbacks into your RedwoodJS application to receive real-time updates on message delivery. Track message success, handle failures, and maintain accurate communication logs – transforming SMS from a "fire and forget" operation into a trackable, auditable communication channel.

Build a RedwoodJS API function that acts as a webhook endpoint to receive notifications from Sinch, then process and store these notifications in your database using Prisma.

What Are SMS Delivery Status Callbacks?

SMS delivery status callbacks (also called delivery receipts or delivery reports) are webhook notifications that SMS providers send to your application when message status changes. Unlike basic SMS sending where you only know the API accepted your request, delivery callbacks provide real-time updates throughout the message lifecycle:

  • Dispatched: Message sent from Sinch to carrier networks
  • Delivered: Message successfully received by recipient's device
  • Failed: Message delivery failed (invalid number, carrier rejection, etc.)
  • Expired: Message not delivered within timeout period

Project Overview and Goals

What You'll Build:

  • A secure RedwoodJS API endpoint (/api/sinchCallbacks) to receive POST requests from Sinch
  • Database models using Prisma to store messages and delivery status updates
  • A RedwoodJS service to validate incoming callback data and persist it to the database
  • HMAC signature validation to ensure callbacks genuinely originate from Sinch

Problem Solved:

Sending an SMS via an API is often a "fire and forget" operation. You know the request was accepted, but you don't know if the message reached the recipient's handset. Delivery status callbacks solve this by pushing status updates (like Dispatched, Delivered, Failed) back to your application, giving you complete visibility into the message lifecycle.

Technologies Used:

  • RedwoodJS: Full-stack JavaScript/TypeScript framework with API functions, services, and integrated Prisma ORM
  • Sinch SMS API: Sends SMS messages and delivery status callbacks
  • Prisma: Next-generation ORM for Node.js and TypeScript, handling database modeling and access
  • Node.js: Runtime environment
  • ngrok (optional): For testing webhook callbacks locally during development

System Architecture:

  1. Your RedwoodJS application sends an SMS message request (including a request for delivery reports) to the Sinch SMS API.
  2. Sinch accepts the request and attempts to deliver the SMS via mobile networks to the recipient's phone.
  3. As the delivery status changes (e.g., dispatched, delivered, failed), the Sinch callback service receives this information.
  4. Sinch sends a POST request containing the status update to your configured RedwoodJS API function endpoint (/api/sinchCallbacks).
  5. Your RedwoodJS API function validates the incoming request (HMAC signature), parses the data, and passes it to a RedwoodJS service.
  6. The RedwoodJS service processes the status update and persists it to your database, linking it to the original message.

Prerequisites:

  • Node.js 20.x or later (LTS version recommended)
  • Yarn 1.22.21 or higher (managed via Corepack for Node.js 18+)
  • RedwoodJS project (latest version: 8.8.1 as of January 2025)
  • Sinch account with an active SMS Service Plan ID and API Token
  • Basic familiarity with RedwoodJS concepts (API functions, services, Prisma)
  • Database supported by Prisma (PostgreSQL, MySQL, or SQLite)

Expected Outcome:

Your RedwoodJS application will:

  1. Receive delivery status updates for individual SMS recipients from Sinch
  2. Securely validate incoming callbacks
  3. Store status updates in your database, linked to the original message

1. Set Up the RedwoodJS Project

Create a new RedwoodJS project and configure the basic structure and environment.

1.1. Create RedwoodJS Project:

bash
# Using Yarn
yarn create redwood-app ./sinch-redwood-callbacks

# Or using npm
npx create-redwood-app ./sinch-redwood-callbacks

This scaffolds a new RedwoodJS project in the sinch-redwood-callbacks directory.

1.2. Navigate to the Project Directory:

bash
cd sinch-redwood-callbacks

1.3. Configure Environment Variables:

Create a .env file in the project root:

bash
touch .env

Add the following variables:

dotenv
# .env

# --- Database ---
# Replace with your actual database connection string
# Example for PostgreSQL: DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
# Example for SQLite (default): DATABASE_URL="file:./dev.db"
DATABASE_URL="file:./dev.db"

# --- Sinch API Credentials ---
# Found on your Sinch Customer Dashboard under your API/Service Plan
SINCH_SERVICE_PLAN_ID="YOUR_SERVICE_PLAN_ID"
SINCH_API_TOKEN="YOUR_API_TOKEN"

# --- Sinch Webhook Security ---
# A secret string you create. Must match the secret configured in the Sinch Dashboard for the callback URL.
# Generate a strong random string (e.g., using `openssl rand -hex 32`, a password manager, or other secure generator)
SINCH_WEBHOOK_SECRET="YOUR_STRONG_RANDOM_SECRET_STRING"

# --- RedwoodJS Logger ---
# Optional: Set log level (trace, debug, info, warn, error, fatal)
LOG_LEVEL="info"
  • DATABASE_URL: Configure this to point to your database. For local development, use SQLite (file:./dev.db).
  • SINCH_SERVICE_PLAN_ID / SINCH_API_TOKEN: Find these in your Sinch Customer Dashboard under APIs > SMS APIs > [Your Service Plan Name].
  • SINCH_WEBHOOK_SECRET: Generate a strong, unique secret (at least 32 characters). Configure this same secret in the Sinch Dashboard later. Use openssl rand -hex 32 or any method that produces a cryptographically strong random string.

1.4. Initialize the Database:

Apply the initial Prisma schema and create the database file (if using SQLite):

bash
yarn rw prisma migrate dev --name initial_setup

This initializes the database, creates the migration history table, and verifies your DATABASE_URL is configured correctly.


2. Implement Core Functionality (Callback Handler)

Create an API endpoint (a RedwoodJS function) to receive callbacks from Sinch.

2.1. Generate the API Function:

Generate a new TypeScript function for type safety:

bash
yarn rw g function sinchCallbacks --ts

This creates api/src/functions/sinchCallbacks.ts.

2.2. Basic Handler Structure:

Open api/src/functions/sinchCallbacks.ts:

typescript
// api/src/functions/sinchCallbacks.ts
import type { APIGatewayEvent, Context } from 'aws-lambda'
import { logger } from 'src/lib/logger'

/**
 * The handler function is your code that processes http request events.
 * You already have access to the logger and use it to log any errors.
 *
 * @typedef { import('aws-lambda').APIGatewayEvent } APIGatewayEvent
 * @typedef { import('aws-lambda').Context } Context
 * @param { APIGatewayEvent } event - an object which contains information from the invoker.
 * @param { Context } context - contains information about the invocation,
 * function, and execution environment.
 */
export const handler = async (event: APIGatewayEvent, context: Context) => {
  logger.info('Incoming request to sinchCallbacks function')

  // Basic validation: Ensure it's a POST request
  if (event.httpMethod !== 'POST') {
    logger.warn('Received non-POST request')
    return {
      statusCode: 405, // Method Not Allowed
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ error: 'Method Not Allowed' }),
    }
  }

  // TODO: Add HMAC Signature Validation
  // TODO: Parse Request Body
  // TODO: Process Callback Data (using a Service)
  // TODO: Return appropriate response to Sinch

  return {
    statusCode: 200, // Placeholder: Acknowledge receipt
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message: 'Callback received' }),
  }
}

This basic structure checks if the request method is POST. You'll add more logic in subsequent steps.


3. Build the API Layer

API functions automatically map to endpoints. The api/src/functions/sinchCallbacks.ts function is accessible at /api/sinchCallbacks.

3.1. Endpoint Definition:

  • URL: /api/sinchCallbacks (relative to your application's base URL)
  • Method: POST
  • Request Body: JSON payload sent by Sinch (structure detailed in Sinch documentation and below)
  • Authentication: HMAC Signature (covered in Security section)
  • Responses:
    • 200 OK: Callback received and successfully processed (or acknowledged even if internal processing fails, to prevent Sinch retries)
    • 400 Bad Request: Invalid request body (e.g., malformed JSON)
    • 401 Unauthorized: Invalid or missing HMAC signature
    • 405 Method Not Allowed: Request method was not POST
    • 500 Internal Server Error: Unexpected server-side error during processing

3.2. Example Sinch Callback Payload:

Sinch sends a POST request with a JSON body like this when you configure delivery_report as per_recipient:

json
{
  "type": "recipient_delivery_report_sms",
  "batch_id": "01FC66621XXXXX119Z8PMV1QPQ",
  "recipient": "+15551231234",
  "code": 401,
  "status": "Dispatched",
  "at": "2022-08-30T08:16:08.930Z",
  "operator_status_at": "2022-08-30T08:16:08.900Z",
  "client_reference": "your_optional_reference_123"
}

3.3. Test the Endpoint Locally:

Test the endpoint using curl once deployed or running locally with ngrok:

bash
# NOTE: Replace YOUR_ENDPOINT_URL with your actual ngrok or deployed URL
# NOTE: This example does *not* include the HMAC signature yet.
curl -X POST \
  YOUR_ENDPOINT_URL/api/sinchCallbacks \
  -H 'Content-Type: application/json' \
  -d '{
    "type": "recipient_delivery_report_sms",
    "batch_id": "TEST_BATCH_ID_123",
    "recipient": "+15559998888",
    "code": 0,
    "status": "Delivered",
    "at": "2025-04-20T10:00:00.000Z"
  }'

You should see logs in your Redwood API server console (yarn rw dev api) and receive a 200 OK response.


4. Integrate with Sinch (Configuration)

Configure Sinch to send delivery reports to your RedwoodJS endpoint.

4.1. Obtain Your Webhook URL:

  • Local Development: Use ngrok to expose your local development server.
    1. Install ngrok from https://ngrok.com/download
    2. Start your Redwood dev server: yarn rw dev (API runs on port 8911)
    3. Run ngrok: ngrok http 8911
    4. Copy the https:// forwarding URL (e.g., https://<random-chars>.ngrok.io). Your callback URL: https://<random-chars>.ngrok.io/api/sinchCallbacks
  • Production/Staging: Use your deployed application URL (e.g., https://yourapp.yourdomain.com/api/sinchCallbacks)

4.2. Configure Callback URL in the Sinch Dashboard:

  1. Log in to your Sinch Customer Dashboard
  2. Navigate to APIsSMS APIs
  3. Select your Service Plan ID (matching SINCH_SERVICE_PLAN_ID in .env)
  4. Find the Callback URLs section (typically under "Settings")
  5. Click Add Callback URL or Edit
  6. Enter your Callback URL from step 4.1
  7. IMPORTANT: Find the Webhook Secret or HMAC Secret field and paste the exact same secret from your .env file (SINCH_WEBHOOK_SECRET)
  8. Save the configuration

Note: The exact UI and field names may vary. Refer to the Sinch documentation for current instructions.

4.3. Request Delivery Reports When Sending SMS:

Sinch only sends callbacks if you request them when sending the message. Include the delivery_report parameter set to "per_recipient" in your request body.

Example: Sending SMS via Sinch REST API (Conceptual curl)

bash
# Replace placeholders with your actual values
SINCH_SERVICE_PLAN_ID="YOUR_SERVICE_PLAN_ID"
SINCH_API_TOKEN="YOUR_API_TOKEN"
YOUR_SINCH_NUMBER_OR_ALPHANUMERIC="YOUR_SENDER_ID"

curl -X POST \
  "https://us.sms.api.sinch.com/xms/v1/${SINCH_SERVICE_PLAN_ID}/batches" \
  -H "Authorization: Bearer ${SINCH_API_TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{
    "from": "'${YOUR_SINCH_NUMBER_OR_ALPHANUMERIC}'",
    "to": [ "+15551234567" ],
    "body": "Hello from RedwoodJS!",
    "delivery_report": "per_recipient",
    "client_reference": "optional_your_ref_123"
  }'

Setting delivery_report to "per_recipient" tells Sinch to send a separate callback to your configured URL for status updates concerning each recipient in the batch. You can also use "per_recipient_final" to only get the final status (e.g., Delivered, Failed). "per_recipient" provides intermediate statuses too (like Dispatched).

Note: The logic for sending SMS messages like the example above typically resides within a RedwoodJS service function, possibly triggered by a mutation, background job, or another application event.

Environment Variable Summary:

  • SINCH_SERVICE_PLAN_ID: Identifies your Sinch service plan (obtained from Sinch Dashboard)
  • SINCH_API_TOKEN: Authenticates your API requests to Sinch for sending SMS (obtained from Sinch Dashboard)
  • SINCH_WEBHOOK_SECRET: Authenticates requests from Sinch to your webhook (you create this and configure it in both .env and Sinch Dashboard)

5. Implement Error Handling and Logging

Robust error handling and logging are essential for a reliable webhook.

5.1. Update the Handler Function:

Modify api/src/functions/sinchCallbacks.ts to include try...catch blocks and detailed logging:

typescript
// api/src/functions/sinchCallbacks.ts
import type { APIGatewayEvent, Context } from 'aws-lambda'
import { logger } from 'src/lib/logger'
// Import validation function (we'll create this in the Security section)
import { validateSinchSignature } from 'src/lib/sinchSecurity'
// Import the service function (we'll create this in the Data Layer section)
import { recordSmsStatusUpdate } from 'src/services/smsStatus/smsStatus'

// Define an interface for the expected callback payload
interface SinchRecipientDeliveryReport {
  type: 'recipient_delivery_report_sms' | 'recipient_delivery_report_mms' // Focus on SMS
  batch_id: string
  recipient: string
  code: number
  status: string
  at: string // ISO 8601 timestamp string
  operator_status_at?: string
  client_reference?: string
}

export const handler = async (event: APIGatewayEvent, context: Context) => {
  // Use a unique request ID for logging correlation
  const requestId = context.awsRequestId || `local-${Date.now()}`
  const handlerLogger = logger.child({ requestId })
  handlerLogger.info('Incoming request to sinchCallbacks function')

  // 1. Basic Method Validation
  if (event.httpMethod !== 'POST') {
    handlerLogger.warn('Received non-POST request')
    return { statusCode: 405, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'Method Not Allowed' }) }
  }

  // 2. HMAC Signature Validation
  try {
    const rawBody = event.body
    if (rawBody === null || rawBody === undefined) {
        handlerLogger.error('Raw request body is missing or null')
        return { statusCode: 400, body: JSON.stringify({ error: 'Bad Request: Missing body' }) }
    }

    const webhookSecret = process.env.SINCH_WEBHOOK_SECRET
    if (!webhookSecret) {
      handlerLogger.error('SINCH_WEBHOOK_SECRET environment variable is not set')
      return { statusCode: 500, body: JSON.stringify({ error: 'Internal Server Error: Webhook secret not configured' }) }
    }

    const isValid = validateSinchSignature(
      event.headers,
      rawBody,
      webhookSecret
    )
    if (!isValid) {
      handlerLogger.error('Invalid Sinch signature')
      return { statusCode: 401, body: JSON.stringify({ error: 'Unauthorized' }) }
    }
    handlerLogger.info('Sinch signature validated successfully')
  } catch (error) {
    handlerLogger.error({ error }, 'Error during signature validation')
    return { statusCode: 400, body: JSON.stringify({ error: 'Signature validation failed' }) }
  }

  // 3. Parse Request Body
  let payload: SinchRecipientDeliveryReport
  try {
    payload = JSON.parse(event.body!)
    handlerLogger.info({ payloadType: payload.type }, 'Parsed request body')

    // Basic payload type check
    if (payload.type !== 'recipient_delivery_report_sms') {
       handlerLogger.warn({ type: payload.type }, 'Received non-SMS recipient report type, ignoring')
       return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'Callback type ignored' }) }
    }

  } catch (error) {
    handlerLogger.error({ error, bodySnippet: event.body?.substring(0, 100) }, 'Failed to parse request body')
    return { statusCode: 400, body: JSON.stringify({ error: 'Bad Request: Malformed JSON' }) }
  }

  // 4. Process Callback Data (Using Service)
  try {
    await recordSmsStatusUpdate({
      batchId: payload.batch_id,
      recipient: payload.recipient,
      statusCode: payload.code,
      status: payload.status,
      statusTimestamp: new Date(payload.at),
      clientReference: payload.client_reference,
      rawPayload: event.body!,
    })

    handlerLogger.info({ batchId: payload.batch_id, recipient: payload.recipient, status: payload.status }, 'Successfully processed Sinch callback')

    // 5. Return Success Response to Sinch
    // IMPORTANT: Always return 2xx to Sinch upon successful receipt,
    // even if your internal processing encounters a recoverable issue.
    // This prevents Sinch from retrying unnecessarily.
    return {
      statusCode: 200,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: 'Callback processed successfully' }),
    }

  } catch (error) {
    handlerLogger.error({ error, batchId: payload.batch_id, recipient: payload.recipient }, 'Error processing Sinch callback in service')
    // Even on internal error, acknowledge receipt to Sinch
    return {
      statusCode: 200, // Acknowledge receipt to prevent Sinch retries
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: 'Callback received but internal processing failed' }),
    }
  }
}

Key Improvements:

  • Type Safety: Defined SinchRecipientDeliveryReport interface
  • Logging: Using Redwood's logger with context (requestId), logging key events and errors
  • Structured Error Handling: try...catch blocks for signature validation, parsing, and service calls
  • Clear Return Codes: Returning appropriate HTTP status codes (405, 401, 400, 500, 200)
  • Sinch Acknowledgement: Consistently returning 200 OK once validated and parsed, even if downstream processing fails (prevents Sinch retries)

6. Create the Database Schema and Data Layer

Create database tables to store messages and status updates.

6.1. Define Prisma Schema:

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

prisma
// api/db/schema.prisma

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

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

// Model representing an SMS message sent via Sinch
model Message {
  id            String   @id @default(cuid())
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  to            String   // Recipient phone number. Store in E.164 format to match Sinch callbacks.
  body          String?
  sentAt        DateTime @default(now())
  sinchBatchId  String   // The Batch ID returned by Sinch
  clientReference String?

  // Track the latest known status
  latestStatus        String?
  latestStatusCode    Int?
  latestStatusTimestamp DateTime?

  // Relation to status updates
  statusUpdates SmsStatusUpdate[]

  @@index([sinchBatchId])
  @@index([clientReference])
  @@index([to])
}

// Model representing a delivery status update received from Sinch
model SmsStatusUpdate {
  id            String   @id @default(cuid())
  createdAt     DateTime @default(now())

  messageId     String
  message       Message  @relation(fields: [messageId], references: [id], onDelete: Cascade)

  sinchBatchId  String
  recipient     String
  status        String   // e.g., "Dispatched", "Delivered", "Failed"
  statusCode    Int      // e.g., 401, 0, 402 (Sinch status codes)
  statusTimestamp DateTime

  clientReference String?
  rawPayload    String   // Store the full JSON payload for auditing

  @@index([messageId])
  @@index([sinchBatchId, recipient])
  @@index([statusTimestamp])
}

Key Decisions:

  • Message Model: Represents messages your application sent, includes sinchBatchId and clientReference to link back to Sinch, and tracks latest status
  • SmsStatusUpdate Model: Represents callbacks from Sinch, linked to Message via messageId, stores key fields and rawPayload
  • Indexes: Optimize querying by sinchBatchId, recipient, and messageId
  • onDelete: Cascade: Deletes related status updates when a message is deleted

6.2. Apply Database Migrations:

Apply schema changes to your database:

bash
yarn rw prisma migrate dev --name add_message_and_status_tables

Answer y if prompted to confirm.

6.3. Generate the Data Layer (Service):

Create a RedwoodJS service:

bash
yarn rw g service smsStatus --ts

This creates api/src/services/smsStatus/smsStatus.ts.

6.4. Implement the Service Logic:

Open api/src/services/smsStatus/smsStatus.ts and add the recordSmsStatusUpdate function:

typescript
// api/src/services/smsStatus/smsStatus.ts
import type { Prisma } from '@prisma/client'
import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'

interface RecordSmsStatusInput {
  batchId: string
  recipient: string
  statusCode: number
  status: string
  statusTimestamp: Date
  clientReference?: string
  rawPayload: string
}

export const recordSmsStatusUpdate = async ({
  batchId,
  recipient,
  statusCode,
  status,
  statusTimestamp,
  clientReference,
  rawPayload,
}: RecordSmsStatusInput): Promise<void> => {
  const serviceLogger = logger.child({
    batchId,
    recipient,
    status,
    functionName: 'recordSmsStatusUpdate',
  })
  serviceLogger.info('Attempting to record SMS status update')

  // 1. Find the original message
  // CRITICAL: Store recipient in Message.to in E.164 format to match Sinch callbacks.
  // Mismatched formats will prevent finding the message.
  const message = await db.message.findFirst({
    where: {
      sinchBatchId: batchId,
      to: recipient, // Must match format (E.164 recommended)
    },
    select: { id: true, latestStatusTimestamp: true },
  })

  if (!message) {
    serviceLogger.error(
      { clientReference },
      'Could not find original message for this status update. Verify recipient format (E.164) and that the message was stored.'
    )
    throw new Error(
      `Original message not found for batchId: ${batchId}, recipient: ${recipient}. Verify recipient format.`
    )
  }

  serviceLogger.info({ messageId: message.id }, 'Found corresponding message')

  // 2. Create the SmsStatusUpdate record
  try {
    const createData: Prisma.SmsStatusUpdateCreateInput = {
      message: { connect: { id: message.id } },
      sinchBatchId: batchId,
      recipient: recipient,
      status: status,
      statusCode: statusCode,
      statusTimestamp: statusTimestamp,
      clientReference: clientReference,
      rawPayload: rawPayload,
    }

    // Idempotency Check: Avoid duplicates if Sinch retries
    const existingUpdate = await db.smsStatusUpdate.findFirst({
      where: {
        messageId: message.id,
        recipient: recipient,
        status: status,
        statusCode: statusCode,
      },
      select: { id: true }
    });

    if (existingUpdate) {
       serviceLogger.warn({ existingUpdateId: existingUpdate.id }, 'Duplicate status update received, ignoring')
       return;
    }

    const newStatusUpdate = await db.smsStatusUpdate.create({ data: createData })
    serviceLogger.info(
      { statusUpdateId: newStatusUpdate.id },
      'Created new SmsStatusUpdate record'
    )

    // 3. Update the latest status on the parent Message (if newer)
    if (
      !message.latestStatusTimestamp ||
      statusTimestamp > message.latestStatusTimestamp
    ) {
      await db.message.update({
        where: { id: message.id },
        data: {
          latestStatus: status,
          latestStatusCode: statusCode,
          latestStatusTimestamp: statusTimestamp,
        },
      })
      serviceLogger.info({ messageId: message.id }, 'Updated latest status on Message record')
    } else {
      serviceLogger.info(
         { messageId: message.id, newStatusTimestamp: statusTimestamp, latestStatusTimestamp: message.latestStatusTimestamp },
         'Received older or same-timestamp status update, not updating latest status'
       )
    }

  } catch (error) {
    serviceLogger.error({ error }, 'Database error while recording status update')
    throw error
  }
}

Key Service Logic:

  • Find Message: Locates the Message using sinchBatchId and recipient (requires matching formats – E.164 recommended)
  • Idempotency: Prevents duplicate SmsStatusUpdate records if Sinch retries
  • Create Status Record: Creates new SmsStatusUpdate linked to the Message
  • Update Latest Status: Updates latestStatus on Message if incoming status is newer
  • Error Handling: Catches and logs database errors

Ensure recordSmsStatusUpdate is imported in api/src/functions/sinchCallbacks.ts (as shown in Section 5.1).


7. Add Security Features (HMAC Validation)

Secure your webhook endpoint to ensure requests genuinely come from Sinch using HMAC-SHA256 signature validation.

7.1. Create a Security Utility:

Create api/src/lib/sinchSecurity.ts:

typescript
// api/src/lib/sinchSecurity.ts
import crypto from 'crypto'
import { logger } from 'src/lib/logger'
import type { APIGatewayProxyEventHeaders } from 'aws-lambda'

/**
 * Find a header value case-insensitively
 */
const findHeader = (headers: APIGatewayProxyEventHeaders, headerName: string): string | undefined => {
  const lowerCaseHeaderName = headerName.toLowerCase();
  const headerKey = Object.keys(headers).find(key => key.toLowerCase() === lowerCaseHeaderName);
  return headerKey ? headers[headerKey] : undefined;
}

/**
 * Validate the Sinch webhook signature using HMAC-SHA256
 *
 * @param headers - Request headers (case-insensitive lookup)
 * @param rawBody - Raw, unparsed request body string (exact bytes received)
 * @param secret - Your Sinch Webhook Secret from environment variables
 * @returns true if signature is valid, false otherwise
 * @throws Error if required headers are missing or secret is invalid
 */
export const validateSinchSignature = (
  headers: APIGatewayProxyEventHeaders,
  rawBody: string,
  secret: string | undefined
): boolean => {
  const securityLogger = logger.child({ functionName: 'validateSinchSignature' })

  if (!secret) {
    securityLogger.error('Webhook secret is not provided or empty')
    throw new Error('Webhook secret is missing or invalid');
  }

  // 1. Extract Sinch headers
  const sinchTimestamp = findHeader(headers, 'x-sinch-timestamp')
  const sinchSignatureHeader = findHeader(headers, 'x-sinch-signature')

  if (!sinchTimestamp) {
    securityLogger.warn('Missing x-sinch-timestamp header')
    return false
  }

  if (!sinchSignatureHeader) {
    securityLogger.warn('Missing x-sinch-signature header')
    return false
  }

  // 2. Prepare the signed content string
  // Sinch typically signs: raw request body + timestamp header value
  // IMPORTANT: Verify this format against current Sinch documentation
  const stringToSign = rawBody + sinchTimestamp;
  securityLogger.debug({ stringToSignLength: stringToSign.length }, 'Constructed string-to-sign')

  // 3. Decode the secret (Sinch secrets are Base64 encoded)
  let decodedSecret: Buffer;
  try {
    decodedSecret = Buffer.from(secret, 'base64');
  } catch (e) {
    securityLogger.error({ error: e }, 'Failed to decode webhook secret from Base64')
    throw new Error('Invalid webhook secret encoding');
  }

  // 4. Calculate the expected signature
  const hmac = crypto.createHmac('sha256', decodedSecret);
  const expectedSignature = hmac.update(stringToSign).digest('base64');
  securityLogger.debug('Calculated expected signature');

  // 5. Compare signatures securely
  const providedSignature = sinchSignatureHeader;

  try {
    const providedSigBuffer = Buffer.from(providedSignature, 'base64');
    const expectedSigBuffer = Buffer.from(expectedSignature, 'base64');

    if (providedSigBuffer.length !== expectedSigBuffer.length) {
        securityLogger.warn('Signature length mismatch');
        return false;
    }

    const isValid = crypto.timingSafeEqual(providedSigBuffer, expectedSigBuffer);
    securityLogger.info(`Signature validation result: ${isValid}`);
    return isValid;

  } catch (error) {
    securityLogger.error({ error }, 'Error during signature comparison (possibly invalid Base64 format)');
    return false;
  }
}

Key Security Logic:

  • Header Extraction: Safely extracts x-sinch-timestamp and x-sinch-signature (case-insensitive)
  • Secret Handling: Validates and decodes secret from Base64
  • String-to-Sign Construction: Creates the string Sinch signed (raw body + timestamp). Note: Verify format against current Sinch documentation
  • HMAC Calculation: Computes expected HMAC-SHA256 signature
  • Secure Comparison: Uses crypto.timingSafeEqual to prevent timing attacks
  • Logging: Includes debugging for signature validation issues

7.2. Integrate Validation into the Handler:

The validateSinchSignature function is already integrated in the handler code from Section 5.1. Ensure raw body and headers are passed correctly.


8. Testing and Debugging Your SMS Webhook

Before deploying to production, thoroughly test your webhook implementation locally and in staging environments.

8.1. Local Testing with ngrok:

  1. Start your RedwoodJS development server:
bash
yarn rw dev
  1. In a separate terminal, start ngrok:
bash
ngrok http 8911
  1. Copy the ngrok HTTPS URL and configure it in your Sinch Dashboard
  2. Send a test SMS through Sinch with delivery_report: "per_recipient"
  3. Monitor your local console for webhook callbacks and logs

8.2. Common Debugging Issues:

  • 401 Unauthorized Errors: HMAC signature mismatch. Verify your SINCH_WEBHOOK_SECRET matches exactly in both .env and Sinch Dashboard (including Base64 encoding)
  • Message Not Found Errors: Phone number format mismatch. Ensure you store recipient numbers in E.164 format (e.g., +15551234567)
  • Duplicate Status Updates: Normal behavior if Sinch retries. The idempotency check in the service prevents duplicate database records
  • Missing Callbacks: Verify you included delivery_report: "per_recipient" when sending the SMS

8.3. Monitoring Production Webhooks:

  • Review logs regularly using your RedwoodJS logger output
  • Set up alerts for repeated 401 or 500 errors
  • Track delivery rates and failure patterns in your database
  • Consider implementing a dashboard to visualize message delivery statistics


Frequently Asked Questions

Q: What's the difference between per_recipient and per_recipient_final delivery reports?

A: per_recipient sends callbacks for every status change (Dispatched, Delivered, Failed), while per_recipient_final only sends the final status once delivery completes or fails. Use per_recipient for detailed tracking and per_recipient_final to reduce webhook traffic.

Q: How long should I wait before considering a message failed?

A: Sinch typically delivers status updates within minutes, but carrier delays can extend this to hours. Messages that remain in "Dispatched" status for 24-48 hours are likely failed. Sinch will send a final "Expired" status if delivery times out.

Q: Can I use this webhook handler for MMS delivery reports?

A: Yes, with minor modifications. Change the type check to include recipient_delivery_report_mms and update your database schema to track MMS-specific fields if needed.

Q: Should I retry failed messages automatically?

A: It depends on the failure reason. Check the statusCode in the callback – some codes indicate permanent failures (invalid number), while others may be temporary (network issues). Implement retry logic with exponential backoff for temporary failures only.

Q: How do I handle webhooks during local development?

A: Use ngrok (as shown in section 4.1) to create a public HTTPS URL that forwards to your local development server. Update the callback URL in Sinch Dashboard to your ngrok URL while developing, then switch to your production URL when deploying.