code examples
code examples
How to Build WhatsApp Integration in RedwoodJS with Sinch API: Complete Guide
Learn how to integrate WhatsApp messaging into your RedwoodJS application using Sinch Conversation API. Complete tutorial with code examples, webhook setup, authentication, and deployment best practices.
Developer Guide: Integrating WhatsApp into RedwoodJS with Node.js and Sinch
Build a production-ready RedwoodJS application that sends and receives WhatsApp messages using the Sinch Conversation API and its Node.js SDK. This guide covers project setup, implementation, deployment, and verification.
Project Overview and Goals
What You're Building:
Create a RedwoodJS application with these capabilities:
- Send WhatsApp Messages: An authenticated API endpoint (GraphQL mutation) to send text messages via WhatsApp through Sinch.
- Receive WhatsApp Messages: A webhook handler (RedwoodJS function) to process incoming messages sent from WhatsApp users to your dedicated Sinch number.
- Basic Message Logging: Store basic information about incoming messages in the database.
Estimated Time: 2–3 hours for basic implementation, 4–6 hours with testing and deployment.
Problem Solved:
This integration enables businesses using RedwoodJS applications to leverage WhatsApp for customer communication, support, notifications, and marketing (following WhatsApp's policies) directly from their backend systems. It abstracts the complexities of the WhatsApp Business API through Sinch's unified Conversation API.
Business Value:
- Reach 2+ billion WhatsApp users globally
- Achieve 98% open rates (vs. 20% for email)
- Reduce support response time by 40–60% with direct messaging
- Lower infrastructure costs compared to direct WhatsApp Business API integration
Technologies Used:
- RedwoodJS: A full-stack JavaScript/TypeScript framework for the web. Provides structure for API (Node.js), database (Prisma), and frontend.
- Node.js: The runtime environment for the RedwoodJS API side.
- Sinch Conversation API: A unified API provided by Sinch to interact with various messaging channels, including WhatsApp.
- Sinch Node.js SDK: Official library to simplify interactions with Sinch APIs from Node.js (
@sinch/sdk-core). - Prisma: ORM used by RedwoodJS for database interactions.
- GraphQL: API query language used by RedwoodJS for frontend-backend communication.
- TypeScript: Strongly typed language for improved developer experience and code quality.
Why These Technologies?
- RedwoodJS: Offers an opinionated, integrated full-stack experience, simplifying setup, API creation, and database management.
- Sinch: Provides a managed WhatsApp Business API solution, handling infrastructure, compliance, and offering a simplified API interface compared to directly integrating with Meta's API. The Conversation API allows future expansion to other channels (SMS, RCS, etc.) with minimal code changes.
- Sinch Node.js SDK: Offers a typed, cleaner interface to the Sinch API than raw HTTP requests, improving maintainability and reducing errors.
System Architecture Diagram:
+-------------------+ +---------------------+ +-----------------+ +------------+ +-------------+
| RedwoodJS Frontend| ---> | RedwoodJS API (GQL)| ---> | Redwood Service | ---> | Sinch SDK | ---> | Sinch API |
| (Optional) | | (Node.js) | | (whatsapp.ts) | | (@sinch/...) | | (Conv. API) |
+-------------------+ +---------+-----------+ +--------+--------+ +-------------+ +-------+-----+
| ^ |
| (DB Interaction) | (Process Incoming) | (To/From WhatsApp)
v | v
+-------------------+ +--------+--------+ +---------+-----------+ +-------------+ +------------+
| Database (Prisma)| <--- | Redwood Service | <--- | Redwood Function | <--- | Sinch Webhook| <--- | WhatsApp |
| | | (optional) | | (sinchWebhook.ts) | | (POST) | | User |
+-------------------+ +-----------------+ +---------------------+ +-------------+ +------------+Prerequisites:
- Node.js: Version 20.x LTS (recommended) or 22.x. Important: Node.js 18.x reaches End of Life (EOL) on May 14, 2025 – do not use for new projects (source: Node.js Release Schedule).
- Verify your version:
node --version
- Verify your version:
- Yarn: Package manager (used by RedwoodJS).
- Verify installation:
yarn --version
- Verify installation:
- RedwoodJS CLI:
npm install -g @redwoodjs/cli- Verify installation:
rw --version
- Verify installation:
- Sinch Account: A registered postpay Sinch account. A postpay account is required for using the WhatsApp channel; trial accounts have limitations. Sign up at Sinch.com.
- Estimated Cost: $0.005–$0.012 per message (varies by destination country). Starting tier typically includes $10–$25 monthly credit.
- Sinch Project & App: A Project created in the Sinch Customer Dashboard, with a Conversation API App configured within it.
- WhatsApp Sender ID: A WhatsApp Sender ID (phone number) provisioned and approved through the Sinch Customer Dashboard and linked to your Sinch App.
- Publicly Accessible URL: For deploying the webhook handler (e.g., using Vercel, Netlify, Render, or ngrok for local development).
Security Note: Do not use the Sinch Node.js SDK (@sinch/sdk-core) in front-end applications (Angular, React, Vue.js, etc.). Doing so exposes your Sinch credentials to end-users. Use the SDK only in backend/server-side code (source: Sinch SDK GitHub).
Final Outcome:
A functional RedwoodJS application backend that sends WhatsApp text messages programmatically via an API call and receives/logs incoming WhatsApp messages via a webhook.
How to Set Up Your RedwoodJS Project for WhatsApp Integration
Create the RedwoodJS project, install dependencies, and configure environment variables.
1.1 Create RedwoodJS Project:
Open your terminal and run the following command using TypeScript:
yarn create redwood-app ./redwood-sinch-whatsapp --typescript
cd redwood-sinch-whatsappThis scaffolds a new RedwoodJS project in the redwood-sinch-whatsapp directory.
Troubleshooting Common Setup Issues:
| Issue | Solution |
|---|---|
| Yarn command not found | Install Yarn: npm install -g yarn |
| Permission errors during creation | Use sudo or fix npm permissions: NPM docs |
| TypeScript errors after creation | Run yarn install to ensure all dependencies are installed |
1.2 Install Sinch Node.js SDK:
Navigate to the API workspace directory and add the Sinch SDK package. Install this in the api side because it's a backend dependency used only by API services and functions.
cd api
yarn add @sinch/sdk-core
cd ..Note: As of January 2025, the latest version is @sinch/sdk-core@1.3.0. This SDK requires Node.js 20.x LTS or later. If you're using Node.js 18.x, upgrade before May 2025 when Node 18 reaches EOL (source: Sinch SDK GitHub, Node.js Release Schedule).
1.3 Configure Environment Variables:
RedwoodJS uses a .env file in the project root for environment variables. Create this file:
touch .envAdd the following variables. Obtain the actual values as described in Section 4. Environment variables typically do not require quotes unless the value contains spaces or special characters.
Generate Webhook Secret:
# On macOS/Linux:
openssl rand -base64 32
# Or use Node.js:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"# .env
# Sinch API Credentials (Find in Sinch Dashboard -> Access Keys -> API Credentials)
SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID
SINCH_KEY_ID=YOUR_SINCH_KEY_ID_FROM_ACCESS_KEYS
SINCH_KEY_SECRET=YOUR_SINCH_KEY_SECRET_FROM_ACCESS_KEYS
# Sinch Conversation API App Details (Find in Sinch Dashboard -> Apps -> Your Conversation API App -> App ID)
SINCH_APP_ID=YOUR_SINCH_CONVERSATION_API_APP_ID
# Sinch WhatsApp Sender ID (Find in Sinch Dashboard -> Apps -> Your Conversation API App -> Channels -> WhatsApp -> Sender Identity)
WHATSAPP_SENDER_ID=YOUR_WHATSAPP_PHONE_NUMBER_SENDER_ID_IN_E164_FORMAT # e.g., +15551234567
# RedwoodJS Webhook Verification (Recommended Security Measure)
# Generate a secure, random string and keep it secret. Use the same value when configuring the webhook in the Sinch Dashboard (Section 4.3).
SINCH_WEBHOOK_SECRET=YOUR_SECURE_RANDOM_WEBHOOK_SECRETExplanation of Variables:
SINCH_PROJECT_ID,SINCH_KEY_ID,SINCH_KEY_SECRET: Used by the Sinch SDK to authenticate your API requests globally for your account.SINCH_APP_ID: Identifies the specific Conversation API application within your Sinch project that handles the messages.WHATSAPP_SENDER_ID: The approved WhatsApp phone number linked to your Sinch App that messages are sent from.SINCH_WEBHOOK_SECRET: A shared secret between your app and Sinch to verify that incoming webhook requests genuinely originate from Sinch (strongly recommended for security).
1.4 Initialize Sinch Client:
Create a reusable Sinch client instance using the environment variables.
Create a new file: api/src/lib/sinch.ts
// api/src/lib/sinch.ts
import { SinchClient } from '@sinch/sdk-core'
// Ensure environment variables are loaded (RedwoodJS handles this automatically)
if (
!process.env.SINCH_PROJECT_ID ||
!process.env.SINCH_KEY_ID ||
!process.env.SINCH_KEY_SECRET
) {
throw new Error(
'Sinch API credentials (SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET) are missing in .env'
)
}
// Initialize the Sinch Client
// Note: The Sinch SDK automatically picks up credentials from environment variables
// if named SINCH_PROJECT_ID, SINCH_KEY_ID, and SINCH_KEY_SECRET.
// Explicitly passing them is also possible if needed.
export const sinchClient = new SinchClient({
// projectId: process.env.SINCH_PROJECT_ID, // Optional if using env vars
// keyId: process.env.SINCH_KEY_ID, // Optional if using env vars
// keySecret: process.env.SINCH_KEY_SECRET, // Optional if using env vars
})
// Export necessary identifiers from environment variables for convenience
export const sinchAppId = process.env.SINCH_APP_ID
export const whatsappSenderId = process.env.WHATSAPP_SENDER_ID
if (!sinchAppId) {
throw new Error('SINCH_APP_ID is missing in .env')
}
if (!whatsappSenderId) {
throw new Error('WHATSAPP_SENDER_ID is missing in .env')
}
console.log('Sinch Client Initialized.') // Log confirmation during startupWhy This Structure?
- Centralized client instantiation (
api/src/lib/sinch.ts) promotes reuse and follows RedwoodJS conventions for library code. - Environment variables keep sensitive credentials out of the codebase.
- Early checks for missing variables prevent runtime errors later.
Implementing WhatsApp Sending and Receiving Functionality
Implement the logic for sending and receiving messages.
2.1 Create WhatsApp Service:
RedwoodJS services encapsulate business logic. Create one for WhatsApp operations:
yarn rw g service whatsappThis creates api/src/services/whatsapp/whatsapp.ts and related files.
Modify api/src/services/whatsapp/whatsapp.ts:
// api/src/services/whatsapp/whatsapp.ts
import type { SendWhatsAppTextMessageInput } from 'types/graphql'
import { sinchClient, sinchAppId, whatsappSenderId } from 'src/lib/sinch'
import { logger } from 'src/lib/logger'
import { db } from 'src/lib/db'
interface SinchMessageRecipient {
contact_id?: string
identified_by?: {
channel: 'WHATSAPP'
identity: string // E.164 phone number
app_id?: string
}
}
/**
* Sends a WhatsApp text message via the Sinch Conversation API.
*/
export const sendWhatsAppTextMessage = async ({
to,
text,
}: SendWhatsAppTextMessageInput): Promise<{ messageId: string }> => {
logger.info({ to, text }, 'Attempting to send WhatsApp text message')
// Validate input
if (!to || !text) {
throw new Error('Recipient phone number (to) and message text are required.')
}
if (!/^\+\d{10,15}$/.test(to)) {
logger.warn({ to }, 'Invalid phone number format provided.')
throw new Error(
'Invalid recipient phone number format. Use E.164 format (e.g., +15551234567).'
)
}
if (text.length > 4096) {
throw new Error('Message text exceeds WhatsApp limit of 4,096 characters.')
}
// Construct the recipient object
const recipient: SinchMessageRecipient = {
identified_by: {
channel: 'WHATSAPP',
identity: to,
},
}
try {
const response = await sinchClient.conversation.messages.send({
app_id: sinchAppId,
recipient: recipient,
message: {
text_message: {
text: text,
},
},
channel_priority_order: ['WHATSAPP'],
})
logger.info({ messageId: response.message_id }, 'WhatsApp message sent successfully via Sinch')
// Optional: Log sent message to your DB
// To enable this, ensure the MessageLog model is defined in schema.prisma (Section 6)
// and uncomment the following line:
// await db.messageLog.create({ data: { direction: 'OUTBOUND', recipient: to, status: 'SENT', sinchMessageId: response.message_id, body: text } });
if (!response.message_id) {
logger.error({ response }, 'Sinch API did not return a message_id')
throw new Error('Failed to send message: No message ID received from Sinch.')
}
return { messageId: response.message_id }
} catch (error) {
logger.error({ error, to, text }, 'Failed to send WhatsApp message via Sinch')
// Handle specific Sinch error codes
if (error.response?.data) {
logger.error({ sinchError: error.response.data }, 'Sinch API Error Details')
// Common error patterns
const errorCode = error.response.data.code
if (errorCode === 'unauthorized') {
throw new Error('Invalid Sinch API credentials. Check your .env configuration.')
}
if (errorCode === 'invalid_argument') {
throw new Error('Invalid phone number or message format.')
}
}
throw new Error(`Failed to send WhatsApp message: ${error.message}`)
}
}
/**
* Processes an incoming WhatsApp message received via webhook.
*/
export const processIncomingWhatsAppMessage = async (payload: any): Promise<void> => {
logger.info({ payload }, 'Processing incoming Sinch webhook payload')
// Validate it's an inbound message event
if (payload?.event !== 'MESSAGE_INBOUND' || payload?.message?.direction !== 'INBOUND') {
logger.warn({ event: payload?.event, direction: payload?.message?.direction }, 'Received non-inbound message event, skipping.')
return;
}
const message = payload.message;
const contactMessage = message?.contact_message;
// Check for different message types
let messageText: string | null = null;
if (contactMessage?.text_message) {
messageText = contactMessage.text_message.text;
} else if (contactMessage?.media_message) {
logger.info({ payload }, 'Received media message (image, video, etc.). Current implementation only logs text.');
messageText = '[Media Message Received]';
} else {
logger.warn({ contactMessage }, 'Received inbound message type not explicitly handled (e.g., location, unsupported).');
return;
}
// Ensure required fields are present
if (!messageText || !message?.contact_id || !message?.channel_identity?.identity) {
logger.warn({ payload }, 'Received inbound message missing required fields after type check, skipping.')
return;
}
const fromNumber = message.channel_identity.identity
const sinchMessageId = message.message_id
const sinchContactId = message.contact_id
logger.info({ fromNumber, messageText, sinchMessageId, sinchContactId }, 'Parsed incoming WhatsApp message')
try {
// Store the incoming message in the database
await db.messageLog.create({
data: {
direction: 'INBOUND',
recipient: fromNumber,
status: 'RECEIVED',
sinchMessageId: sinchMessageId,
sinchContactId: sinchContactId,
body: messageText,
receivedAt: new Date(payload.accepted_time || Date.now()),
payload: payload,
},
})
logger.info({ sinchMessageId }, 'Incoming message logged to database.')
// Add your business logic here
// Example: Trigger automated responses, notify support staff, etc.
// if (messageText.toLowerCase().includes('help')) {
// await sendWhatsAppTextMessage({ to: fromNumber, text: 'How can I assist you today?' });
// }
} catch (error) {
logger.error({ error, sinchMessageId, fromNumber }, 'Failed to process or store incoming WhatsApp message')
}
}Input Validation Patterns:
| Field | Validation | Error Message |
|---|---|---|
to | E.164 format regex: ^\+\d{10,15}$ | "Invalid recipient phone number format. Use E.164 format (e.g., +15551234567)." |
text | Required, max 4,096 characters | "Message text exceeds WhatsApp limit of 4,096 characters." |
| Webhook payload | Event type and direction check | "Received non-inbound message event, skipping." |
Edge Cases Handled:
- Empty or null inputs
- Invalid phone number formats
- Media messages (logged with placeholder text)
- Missing required fields in webhook payload
- Sinch API authentication failures
- Character limit violations
Why This Approach?
- Separation of Concerns: The service handles the core logic of interacting with the Sinch SDK and database.
- Clear Functions: Dedicated functions for sending and processing messages.
- Robust Error Handling: Comprehensive
try...catchblocks with specific error code handling. - Detailed Logging: Uses Redwood's built-in logger for visibility.
- Comprehensive Input Validation: Checks for required fields, format, and limits.
2.2 Create Webhook Handler:
RedwoodJS functions are serverless functions that handle HTTP requests. Create one to receive webhooks from Sinch:
yarn rw g function sinchWebhook --typescriptThis creates api/src/functions/sinchWebhook.ts.
Modify api/src/functions/sinchWebhook.ts:
// api/src/functions/sinchWebhook.ts
import type { APIGatewayEvent, Context } from 'aws-lambda'
import { logger } from 'src/lib/logger'
import { processIncomingWhatsAppMessage } from 'src/services/whatsapp/whatsapp'
import { createHmac, timingSafeEqual } from 'crypto'
export const handler = async (event: APIGatewayEvent, _context: Context) => {
logger.info('Sinch Webhook received request')
// Security: Signature Verification (Strongly Recommended)
const webhookSecret = process.env.SINCH_WEBHOOK_SECRET;
if (!webhookSecret) {
logger.warn('SINCH_WEBHOOK_SECRET is not configured. Skipping signature verification. This is insecure!');
} else {
const sinchSignature = event.headers['x-sinch-signature'] || event.headers['X-Sinch-Signature'];
if (!sinchSignature) {
logger.warn('Missing x-sinch-signature header. Unauthorized.');
return { statusCode: 401, body: 'Missing signature' };
}
try {
const hmac = createHmac('sha256', webhookSecret);
const digest = Buffer.from(hmac.update(event.body).digest('base64'), 'utf8');
const checksum = Buffer.from(sinchSignature, 'utf8');
if (checksum.length !== digest.length || !timingSafeEqual(digest, checksum)) {
logger.warn('Invalid signature. Unauthorized.');
return { statusCode: 401, body: 'Invalid signature' };
}
logger.info('Sinch webhook signature verified successfully.');
} catch (error) {
logger.error({ error }, 'Error during signature verification');
return { statusCode: 500, body: 'Signature verification failed' };
}
}
// Validate HTTP Method
if (event.httpMethod !== 'POST') {
logger.warn(`Unsupported HTTP method: ${event.httpMethod}`)
return { statusCode: 405, body: 'Method Not Allowed' }
}
// Parse the request body
let payload: any;
try {
payload = JSON.parse(event.body || '{}');
if (Object.keys(payload).length === 0 && event.body && event.body !== '{}') {
throw new Error('Parsed empty object from non-empty body');
}
} catch (error) {
logger.error({ error, body: event.body }, 'Failed to parse incoming webhook body')
return { statusCode: 400, body: 'Bad Request: Invalid JSON payload' }
}
// Process the message asynchronously
// IMPORTANT: For production reliability, use a job queue system (BullMQ, Redis SMQ, AWS SQS)
// instead of setImmediate to prevent message loss during processing failures.
setImmediate(() => {
processIncomingWhatsAppMessage(payload).catch((error) => {
logger.error({ error }, 'Unhandled error during async webhook processing');
});
});
// Acknowledge receipt immediately to Sinch
logger.info('Webhook received successfully, acknowledging.')
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'received' }),
}
}Rate Limiting Recommendation:
Implement rate limiting on the webhook endpoint to prevent abuse:
Option 1: API Gateway Rate Limiting (AWS)
// serverless.yml or AWS Console
functions:
sinchWebhook:
handler: api/src/functions/sinchWebhook.handler
events:
- http:
path: /.redwood/functions/sinchWebhook
method: post
throttling:
maxRequestsPerSecond: 100
maxConcurrentRequests: 50Option 2: In-Memory Rate Limiting (Simple)
import rateLimit from 'express-rate-limit'
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // limit each IP to 100 requests per windowMs
message: 'Too many webhook requests'
})Why This Approach?
- Dedicated Endpoint: A specific function handles incoming Sinch events.
- Security First: Includes webhook signature verification using HMAC-SHA256 and
timingSafeEqualto prevent timing attacks. - Asynchronous Processing: Uses
setImmediatefor basic async handling, but strongly recommends using a job queue for production reliability. - Robust Error Handling: Catches JSON parsing errors and logs issues during processing.
- Clear Logging: Provides visibility into received requests and processing steps.
Building the GraphQL API Layer for WhatsApp
Expose the sendWhatsAppTextMessage functionality through Redwood's GraphQL API.
3.1 Define GraphQL Schema:
Add the necessary types and mutations to your GraphQL schema.
Edit api/src/graphql/whatsapp.sdl.ts (create if it doesn't exist):
# api/src/graphql/whatsapp.sdl.ts
export const schema = gql`
type WhatsAppSendResponse {
messageId: String!
status: String
}
input SendWhatsAppTextMessageInput {
"Recipient phone number in E.164 format (e.g., +15551234567)"
to: String!
"The text content of the message"
text: String!
}
type Mutation {
"""
Sends a WhatsApp text message via Sinch.
Requires authentication.
"""
sendWhatsAppTextMessage(input: SendWhatsAppTextMessageInput!): WhatsAppSendResponse! @requireAuth
}
`3.2 Link Service to Resolver:
Redwood automatically maps the sendWhatsAppTextMessage mutation to the service function of the same name (api/src/services/whatsapp/whatsapp.ts). Ensure the input type (SendWhatsAppTextMessageInput) matches between the SDL and the service function signature.
3.3 Add Authentication:
The @requireAuth directive ensures only logged-in users (according to your RedwoodJS auth setup) can call this mutation.
Implementing Role-Based Authorization:
# Example: Restrict to specific roles
type Mutation {
sendWhatsAppTextMessage(input: SendWhatsAppTextMessageInput!): WhatsAppSendResponse!
@requireAuth(roles: ["ADMIN", "SUPPORT"])
}Service-Level Authorization:
// api/src/services/whatsapp/whatsapp.ts
import { requireAuth } from 'src/lib/auth'
export const sendWhatsAppTextMessage = async ({
to,
text,
}: SendWhatsAppTextMessageInput): Promise<{ messageId: string }> => {
// Verify user has required role
requireAuth({ roles: ['ADMIN', 'SUPPORT'] })
// Rest of the function...
}- Note: Configure authentication in your RedwoodJS app for
@requireAuthto work. Runyarn rw setup auth <provider>(e.g.,dbAuth,clerk,supabase) if you haven't already.
3.4 Test the Endpoint (Example using curl):
Once your Redwood app is running (yarn rw dev), test the mutation. You need a valid authentication token (Bearer token) obtained after logging in via your configured auth provider.
# Replace these placeholders:
# YOUR_API_URL: Your RedwoodJS GraphQL endpoint URL
# - Local Dev: http://localhost:8911/graphql
# - Production: https://your-app-domain.com/graphql
# YOUR_AUTH_TOKEN: Valid Bearer token from your app's login flow
# YOUR_AUTH_PROVIDER_HEADER_VALUE: Auth provider identifier (e.g., 'dbAuth', 'clerk', 'supabase')
# This header may not be needed for all providers – check your auth setup
# RECIPIENT_PHONE_NUMBER: Valid WhatsApp-enabled phone number in E.164 format (e.g., +15559876543)
curl 'YOUR_API_URL' \
-H 'Authorization: Bearer YOUR_AUTH_TOKEN' \
-H 'Content-Type: application/json' \
-H 'auth-provider: YOUR_AUTH_PROVIDER_HEADER_VALUE' \
--data-binary '{
"query": "mutation SendMessage($input: SendWhatsAppTextMessageInput!) { sendWhatsAppTextMessage(input: $input) { messageId } }",
"variables": {
"input": {
"to": "RECIPIENT_PHONE_NUMBER",
"text": "Hello from RedwoodJS via Sinch!"
}
}
}' \
--compressedExpected Response (JSON):
{
"data": {
"sendWhatsAppTextMessage": {
"messageId": "01HF..."
}
}
}Error Response Examples:
Invalid Phone Number:
{
"errors": [{
"message": "Invalid recipient phone number format. Use E.164 format (e.g., +15551234567).",
"extensions": { "code": "BAD_USER_INPUT" }
}]
}Authentication Failed:
{
"errors": [{
"message": "You are not authenticated",
"extensions": { "code": "UNAUTHENTICATED" }
}]
}Sinch API Error:
{
"errors": [{
"message": "Failed to send WhatsApp message: Invalid Sinch API credentials. Check your .env configuration.",
"extensions": { "code": "INTERNAL_SERVER_ERROR" }
}]
}Configuring Sinch Dashboard for WhatsApp Business API
Obtain the necessary credentials and configure your Sinch App.
4.1 Obtain Sinch API Credentials:
- Log in to the Sinch Customer Dashboard.
- Navigate to Access Keys in the left-hand menu.
- Under API Credentials, find your Project ID. Copy this value.
- Click + Create Key.
- Give the key a descriptive name (e.g.,
redwood-whatsapp-app-key). - Click Create Key.
- Immediately copy the Key ID and Key Secret. The Key Secret is shown only once. Store these securely.
- Update your
.envfile with these values:SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_IDSINCH_KEY_ID=YOUR_SINCH_KEY_ID_FROM_ACCESS_KEYSSINCH_KEY_SECRET=YOUR_SINCH_KEY_SECRET_FROM_ACCESS_KEYS
4.2 Obtain Sinch App ID and WhatsApp Sender ID:
- In the Sinch Dashboard, navigate to Apps → Conversation API.
- Select the App you created for this integration (or create a new one).
- On the App's details page, find the App ID. Copy this value.
- Update your
.envfile:SINCH_APP_ID=YOUR_SINCH_CONVERSATION_API_APP_ID
- Scroll down to the Channels section.
- Ensure the WhatsApp channel is enabled. If not, click Set up channel, select your previously provisioned WhatsApp Sender Identity (your WhatsApp number), and save.
- The Sender Identity listed under the enabled WhatsApp channel is your WhatsApp Sender ID (phone number in E.164 format). Copy this value.
- Update your
.envfile:WHATSAPP_SENDER_ID=YOUR_WHATSAPP_PHONE_NUMBER_SENDER_ID_IN_E164_FORMAT(e.g.,+15551234567)
4.3 Configure the Sinch Webhook:
- While still on your Conversation API App's details page in the Sinch Dashboard, scroll to the Webhooks section.
- Provide the public URL of your deployed RedwoodJS webhook function (
sinchWebhook).- For Production:
https://your-deployed-app-domain.com/.redwood/functions/sinchWebhook - For Local Development: Use ngrok to expose your local server:
- Install ngrok:
npm install -g ngrok - Run Redwood dev server:
yarn rw dev(API runs on port 8911 by default) - In another terminal:
ngrok http 8911 - Copy the
https://******.ngrok.ioforwarding URL. Your webhook URL:https://******.ngrok.io/.redwood/functions/sinchWebhook
- Install ngrok:
- For Production:
- Click + Add Webhook.
- Enter the Target URL (your public function URL).
- For Triggers, select at least:
MESSAGE_INBOUND(to receive messages from users)- Consider adding
MESSAGE_DELIVERYif you want status updates (see Section 8).
- Secret Token (Strongly Recommended): If you implemented signature verification in
sinchWebhook.ts(as recommended), paste the exact same secure random string you used for theSINCH_WEBHOOK_SECRETenvironment variable into the Secret Token field here. - Click Add.
Testing Webhook Before Going Live:
Test your webhook configuration using curl before configuring it in Sinch:
# Generate test signature
WEBHOOK_SECRET="your_webhook_secret"
PAYLOAD='{"event":"MESSAGE_INBOUND","message":{"direction":"INBOUND"}}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -binary | base64)
# Test webhook locally
curl -X POST http://localhost:8911/.redwood/functions/sinchWebhook \
-H "Content-Type: application/json" \
-H "x-sinch-signature: $SIGNATURE" \
-d "$PAYLOAD"
# Expected response: {"status":"received"}4.4 Set Channel Priority (Important):
- On the App's details page, find the Channel Priority section.
- Ensure WhatsApp is listed, preferably as the first or only channel if you only intend to use WhatsApp via this App ID for sending.
- If not present, add it and drag it to the desired priority. This tells Sinch which channel to try first when sending messages if the recipient could be reached on multiple channels linked to the same contact.
Production-Ready Error Handling and Logging
Production applications require robust error handling.
5.1 Consistent Error Handling:
- Service Layer: The
whatsapp.tsservice catches errors from the Sinch SDK. Log detailed errors here, including the original error object and relevant context (like recipient number). Re-throw standardized errors to be handled by the GraphQL layer. - GraphQL Layer: Redwood's GraphQL setup handles errors thrown from services and formats them for the client. Customize error formatting if needed.
- Webhook Handler: The
sinchWebhook.tsfunction catches errors during payload parsing and signature verification. Log errors thoroughly. Since it processes asynchronously (especially with a queue), ensure unhandled promise rejections inprocessIncomingWhatsAppMessageare caught and logged within the async task/job handler.
5.2 Logging:
- Redwood Logger: Use
src/lib/logger.tsconsistently.logger.info()for standard operations (sending message, receiving webhook).logger.warn()for non-critical issues (invalid input format, unexpected webhook event type, skipped signature verification).logger.error()for failures (Sinch API errors, DB errors, processing failures, signature verification errors). Include error objects and context.
- Log Levels: Configure log levels appropriately for different environments (e.g.,
debuglocally,infoorwarnin production) via environment variables (LOG_LEVEL). - Log Aggregation: In production, use a log aggregation service (e.g., Datadog, Logtail, Papertrail) to collect and analyze logs from your deployed functions/API.
5.3 Retry Mechanisms:
Implementing Exponential Backoff for Message Sending:
import retry from 'async-retry'
export const sendWhatsAppTextMessage = async ({
to,
text,
}: SendWhatsAppTextMessageInput): Promise<{ messageId: string }> => {
// Validation code...
const recipient: SinchMessageRecipient = {
identified_by: { channel: 'WHATSAPP', identity: to },
}
return await retry(
async (bail) => {
try {
const response = await sinchClient.conversation.messages.send({
app_id: sinchAppId,
recipient: recipient,
message: { text_message: { text: text } },
channel_priority_order: ['WHATSAPP'],
})
if (!response.message_id) {
throw new Error('No message ID received from Sinch.')
}
return { messageId: response.message_id }
} catch (error) {
// Don't retry on permanent errors
if (error.response?.status === 400 || error.response?.status === 401) {
bail(error)
return
}
logger.warn({ error, attempt: error.attemptNumber }, 'Retry attempt failed')
throw error
}
},
{
retries: 3,
factor: 2,
minTimeout: 1000,
maxTimeout: 5000,
onRetry: (error, attempt) => {
logger.info({ attempt, error: error.message }, 'Retrying send operation')
},
}
)
}Retry Strategy Breakdown:
| Attempt | Wait Time | Total Elapsed |
|---|---|---|
| 1 | 0ms | 0ms |
| 2 | 1,000ms | 1,000ms |
| 3 | 2,000ms | 3,000ms |
| 4 | 4,000ms | 7,000ms |
- Sending Messages:
- Sinch handles some network retry internally.
- For application-level errors (e.g., temporary Sinch outage, rate limiting), implement a retry strategy with exponential backoff before failing the GraphQL mutation. Libraries like
async-retryhelp. Don't retry indefinitely or for errors that won't resolve (e.g., invalid number).
- Receiving Webhooks:
- Sinch retries sending webhooks if your endpoint doesn't return a
2xxstatus code within a timeout. This covers temporary network issues, deployment glitches, or signature verification failures. - For errors during the asynchronous
processIncomingWhatsAppMessage(after acknowledging with200 OK), Sinch won't retry. A job queue becomes essential for reliability:- Robust (Recommended): Use a dedicated job queue (e.g., BullMQ, Redis SMQ, AWS SQS). The webhook handler validates the request (signature, basic format) and adds a job to the queue. A separate worker process handles
processIncomingWhatsAppMessagewith retry logic managed by the queue system. This prevents message loss if processing logic fails intermittently. - Basic (
setImmediate): The current example usessetImmediate. This is not reliable for production as processing errors after the200 OKresponse are lost unless manually handled with extreme care. Upgrade to a job queue.
- Robust (Recommended): Use a dedicated job queue (e.g., BullMQ, Redis SMQ, AWS SQS). The webhook handler validates the request (signature, basic format) and adds a job to the queue. A separate worker process handles
- Sinch retries sending webhooks if your endpoint doesn't return a
Monitoring and Alerting Setup:
Option 1: Datadog Integration
import { datadogLogs } from '@datadog/browser-logs'
datadogLogs.logger.error('Sinch API error', {
error: error.message,
recipient: to,
environment: process.env.NODE_ENV,
})Option 2: Sentry Integration
import * as Sentry from '@sentry/node'
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
})
// In your error handler:
Sentry.captureException(error, {
tags: { service: 'whatsapp', operation: 'send_message' },
extra: { recipient: to, messageText: text },
})Testing Error Scenarios:
- Provide invalid API keys in
.env. - Try sending to an invalid or non-WhatsApp phone number.
- Temporarily disable the webhook endpoint or make it return errors to test Sinch retries.
- Send an invalid signature with a webhook request (using
curl) to test verification. - Introduce errors in
processIncomingWhatsAppMessage(e.g., database connection issue) to observe logging and potential message loss without a queue.
Frequently Asked Questions (FAQ)
Can I use the Sinch Node.js SDK in my React or Vue.js frontend?
No. Do not use @sinch/sdk-core in front-end applications (React, Vue.js, Angular, etc.). Using the SDK in client-side code exposes your Sinch API credentials to end-users, creating a critical security vulnerability. Use the SDK only in backend/server-side code like RedwoodJS API services and functions (source: Sinch SDK GitHub).
What Node.js version should I use for Sinch WhatsApp integration?
Use Node.js 20.x LTS (recommended) or 22.x for production applications. Node.js 18.x reaches End of Life (EOL) on May 14, 2025 – do not use for new projects. The Sinch SDK (@sinch/sdk-core@1.3.0) requires Node.js 20.x or later (source: Node.js Release Schedule, Sinch SDK GitHub).
Do I need a paid Sinch account to send WhatsApp messages?
Yes. WhatsApp integration through Sinch requires a postpay (paid) account. Trial accounts have limitations and may not support the WhatsApp channel.
Approximate Cost Ranges:
- US/Canada: $0.005–$0.008 per message
- Europe: $0.008–$0.012 per message
- Asia-Pacific: $0.010–$0.015 per message
- Latin America: $0.012–$0.020 per message
Most accounts include $10–$25 monthly credit. Sign up for a postpay account at Sinch.com to access full WhatsApp Business API functionality.
How do I secure my webhook endpoint against unauthorized requests?
Implement webhook signature verification using a shared secret. Configure SINCH_WEBHOOK_SECRET in your .env file and add the same secret to your Sinch App webhook configuration. The example code in Section 2.2 includes HMAC-SHA256 signature verification using createHmac and timingSafeEqual to prevent timing attacks and ensure requests genuinely originate from Sinch.
What happens if my server crashes while processing an incoming WhatsApp message?
With the basic setImmediate implementation shown in this guide, messages may be lost if processing fails after acknowledging the webhook with 200 OK. For production reliability, use a job queue system (BullMQ, Redis SMQ, AWS SQS) to decouple acknowledgment from processing. Queue-based approaches provide retry logic, persistence, and graceful failure handling.
Can I send media (images, documents) via WhatsApp using this integration?
Yes. The Sinch Conversation API supports sending media messages including images, documents, videos, and audio files.
Example: Sending an Image Message
export const sendWhatsAppImage = async ({
to,
imageUrl,
caption,
}: SendWhatsAppImageInput): Promise<{ messageId: string }> => {
const recipient: SinchMessageRecipient = {
identified_by: { channel: 'WHATSAPP', identity: to },
}
const response = await sinchClient.conversation.messages.send({
app_id: sinchAppId,
recipient: recipient,
message: {
media_message: {
url: imageUrl,
caption: caption,
},
},
channel_priority_order: ['WHATSAPP'],
})
return { messageId: response.message_id }
}Extend the sendWhatsAppTextMessage service function to support media_message types with url parameters pointing to publicly accessible media files. Refer to the Sinch Conversation API documentation for media message schemas.
How do I test WhatsApp webhooks during local development?
Use ngrok to expose your local RedwoodJS API server (default port 8911) to the internet. Run ngrok http 8911 to get a public HTTPS URL, then configure this URL in your Sinch App webhook settings: https://YOUR-NGROK-URL.ngrok.io/.redwood/functions/sinchWebhook. Update the webhook URL when deploying to production.
What's the difference between Sinch Conversation API and direct WhatsApp Business API?
The Sinch Conversation API provides a unified interface for multiple messaging channels (WhatsApp, SMS, RCS, Viber, etc.) and handles infrastructure complexity, carrier relationships, and compliance requirements. Direct WhatsApp Business API integration requires managing Meta partnerships, infrastructure, and channel-specific implementations. Sinch simplifies this to a single API with potential for multi-channel expansion with minimal code changes.
How do I handle WhatsApp rate limits?
WhatsApp imposes messaging tier limits based on your phone number's quality rating:
| Tier | Daily Message Limit | Requirement |
|---|---|---|
| Tier 1 | 1,000 messages | New phone numbers start here |
| Tier 2 | 10,000 messages | Maintain quality rating |
| Tier 3 | 100,000 messages | Maintain quality rating |
| Unlimited | No limit | High quality rating history |
Implement rate limiting in your application:
import Bottleneck from 'bottleneck'
const limiter = new Bottleneck({
maxConcurrent: 5,
minTime: 100, // 100ms between messages = max 10/second
})
export const sendWhatsAppTextMessage = limiter.wrap(async ({
to,
text,
}: SendWhatsAppTextMessageInput) => {
// Existing implementation...
})Do I need message templates for WhatsApp Business?
Yes, for user-initiated conversations starting after 24 hours of no contact. WhatsApp requires pre-approved message templates for business-initiated conversations. During the 24-hour customer service window (after a user messages you), you can send free-form messages.
Template Message Example:
const response = await sinchClient.conversation.messages.send({
app_id: sinchAppId,
recipient: recipient,
message: {
template_message: {
channel_template: {
WHATSAPP: {
template_id: 'your_approved_template_id',
language_code: 'en',
parameters: {
'1': 'John Doe',
'2': '12345',
},
},
},
},
},
channel_priority_order: ['WHATSAPP'],
})What are WhatsApp Business API compliance requirements?
Key compliance requirements:
- Opt-in Required: Users must explicitly opt in to receive messages
- 24-Hour Window: Free-form messages only allowed within 24 hours of user message
- Template Approval: Business-initiated messages require pre-approved templates
- Quality Rating: Maintain high quality rating (low block/report rates)
- No Spam: Follow WhatsApp Commerce and Business Policy
- Data Privacy: Comply with GDPR, CCPA, and regional data protection laws
Non-compliance can result in:
- Rate limit reduction
- Phone number suspension
- Account termination
Related Resources
- Sinch Conversation API Documentation – Official API reference and guides
- Sinch Node.js SDK GitHub – SDK source code and examples
- RedwoodJS Documentation – Full-stack framework documentation
- RedwoodJS Community Forum – Ask questions and share solutions
- WhatsApp Business Platform – Official WhatsApp Business guidelines
- WhatsApp Business Policy – Compliance requirements
- Node.js Release Schedule – LTS versions and EOL dates
- GraphQL Best Practices – Query optimization and schema design
- Prisma Documentation – Database ORM and migration guides
- BullMQ Documentation – Job queue for production reliability
- Example Repository: RedwoodJS WhatsApp Integration – Complete working example
- Video Tutorial: WhatsApp Integration with RedwoodJS – Step-by-step walkthrough
Frequently Asked Questions
How to send WhatsApp messages using RedwoodJS?
You can send WhatsApp messages by creating a GraphQL mutation in your RedwoodJS application that calls a service function. This service function utilizes the Sinch Conversation API and Node.js SDK to send text messages via an authenticated API endpoint. The mutation requires the recipient's phone number and the message text as input.
What is the Sinch Conversation API?
The Sinch Conversation API is a unified API provided by Sinch, allowing interaction with various messaging channels, including WhatsApp. It simplifies the complexities of the WhatsApp Business API and enables potential expansion to other messaging channels like SMS and RCS in the future.
Why use Sinch with RedwoodJS for WhatsApp integration?
Sinch provides a managed WhatsApp Business API solution, handling infrastructure and compliance. This simplifies integration compared to working directly with Meta's API. RedwoodJS offers a structured, full-stack JavaScript framework, making development and deployment easier.
When should I use a job queue for WhatsApp messages?
Using a job queue like BullMQ, Redis SMQ, or AWS SQS is crucial for production reliability when handling incoming WhatsApp messages. A queue ensures messages aren't lost if processing fails after acknowledging receipt of the webhook from Sinch.
Can I receive WhatsApp messages in my RedwoodJS app?
Yes, you can receive WhatsApp messages by setting up a webhook handler (a RedwoodJS function) to process incoming messages sent to your dedicated Sinch number. The handler parses the message content and logs it in your database.
How to set up Sinch with RedwoodJS project?
First, install the Sinch Node.js SDK in your RedwoodJS API side. Then, configure environment variables for your Sinch Project ID, Key ID, Key Secret, App ID, WhatsApp Sender ID, and Webhook Secret. Finally, initialize a reusable Sinch client instance using these credentials in `api/src/lib/sinch.ts`.
What is the purpose of RedwoodJS services?
RedwoodJS services encapsulate the core business logic of your application, separating concerns from other parts of your codebase. In this WhatsApp integration, the service handles the interaction with the Sinch SDK and database operations for storing messages.
How to verify Sinch webhook signatures in RedwoodJS?
Use the 'crypto' module's `createHmac` and `timingSafeEqual` functions to verify the signature of incoming webhook requests. Compare the signature with a hash generated using your `SINCH_WEBHOOK_SECRET` to ensure requests genuinely originate from Sinch.
What is the RedwoodJS auth provider header for?
The auth provider header, such as 'auth-provider: dbAuth', signifies the authentication method used in your RedwoodJS application. Its usage depends on the specific provider, like 'dbAuth', 'clerk', or 'supabase', and is needed when making authenticated requests to your GraphQL API.
What's the recommended Node.js version for this integration?
The guide recommends using Node.js version 18.x or later for compatibility with the RedwoodJS framework and Sinch Node.js SDK.
How to handle different WhatsApp message types?
The `processIncomingWhatsAppMessage` function provides basic handling for text messages. For media messages (images, videos), the provided example logs the message, however, you'll have to write custom logic for their processing in the service. Consider adding more sophisticated handling for locations and other message types in your application as needed.
Why does the Sinch integration require a postpay account?
The WhatsApp channel through the Sinch Conversation API requires a postpay account due to the associated costs and billing mechanisms for sending and receiving WhatsApp messages. Trial accounts typically have limitations that prevent WhatsApp integration.
What information is logged when receiving WhatsApp messages?
The integration logs key information about incoming messages, including the sender's phone number, the message content (text or a placeholder for media), the Sinch Message ID, the Sinch Contact ID, the webhook payload and timestamps.
Where can I find my Sinch Project ID and Key ID?
You can find your Sinch Project ID and Key ID in the Sinch Customer Dashboard under 'Access Keys' -> 'API Credentials'. The Key Secret is shown only once during creation, so store it securely.