This guide provides a step-by-step walkthrough for integrating Sinch SMS capabilities into a RedwoodJS application, enabling both outbound and inbound (two-way) messaging. We'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 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 ORM, and testing setup.
- Sinch: A cloud communications platform offering APIs for SMS, Voice, Video, and more. We'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:
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
oryarn 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 will have a functional RedwoodJS application capable of:
- Sending SMS messages through a GraphQL mutation.
- Receiving incoming SMS messages via a webhook.
- Storing both outbound and inbound messages in your database.
- A foundation for building more complex messaging features.
1. Project Setup & Configuration
Let's 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:
yarn create redwood-app ./redwood-sinch-messaging
cd redwood-sinch-messaging
Follow the prompts. Choose JavaScript or TypeScript based on your preference (this guide will use examples assuming JavaScript, but the concepts apply equally to TypeScript). Select your preferred database (PostgreSQL recommended).
2. Install Sinch SDK:
We need the Sinch Node.js SDK to interact with their API easily.
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 wherefetch
isn't native.
3. Environment Variables:
RedwoodJS uses a .env
file for environment variables. Create one in the project root (redwood-sinch-messaging/.env
):
# .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 theYOUR_...
placeholders with your actual credentials found in your Sinch Dashboard underAccess Keys
. You might need to create a new API key pair. TheSINCH_KEY_SECRET
is only shown once upon creation – save it securely.SINCH_NUMBER
: ReplaceYOUR_SINCH_VIRTUAL_NUMBER
with the virtual phone number you acquired from Sinch, formatted in E.164 (e.g.,+12223334444
). Find it underNumbers
>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
: ReplaceYOUR_STRONG_RANDOM_WEBHOOK_SECRET
with a unique, cryptographically strong random string that you generate. This is used to secure your webhook endpoint.
4. Initialize Sinch Client (Utility):
To avoid repeating client initialization, let's create a utility function.
Create api/src/lib/sinch.js
:
// 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. Please 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.
2. Database Schema and Data Layer
We need a way to store messages. Let's define a Prisma schema.
1. Define Prisma Schema:
Open api/db/schema.prisma
and add a Message
model:
// 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 our app (OUTBOUND
) or received by our app (INBOUND
).status
,sentAt
,receivedAt
: Timestamps and status information.
2. Create Database Migration:
Apply the schema changes to your database:
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.
3. Implementing Core Functionality: Sending SMS
Let's build the service and GraphQL mutation to send SMS messages.
1. Create Redwood Service:
Generate a service to handle message logic:
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
:
// 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. Let's 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 toSENT
(orACCEPTED
) if Sinch accepts the request, andFAILED
on error. Note thatSENT
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:
# 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):
-
Start your development server:
yarn rw dev
-
Navigate to
http://localhost:8911/graphql
(or your configured GraphQL endpoint). -
Use the following mutation (replace the placeholder
to
number with a real E.164 formatted number you can check):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 } }
-
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 newMessage
record.
4. Implementing Core Functionality: Receiving SMS
Now, let's 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:
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
:
// 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-signature']
const timestamp = req.headers['x-sinch-timestamp']
const webhookSecret = process.env.SINCH_WEBHOOK_SECRET
// Check for missing headers or secret (including placeholder value)
if (!sinchSignature || !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.
// Standard serverless environments (like default Redwood deployments on Netlify/Vercel)
// often parse JSON payloads *before* your function runs, making the raw body inaccessible.
// The `JSON.stringify(event.body)` approach below is a *workaround* that might
// succeed *only if* the parsing and re-stringifying process doesn't alter whitespace,
// key order, or character encoding compared to the original raw body.
// **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 (like `express.raw()`) 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;
}
const stringToSign = `${requestBody}|${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, 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, 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 we 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 headers and yourSINCH_WEBHOOK_SECRET
. Strongly emphasized the unreliability of theJSON.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 oftext_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 withdirection: 'INBOUND'
. - Error Handling: Logs errors if database storage fails and returns
500
status code, potentially prompting Sinch to retry. Returns400
for malformed payloads. - Acknowledgement: Returns
200 OK
quickly.
3. Expose Local Endpoint with ngrok
:
To let Sinch send webhooks to your local development machine, use ngrok
.
-
Make sure your Redwood dev server is running (
yarn rw dev
). The API server typically runs on port 8911. -
Open a new terminal window and run:
ngrok http 8911
-
ngrok
will display forwarding URLs. Copy thehttps://
URL (e.g.,https://<random-string>.ngrok-free.app
).
4. Configure Sinch Webhook:
- Go to your Sinch Customer Dashboard.
- Navigate to Conversation API -> Apps.
- Select the App associated with your SMS number (or create one if needed and link the number).
- Find the Webhooks section (or Callbacks).
- 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
- Webhook Secret: Paste the same
SINCH_WEBHOOK_SECRET
you defined in your.env
file (make sure it's not the placeholder!). - 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 withMESSAGE_INBOUND
.
- Authentication: Select Signed (HMAC-SHA256).
- Save the webhook configuration.
5. Test Receiving SMS:
- Ensure
yarn rw dev
andngrok http 8911
are running. - Using a real phone, send an SMS message to your
SINCH_NUMBER
. - Watch the console output of your Redwood API server (
yarn rw dev
). You should see logs from theinboundSms
function. Look for ""Sinch webhook signature verified successfully"" and ""Inbound message stored successfully."" - Check your database to confirm a new
Message
record withdirection: 'INBOUND'
has been created. - 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).
5. Error Handling, Logging, and Retries
-
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 inapi/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 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.// 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). Returning2xx
confirms receipt. Returning4xx
(like401
for bad signature or400
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 (theexternalId
check helps with this).
6. Security Features
- 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.