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.
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.
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
Yarn: Package manager (used by RedwoodJS).
Verify installation: yarn --version
RedwoodJS CLI:npm install -g @redwoodjs/cli
Verify installation: rw --version
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:
Bash
yarn create redwood-app ./redwood-sinch-whatsapp --typescript
cd redwood-sinch-whatsapp
This scaffolds a new RedwoodJS project in the redwood-sinch-whatsapp directory.
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.
Bash
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:
Bash
touch .env
Add 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:
Bash
# On macOS/Linux:
openssl rand -base64 32
# Or use Node.js:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
.env
# .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_SECRET
Explanation 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
TypeScript
// api/src/lib/sinch.tsimport { 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
) {
thrownewError(
'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.exportconst sinchClient = newSinchClient({
// 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 convenienceexportconst sinchAppId = process.env.SINCH_APP_IDexportconst whatsappSenderId = process.env.WHATSAPP_SENDER_IDif (!sinchAppId) {
thrownewError('SINCH_APP_ID is missing in .env')
}
if (!whatsappSenderId) {
thrownewError('WHATSAPP_SENDER_ID is missing in .env')
}
console.log('Sinch Client Initialized.') // Log confirmation during startup
Why 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:
Bash
yarn rw g service whatsapp
This creates api/src/services/whatsapp/whatsapp.ts and related files.
Modify api/src/services/whatsapp/whatsapp.ts:
TypeScript
// api/src/services/whatsapp/whatsapp.tsimporttype { SendWhatsAppTextMessageInput } from'types/graphql'import { sinchClient, sinchAppId, whatsappSenderId } from'src/lib/sinch'import { logger } from'src/lib/logger'import { db } from'src/lib/db'interfaceSinchMessageRecipient {
contact_id?: stringidentified_by?: {
channel: 'WHATSAPP'identity: string// E.164 phone numberapp_id?: string
}
}
/**
* Sends a WhatsApp text message via the Sinch Conversation API.
*/exportconst sendWhatsAppTextMessage = async ({
to,
text,
}: SendWhatsAppTextMessageInput): Promise<{ messageId: string }> => {
logger.info({ to, text }, 'Attempting to send WhatsApp text message')
// Validate inputif (!to || !text) {
thrownewError('Recipient phone number (to) and message text are required.')
}
if (!/^\+\d{10,15}$/.test(to)) {
logger.warn({ to }, 'Invalid phone number format provided.')
thrownewError(
'Invalid recipient phone number format. Use E.164 format (e.g., +15551234567).'
)
}
if (text.length > 4096) {
thrownewError('Message text exceeds WhatsApp limit of 4,096 characters.')
}
// Construct the recipient objectconstrecipient: 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')
thrownewError('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 codesif (error.response?.data) {
logger.error({ sinchError: error.response.data }, 'Sinch API Error Details')
// Common error patternsconst errorCode = error.response.data.codeif (errorCode === 'unauthorized') {
thrownewError('Invalid Sinch API credentials. Check your .env configuration.')
}
if (errorCode === 'invalid_argument') {
thrownewError('Invalid phone number or message format.')
}
}
thrownewError(`Failed to send WhatsApp message: ${error.message}`)
}
}
/**
* Processes an incoming WhatsApp message received via webhook.
*/exportconst processIncomingWhatsAppMessage = async (payload: any): Promise<void> => {
logger.info({ payload }, 'Processing incoming Sinch webhook payload')
// Validate it's an inbound message eventif (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 typesletmessageText: string | null = null;
if (contactMessage?.text_message) {
messageText = contactMessage.text_message.text;
} elseif (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 presentif (!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.identityconst sinchMessageId = message.message_idconst sinchContactId = message.contact_id
logger.info({ fromNumber, messageText, sinchMessageId, sinchContactId }, 'Parsed incoming WhatsApp message')
try {
// Store the incoming message in the databaseawait db.messageLog.create({
data: {
direction: 'INBOUND',
recipient: fromNumber,
status: 'RECEIVED',
sinchMessageId: sinchMessageId,
sinchContactId: sinchContactId,
body: messageText,
receivedAt: newDate(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...catch blocks 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:
Implement rate limiting on the webhook endpoint to prevent abuse:
Option 1: API Gateway Rate Limiting (AWS)
JavaScript
// serverless.yml or AWS Consolefunctions:
sinchWebhook:
handler: api/src/functions/sinchWebhook.handlerevents:
- http:
path: /.redwood/functions/sinchWebhook
method: post
throttling:
maxRequestsPerSecond: 100maxConcurrentRequests: 50
Option 2: In-Memory Rate Limiting (Simple)
TypeScript
import rateLimit from'express-rate-limit'const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minutemax: 100, // limit each IP to 100 requests per windowMsmessage: '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 timingSafeEqual to prevent timing attacks.
Asynchronous Processing: Uses setImmediate for 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):
GraphQL
# 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!}typeMutation{"""
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:
GraphQL
# Example: Restrict to specific rolestypeMutation{
sendWhatsAppTextMessage(input: SendWhatsAppTextMessageInput!): WhatsAppSendResponse!@requireAuth(roles:["ADMIN", "SUPPORT"])}
Service-Level Authorization:
TypeScript
// api/src/services/whatsapp/whatsapp.tsimport { requireAuth } from'src/lib/auth'exportconst sendWhatsAppTextMessage = async ({
to,
text,
}: SendWhatsAppTextMessageInput): Promise<{ messageId: string }> => {
// Verify user has required rolerequireAuth({ roles: ['ADMIN', 'SUPPORT'] })
// Rest of the function...
}
Note: Configure authentication in your RedwoodJS app for @requireAuth to work. Run yarn 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.
Bash
# 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!"
}
}
}' \
--compressed
{"errors":[{"message":"Invalid recipient phone number format. Use E.164 format (e.g., +15551234567).","extensions":{"code":"BAD_USER_INPUT"}}]}
Authentication Failed:
JSON
{"errors":[{"message":"You are not authenticated","extensions":{"code":"UNAUTHENTICATED"}}]}
Sinch API Error:
JSON
{"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.
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 .env file:
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.
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.io forwarding URL. Your webhook URL: https://******.ngrok.io/.redwood/functions/sinchWebhook
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_DELIVERY if 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 the SINCH_WEBHOOK_SECRET environment 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:
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.ts service 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.ts function catches errors during payload parsing and signature verification. Log errors thoroughly. Since it processes asynchronously (especially with a queue), ensure unhandled promise rejections in processIncomingWhatsAppMessage are caught and logged within the async task/job handler.
5.2 Logging:
Redwood Logger: Use src/lib/logger.ts consistently.
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., debug locally, info or warn in 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:
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-retry help. 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 2xx status code within a timeout. This covers temporary network issues, deployment glitches, or signature verification failures.
For errors during the asynchronous processIncomingWhatsAppMessage (after acknowledging with 200 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 processIncomingWhatsAppMessage with retry logic managed by the queue system. This prevents message loss if processing logic fails intermittently.
Basic (setImmediate): The current example uses setImmediate. This is not reliable for production as processing errors after the 200 OK response are lost unless manually handled with extreme care. Upgrade to a job queue.
Monitoring and Alerting Setup:
Option 1: Datadog Integration
TypeScript
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
TypeScript
import * asSentryfrom'@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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The guide recommends using Node.js version 18.x or later for compatibility with the RedwoodJS framework and Sinch Node.js SDK.
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.
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.
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.
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.
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:
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:
Technologies Used:
@sinch/sdk-core).Why These Technologies?
System Architecture Diagram:
Prerequisites:
node --versionyarn --versionnpm install -g @redwoodjs/clirw --versionSecurity 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-whatsappdirectory.Troubleshooting Common Setup Issues:
npm install -g yarnsudoor fix npm permissions: NPM docsyarn installto ensure all dependencies are installed1.2 Install Sinch Node.js SDK:
Navigate to the API workspace directory and add the Sinch SDK package. Install this in the
apiside 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
.envfile 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'))"Explanation 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?
api/src/lib/sinch.ts) promotes reuse and follows RedwoodJS conventions for library code.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:
This creates
api/src/services/whatsapp/whatsapp.tsand 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:
to^\+\d{10,15}$textEdge Cases Handled:
Why This Approach?
try...catchblocks with specific error code handling.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?
timingSafeEqualto prevent timing attacks.setImmediatefor basic async handling, but strongly recommends using a job queue for production reliability.Building the GraphQL API Layer for WhatsApp
Expose the
sendWhatsAppTextMessagefunctionality 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
sendWhatsAppTextMessagemutation 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
@requireAuthdirective 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... }@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:
redwood-whatsapp-app-key)..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_KEYS4.2 Obtain Sinch App ID and WhatsApp Sender ID:
.envfile:SINCH_APP_ID=YOUR_SINCH_CONVERSATION_API_APP_ID.envfile:WHATSAPP_SENDER_ID=YOUR_WHATSAPP_PHONE_NUMBER_SENDER_ID_IN_E164_FORMAT(e.g.,+15551234567)4.3 Configure the Sinch Webhook:
sinchWebhook).https://your-deployed-app-domain.com/.redwood/functions/sinchWebhooknpm install -g ngrokyarn rw dev(API runs on port 8911 by default)ngrok http 8911https://******.ngrok.ioforwarding URL. Your webhook URL:https://******.ngrok.io/.redwood/functions/sinchWebhookMESSAGE_INBOUND(to receive messages from users)MESSAGE_DELIVERYif you want status updates (see Section 8).sinchWebhook.ts(as recommended), paste the exact same secure random string you used for theSINCH_WEBHOOK_SECRETenvironment variable into the Secret Token field here.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):
Production-Ready Error Handling and Logging
Production applications require robust error handling.
5.1 Consistent Error Handling:
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.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:
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.debuglocally,infoorwarnin production) via environment variables (LOG_LEVEL).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:
async-retryhelp. Don't retry indefinitely or for errors that won't resolve (e.g., invalid number).2xxstatus code within a timeout. This covers temporary network issues, deployment glitches, or signature verification failures.processIncomingWhatsAppMessage(after acknowledging with200 OK), Sinch won't retry. A job queue becomes essential for reliability:processIncomingWhatsAppMessagewith retry logic managed by the queue system. This prevents message loss if processing logic fails intermittently.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.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:
.env.curl) to test verification.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-corein 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:
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_SECRETin your.envfile and add the same secret to your Sinch App webhook configuration. The example code in Section 2.2 includes HMAC-SHA256 signature verification usingcreateHmacandtimingSafeEqualto 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
setImmediateimplementation shown in this guide, messages may be lost if processing fails after acknowledging the webhook with200 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
sendWhatsAppTextMessageservice function to supportmedia_messagetypes withurlparameters 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 8911to 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:
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:
Non-compliance can result in:
Related Resources