Learn how to integrate Sinch WhatsApp Business API with Next.js. Complete step-by-step tutorial covering message sending, webhooks, authentication, and production deployment with the Conversation API.
Build robust WhatsApp messaging into your Next.js application using the Sinch Conversation API. This tutorial shows you how to send and receive WhatsApp messages, handle webhooks, manage security, and deploy a production-ready solution.
You'll create a Next.js application with API routes that interact with the Sinch Conversation API to send outbound WhatsApp messages and process inbound messages via webhooks. By the end, you'll have a functional bidirectional integration ready for testing and deployment.
What Will You Build with This Tutorial?
Objective: Implement WhatsApp messaging within your Next.js application to send outbound messages and receive inbound messages via the Sinch Conversation API.
Problem Solved: Programmatically communicate with users on WhatsApp for notifications, customer support, alerts, order updates, and marketing – directly from your web application.
Technologies & Requirements (Verified January 2025):
Next.js 13+: React framework for full-stack web applications. This guide uses the App Router (introduced in Next.js 13, stable in Next.js 14). Use Next.js 14 or Next.js 15 for the latest features and stability. The App Router provides modern API routes with improved performance.
Node.js 18.18.0+: JavaScript runtime environment. Use Node.js 20 LTS "Iron" (Maintenance LTS until April 2026) or Node.js 22 LTS "Jod" (Active LTS until October 2027). Node.js 18 LTS "Hydrogen" ended support in March 2025.
Sinch Conversation API: Unified API for managing conversations across multiple channels, including WhatsApp Business API. Includes template messages, media handling, and delivery tracking.
WhatsApp Business API: Meta's official business messaging platform. Requires an approved WhatsApp Business Account through Sinch's embedded signup. WhatsApp enforces a 24-hour messaging window for session messages. Use template messages to initiate conversations or message users outside this window.
System Architecture:
End user's WhatsApp client communicates with the WhatsApp Business API platform
WhatsApp platform relays messages to and from Sinch Conversation API
Your Next.js application interacts with Sinch API to send messages (via HTTP) and receive messages/events (via webhooks)
Your application logic triggers outbound messages and optionally stores message history in a database
Final Outcome – A Next.js Application With:
API endpoint (/api/send-whatsapp) to send WhatsApp messages via Sinch
API endpoint (/api/sinch-webhook) to receive incoming message notifications from Sinch
Secure credential handling and comprehensive error handling
Production deployment instructions and testing guidance
Prerequisites:
Node.js 18.18.0+ (LTS version recommended) and npm or yarn installed. Verify with node --version
Sinch account with Conversation API access. Enable postpay billing to send messages beyond initial free credits or for production volumes. Trial accounts work for initial testing within limits. Contact Sinch support for billing details.
Configured Sinch Conversation API App (covered in this guide)
Provisioned WhatsApp Business Sender ID via Sinch using Embedded Signup process
Familiarity with JavaScript, React, Next.js App Router, and REST APIs
Understanding of E.164 phone number format (international format with + prefix, e.g., +15551234567)
Technology Verification Date: All technology versions and requirements verified as of January 2025. Check official documentation for latest releases.
How Do You Set Up Your Next.js Project for Sinch WhatsApp?
Initialize your Next.js project and configure environment variables for Sinch integration.
Creating Your Next.js App with App Router
Open your terminal and run:
Bash
npx create-next-app@latest sinch-whatsapp-nextjs
# Follow the prompts (e.g., select TypeScript: No, ESLint: Yes, Tailwind CSS: No, `src/` directory: No, App Router: Yes (Recommended), Import alias: No)cd sinch-whatsapp-nextjs
How to Configure Sinch Environment Variables Securely
Sinch requires several credentials. Store them securely in environment variables. Create a .env.local file in your project root.
Never commit .env.local to version control. Add it to .gitignore if it's not already there.
Important: Replace all YOUR_... placeholders with your actual credentials from the Sinch dashboard (explained later).
Text
# .env.local
# Sinch Project & API Access Credentials
# Found in Sinch Dashboard -> Your Project -> API Keys
SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID
SINCH_KEY_ID=YOUR_SINCH_ACCESS_KEY_ID
SINCH_KEY_SECRET=YOUR_SINCH_ACCESS_KEY_SECRET
# Sinch Conversation API App Credentials
# Found in Sinch Dashboard -> Conversation API -> Your App
SINCH_APP_ID=YOUR_CONVERSATION_APP_ID
# Typically 'us', 'eu', or 'br' - Match your Conversation API App region
SINCH_REGION=us
# Sinch WhatsApp Channel Credentials (Specific to your configured WhatsApp Sender)
# Found during WhatsApp Sender setup or in App Configuration
SINCH_WHATSAPP_SENDER_ID=YOUR_WHATSAPP_SENDER_ID # Often the phone number in E.164 format
# Note: This token is essential for configuring the WhatsApp channel in the Sinch App (dashboard setup).
# The API calls in this guide use Basic Authentication derived from SINCH_KEY_ID/SECRET for sending.
SINCH_WHATSAPP_BEARER_TOKEN=YOUR_WHATSAPP_SENDER_BEARER_TOKEN # Provided during sender setup
# Application URL (Needed for Webhook setup in Sinch Dashboard)
# Use your deployed URL in production. For local dev, use an ngrok URL.
NEXT_PUBLIC_APP_URL=http://localhost:3000 # Replace with deployment or ngrok URL later
Purpose: Store sensitive credentials outside your codebase for security. Next.js automatically loads .env.local variables into process.env.
Obtaining Values: The "Get Your Sinch Credentials" section explains how to find these values in the Sinch Customer Dashboard.
NEXT_PUBLIC_APP_URL: Set this to your application's publicly accessible base URL. Sinch uses this to send webhooks. The NEXT_PUBLIC_ prefix follows Next.js convention.
Understanding the Next.js App Router Project Structure
You'll work primarily in the app/api/ directory for backend logic.
Code
sinch-whatsapp-nextjs/
├── app/
│ ├── api/
│ │ ├── send-whatsapp/
│ │ │ └── route.js # Endpoint to send messages
│ │ └── sinch-webhook/
│ │ └── route.js # Endpoint to receive webhooks
│ ├── layout.js
│ └── page.js # Optional simple UI for testing
├── .env.local # Your secret credentials (DO NOT COMMIT)
├── .env.local.example # Example structure for others (Commit this)
├── .gitignore
├── next.config.js
├── package.json
└── README.md
Architectural Decision: Next.js API routes keep backend logic colocated with your frontend, simplifying development and deployment. The App Router provides a modern, streamlined approach.
How Do You Send WhatsApp Messages with Sinch Conversation API?
Create the API endpoint that sends outbound WhatsApp messages via Sinch.
Understanding WhatsApp Message Types
WhatsApp enforces specific rules for message types:
Message Type
Use Case
Requirements
Session Messages (text_message)
Reply within 24 hours of user's last message
No pre-approval needed; allows free-form text and media
Template Messages (template_message)
Initiate conversations or message outside 24-hour window
Must be pre-approved by WhatsApp; strict formatting with placeholders
When to use each type:
Use text_message for ongoing conversations where the user messaged you recently (within 24 hours)
Use template_message to:
Start new conversations
Send scheduled reminders
Reach users who haven't messaged in 24+ hours
Send notifications, alerts, or marketing (with user opt-in)
File:app/api/send-whatsapp/route.js
JavaScript
// app/api/send-whatsapp/route.jsimport { NextResponse } from'next/server';
// Basic Authentication Token Generation (cache in production)functiongetSinchAuthToken() {
const keyId = process.env.SINCH_KEY_ID;
const keySecret = process.env.SINCH_KEY_SECRET;
if (!keyId || !keySecret) {
thrownewError("Sinch Key ID or Secret not configured in environment variables.");
}
const credentials = `${keyId}:${keySecret}`;
return`Basic ${Buffer.from(credentials).toString('base64')}`;
// Note: For production, consider Sinch OAuth for better security & rotation// See: https://developers.sinch.com/docs/conversation/authentication/
}
exportasyncfunctionPOST(request) {
const { to, text } = await request.json();
// --- 1. Input Validation ---if (!to || !text) {
returnNextResponse.json({ error: 'Missing "to" phone number or "text" message.' }, { status: 400 });
}
// Basic E.164 format check (adjust regex as needed for stricter validation)if (!/^\+\d{11,15}$/.test(to)) {
returnNextResponse.json({ error: 'Invalid "to" phone number format. Use E.164 (e.g., +15551234567).' }, { status: 400 });
}
const projectId = process.env.SINCH_PROJECT_ID;
const sinchAppId = process.env.SINCH_APP_ID;
const senderId = process.env.SINCH_WHATSAPP_SENDER_ID; // Used in payload, ensure it's setconst region = process.env.SINCH_REGION || 'us'; // Default to 'us' if not setif (!projectId || !sinchAppId || !senderId) {
returnNextResponse.json({ error: 'Sinch Project ID, App ID, or Sender ID not configured.' }, { status: 500 });
}
const sinchApiUrl = `https://${region}.conversation.api.sinch.com/v1/projects/${projectId}/messages:send`;
const payload = {
app_id: sinchAppId,
recipient: {
contact_id: to // Using contact_id for direct E.164 number sending// Alternatively, use channel_identity for specific channel/number pair:// channel_identity: { channel: 'WHATSAPP', identity: to }
},
message: {
text_message: {
text: text
}
// For Template Messages, use:// template_message: {// template_id: "your_template_id", // The registered template name/ID// version: "your_template_version", // e.g., "1"// language_code: "en_US", // Or relevant language// parameters: { /* template parameters if any */ }// }
},
channel_priority_order: ["WHATSAPP"] // Ensure WhatsApp is prioritized
};
try {
// --- 2. API Call to Sinch ---const response = awaitfetch(sinchApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': getSinchAuthToken() // Use Basic Auth or OAuth
},
body: JSON.stringify(payload),
});
const responseData = await response.json();
// --- 3. Response Handling ---if (!response.ok) {
console.error('Sinch API Error:', response.status, responseData);
returnNextResponse.json(
{ error: 'Failed to send message via Sinch.', details: responseData },
{ status: response.status }
);
}
console.log('Sinch Send Success:', responseData);
returnNextResponse.json({ success: true, messageId: responseData.message_id });
} catch (error) {
// --- 4. Error Handling ---console.error('Error sending WhatsApp message:', error);
returnNextResponse.json({ error: 'Internal server error.' }, { status: 500 });
}
}
Code Walkthrough:
Input Validation: Validates that to (recipient phone number in E.164 format) and text fields exist in the request body. Includes basic E.164 format checking.
Authentication: The getSinchAuthToken function generates the Basic Authentication header using your Key ID and Secret. For production, implement Sinch's OAuth 2.0 flow for more secure, short-lived tokens.
Payload Construction: Builds the JSON payload according to the Sinch Conversation API /messages:send endpoint specification. Includes the target app, recipient (using contact_id), message content (text_message), and prioritizes the WHATSAPP channel. Comments show how to structure a template_message (required outside the 24-hour window or for initiating conversations).
API Call: Uses the native fetch API to send the POST request to the appropriate regional Sinch API endpoint.
Response Handling: Checks if the API call succeeded (response.ok). Logs error details returned by Sinch on failure and sends an appropriate error response to the client. On success, logs the result and returns the message_id.
Error Handling: A try...catch block handles network errors or unexpected issues during the process.
How Do You Receive WhatsApp Messages Using Sinch Webhooks?
Sinch needs a publicly accessible endpoint to send incoming messages and delivery status updates via POST requests (webhooks).
File:app/api/sinch-webhook/route.js
JavaScript
// app/api/sinch-webhook/route.jsimport { NextResponse } from'next/server';
exportasyncfunctionPOST(request) {
try {
const payload = await request.json();
console.log('Received Sinch Webhook');
console.log('Payload:', JSON.stringify(payload, null, 2)); // Log the full payload for inspection// --- 1. Identify Event Type & Process based on Payload Structure ---// Sinch uses different payload structures for different events.// Inspect payloads in the Sinch Dashboard or your logs to handle each relevant case.if (payload.message_inbound_event) {
// --- 2. Handle Incoming Messages ---const event = payload.message_inbound_event;
const message = event.message;
const contactId = event.contact_id; // Sender's phone number (usually)const appId = event.app_id;
console.log(`---> Incoming Message from ${contactId} in App ${appId}`);
if (message.text_message) {
console.log(` Text: ${message.text_message.text}`);
// TODO: Add your logic here:// - Store the message in a database// - Trigger automated replies// - Forward to a support system
} elseif (message.media_message) {
console.log(` Media URL: ${message.media_message.url}`);
// TODO: Handle incoming media (images, videos, etc.)
}
// Add handling for other message types (location, contacts, etc.) as needed
} elseif (payload.message_delivery_event) {
// --- 3. Handle Delivery Receipts ---const event = payload.message_delivery_event;
const messageId = event.message_id;
const status = event.status; // e.g., DELIVERED, FAILED, READconst reason = event.reason; // Reason for failure, if anyconsole.log(`---> Delivery Status for Message ${messageId}: ${status}`);
if (reason) {
console.log(` Reason: ${reason}`);
}
// TODO: Update message status in your database
} else {
// Log other event types you might receive but aren't explicitly handling yetconst eventType = Object.keys(payload)[0]; // Basic way to guess event type keyconsole.warn(`Received unhandled webhook event type: ${eventType || 'Unknown'}`);
}
// --- 4. Acknowledge Receipt ---// Respond quickly to Sinch to acknowledge receipt.// Process heavy logic asynchronously (e.g., using queues or background jobs).returnNextResponse.json({ success: true });
} catch (error) {
console.error('Error processing Sinch webhook:', error);
// Avoid sending detailed errors back in the webhook responsereturnNextResponse.json({ error: 'Webhook processing failed.' }, { status: 500 });
}
}
// --- Optional: GET handler for verification ---// Some services require a GET request check during webhook setup.// Sinch doesn't typically require this, but it's useful for health checks.exportasyncfunctionGET(request) {
console.log('Received GET request on webhook endpoint.');
returnNextResponse.json({ message: 'Sinch webhook endpoint is active.' });
}
Code Walkthrough:
Receive Payload: Parses the incoming JSON payload sent by Sinch.
Event Type Identification: Checks the payload structure (e.g., presence of message_inbound_event or message_delivery_event keys) to determine the event type. Inspect actual webhook payloads received from Sinch (viewable in the Sinch Dashboard or your logs) to confirm the exact structure and event types you need to handle.
Incoming Message Handling: For inbound messages, extracts key information like the sender (contact_id), message content (text_message, media_message, etc.), and logs it. Integrate your application logic here – save messages, trigger replies, etc.
Delivery Receipt Handling: For delivery events, extracts the message ID, status (DELIVERED, FAILED, READ), and potentially a failure reason. Use this for tracking message status.
Acknowledgement: Returns a 200 OK response (NextResponse.json({ success: true })) quickly. Sinch expects timely acknowledgement. Move time-consuming processing to asynchronous operations (e.g., job queue like BullMQ, or serverless functions) to prevent webhook timeouts.
Error Handling: Catches errors during processing and logs them, returning a generic 500 error to Sinch. Avoid sending detailed internal error messages.
GET Handler (Optional): A simple GET handler for manually checking if the endpoint is reachable and deployed correctly.
Security Note: For production, implement webhook signature verification to ensure incoming requests genuinely originate from Sinch. Sinch likely provides a mechanism (e.g., using a secret key to generate an HMAC signature in request headers). Consult the official Sinch Conversation API documentation for specific details on implementing webhook signature verification and add the necessary validation logic to this webhook handler. This is a critical security step.
How to Get Your Sinch Credentials and Configure WhatsApp
Obtain the necessary credentials and configure your Sinch App. Replace YOUR_... placeholders in .env.local with the actual values you obtain here.
Project ID: Navigate to "Settings" (usually bottom-left) → "Projects". Your Project ID is listed there. Copy this to SINCH_PROJECT_ID in your .env.local.
API Keys (Key ID & Secret):
Go to "Settings" → "API Keys".
Click "CREATE KEY".
Give it a descriptive name (e.g., "NextJS WhatsApp App Key").
Important: Copy the Key ID and Key Secret immediately and store them securely in your .env.local (SINCH_KEY_ID, SINCH_KEY_SECRET). The Key Secret is only shown once.
Conversation API Access & App:
Navigate to "Conversation API" in the left menu. If you haven't already, click "GET ACCESS" and agree to the terms.
Click "CREATE APP".
Give your app a name (e.g., "My NextJS WhatsApp App").
Select the appropriate Region (e.g., US, EU, BR). This must match the SINCH_REGION in your .env.local.
Click "CREATE".
On the app's overview page, find the App ID. Copy this to SINCH_APP_ID in your .env.local.
During this process, you'll link a phone number and obtain:
WhatsApp Sender ID: Usually the phone number in E.164 format (e.g., +15551234567). Copy this to SINCH_WHATSAPP_SENDER_ID.
Bearer Token (Access Token): A token specific to this WhatsApp Sender. Copy this to SINCH_WHATSAPP_BEARER_TOKEN. This token is needed for linking the sender in the dashboard setup.
How to Configure Your Sinch Conversation API App for WhatsApp
Navigate to your App: In the Sinch Dashboard, go to "Conversation API" → "Your Apps" and select the app you created.
Add WhatsApp Channel:
Scroll down to the "Channels" section.
Find "WhatsApp" and click "SET UP CHANNEL".
Select the Sender Identity (your WhatsApp Sender ID / phone number) from the dropdown list that you provisioned earlier.
The Bearer Token associated with that Sender ID should be automatically used or confirmed here as part of the channel configuration.
Click "SAVE".
Configure Webhooks:
Scroll to the "Webhooks" section.
Click "ADD WEBHOOK".
Target URL: Enter the publicly accessible URL for your webhook handler. This should be the value of NEXT_PUBLIC_APP_URL from your environment variables, followed by the API route path: ${NEXT_PUBLIC_APP_URL}/api/sinch-webhook.
For local development, use ngrok. Start ngrok http 3000 and use the provided https:// URL (e.g., https://your-ngrok-subdomain.ngrok-free.app/api/sinch-webhook). Update NEXT_PUBLIC_APP_URL in .env.local temporarily.
For production (e.g., Vercel), use the deployment URL (e.g., https://your-app.vercel.app/api/sinch-webhook). Ensure NEXT_PUBLIC_APP_URL is set correctly in your deployment environment variables.
Triggers: Select the events you want to receive. At a minimum, choose:
MESSAGE_INBOUND (for incoming messages)
MESSAGE_DELIVERY (for delivery receipts)
Add others like CONTACT_CREATE_EVENT, CONVERSATION_START_EVENT, etc., based on your needs.
Secret: Optionally add a secret token here. If you do, implement signature verification logic in your sinch-webhook route using this secret (refer to Sinch documentation).
Click "ADD".
Follow the dashboard navigation paths precisely to find these settings.
What Error Handling Best Practices Should You Implement?
Robust error handling is crucial for production WhatsApp messaging systems.
Error Handling Strategy
Component
Strategy
API Routes
Use try...catch blocks in both send-whatsapp and sinch-webhook routes
Validation
Validate input early (request bodies, phone number formats)
Sinch API Responses
Check response.ok and parse error details from the JSON body returned by Sinch on failure. Log these details.
Webhook Responses
Return 200 OK quickly. For processing errors, log internally and return a generic 500 error to Sinch without revealing internal details.
Logging Best Practices
Use console.log, console.warn, and console.error strategically.
Log key events: sending requests, receiving webhooks, successful operations, errors, and relevant data (like message IDs, contact IDs, error messages from Sinch).
In production, use a dedicated logging service (like Vercel Logs, Datadog, Sentry) for structured logging, searching, and alerting.
Retry Mechanisms
For transient network errors or temporary Sinch API issues (e.g., 5xx errors) when sending messages, implement a retry strategy.
Caution: Avoid retrying blindly for client-side errors (4xx errors like invalid input). Retry primarily for server-side issues (5xx errors) or network timeouts.
Example using async-retry library:
First, install the library:
Bash
npm install async-retry
Then, integrate it into your sending logic:
JavaScript
// Inside app/api/send-whatsapp/route.js (conceptual integration)import { NextResponse } from'next/server';
import retry from'async-retry';
// ... getSinchAuthToken function ...exportasyncfunctionPOST(request) {
// ... input validation ...// ... variable setup (projectId, sinchApiUrl, payload) ...try {
// Wrap the fetch call within the retry blockconst result = awaitretry(
async (bail, attempt) => {
console.log(`Attempting Sinch API call (attempt ${attempt})...`);
const response = awaitfetch(sinchApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': getSinchAuthToken()
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const status = response.status;
// Don't retry on client errors (4xx)if (status >= 400 && status < 500) {
// Use bail to stop retrying and propagate the error immediatelyconst errorData = await response.json();
const clientError = newError(`Sinch API client error: ${status}`);
clientError.originalStatus = status;
clientError.details = errorData;
bail(clientError); // Stop retryingreturn; // Bail prevents further execution here
}
// For server errors (5xx) or other network issues, throw to trigger retryconst serverError = newError(`Sinch API server error or network issue: ${status}`);
serverError.originalStatus = status;
try { serverError.details = await response.json(); } catch { /* ignore parsing error */ }
throw serverError; // Trigger retry
}
// If response is OK, parse and return the dataconst responseData = await response.json();
console.log('Sinch Send Success within retry block:', responseData);
return responseData; // Return successful data
},
{
retries: 3, // Number of retriesfactor: 2, // Exponential backoff factorminTimeout: 1000, // Initial timeout in ms (1 second)onRetry: (error, attempt) => {
console.warn(`Retrying Sinch API call (attempt ${attempt}) due to: ${error.message}`);
}
}
);
// If retry succeeded, 'result' holds the responseDatareturnNextResponse.json({ success: true, messageId: result.message_id });
} catch (error) {
// This catches errors after all retries failed, or if bail was calledconsole.error('Error sending WhatsApp message after retries:', error.message);
// Log details if available (e.g., from clientError or last serverError)const status = error.originalStatus || 500;
const details = error.details || null;
returnNextResponse.json(
{ error: 'Failed to send message via Sinch after retries.', details: details },
{ status: status }
);
}
}
Integrate this retry logic carefully into your existing send-whatsapp route, replacing the simple fetch call.
Should You Store WhatsApp Message History in a Database?
While not required for basic sending/receiving, storing message history or contact information is common. Prisma is a popular choice with Next.js for database operations.
For a complete guide on implementing database storage with Prisma, including schema design and integration examples, see our Next.js database integration tutorial.
How to Set Up Prisma for Message Storage
Bash
npm install prisma --save-dev
npm install @prisma/client
npx prisma init --datasource-provider postgresql # Or your preferred DB
Configure your database connection URL in .env.
Example Database Schema for WhatsApp Messages
File: prisma/schema.prisma
Prisma
// prisma/schema.prisma
datasource db {
provider = "postgresql" // Or "mysql", "sqlite", "sqlserver", "mongodb"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Contact {
id String @id @default(cuid())
phone String @unique // E.164 format
name String?
createdAt DateTime @default(now())
messages Message[]
}
model Message {
id String @id @default(cuid())
sinchMessageId String? @unique // ID from Sinch API response/webhook
contactId String
contact Contact @relation(fields: [contactId], references: [id])
appId String? // Sinch App ID associated with the message
direction String // "INBOUND" or "OUTBOUND"
channel String // e.g., "WHATSAPP"
status String? // e.g., "SENT", "DELIVERED", "READ", "FAILED", "RECEIVED"
content Json // Store text, media URLs, templates etc.
timestamp DateTime // Time of the event (from Sinch or DB creation)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
reason String? // Failure reason from delivery receipt
}
Apply Schema & Generate Client
Bash
npx prisma migrate dev --name init # Creates migration and applies to DB
npx prisma generate # Generates Prisma Client
Usage in API Routes
JavaScript
// Example in app/api/sinch-webhook/route.jsimport { PrismaClient } from'@prisma/client';
const prisma = newPrismaClient();
// ... inside POST function, after parsing payload ...if (payload.message_inbound_event) {
const event = payload.message_inbound_event;
const messageData = event.message;
const contactPhone = event.contact_id; // Assuming this is the E.164 phoneconst sinchMsgId = event.message_id; // Check if inbound events provide a unique ID// Perform DB operations asynchronously (move outside the immediate response if heavy)try {
await prisma.message.create({
data: {
sinchMessageId: sinchMsgId, // Ensure this is unique or handle conflictscontact: {
connectOrCreate: {
where: { phone: contactPhone },
create: { phone: contactPhone },
},
},
appId: event.app_id,
direction: 'INBOUND',
channel: event.channel, // Make sure channel is in the event payloadcontent: messageData, // Store the whole message objecttimestamp: newDate(event.accepted_time), // Use Sinch timestampstatus: 'RECEIVED', // Custom status
},
});
console.log(`Saved inbound message ${sinchMsgId} from ${contactPhone}`);
} catch (dbError) {
console.error('Database error saving inbound message:', dbError);
// Decide how to handle DB errors – perhaps queue for later retry?
}
} elseif (payload.message_delivery_event) {
const event = payload.message_delivery_event;
const sinchMsgId = event.message_id;
// Perform DB operations asynchronouslytry {
const updatedMessage = await prisma.message.update({
where: { sinchMessageId: sinchMsgId },
data: {
status: event.status,
reason: event.reason,
timestamp: newDate(event.processed_time), // Use Sinch timestamp
},
});
console.log(`Updated status for message ${sinchMsgId} to ${event.status}`);
} catch (dbError) {
// Handle cases where the message might not exist (e.g., if outbound save failed)if (dbError.code === 'P2025') { // Prisma code for RecordNotFoundconsole.warn(`Message with sinchMessageId ${sinchMsgId} not found for status update.`);
} else {
console.error('Database error updating message status:', dbError);
}
// Decide how to handle DB errors
}
}
// Handle prisma client connection properly (e.g., singleton pattern)// https://www.prisma.io/docs/guides/database/troubleshooting-orm/help-articles/nextjs-prisma-client-dev-practices
Frequently asked questions
Create a Next.js API route that handles POST requests. This route should construct a JSON payload according to Sinch's API specifications, including recipient, message content, and authentication, then send a POST request to the Sinch Conversation API endpoint using a library like 'node-fetch'.
The Sinch Conversation API enables two-way communication with users on WhatsApp directly from your Next.js application backend. It's used for various messaging functionalities such as notifications, customer support, and alerts.
Sinch requires credentials like Project ID, Key ID, Key Secret, and App ID. Storing these in environment variables (like a .env.local file) keeps them separate from your codebase, enhancing security and preventing accidental exposure in version control.
Template messages are necessary when initiating a conversation with a user outside the 24-hour window after their last interaction or for sending specific pre-approved message formats. You'll need to register these templates beforehand in your Sinch dashboard.
Yes, set up a webhook endpoint in your Next.js application (e.g., /api/sinch-webhook) and configure it in your Sinch dashboard. Sinch will then send POST requests to this endpoint containing incoming messages and delivery status updates.
In your Sinch dashboard, add a new webhook with your publicly accessible Next.js API route URL (e.g., your-app-url/api/sinch-webhook) as the target. Select the desired event triggers, like 'MESSAGE_INBOUND' and 'MESSAGE_DELIVERY', to receive notifications.
NEXT_PUBLIC_APP_URL stores your application's public base URL. It's crucial for configuring webhooks correctly, as Sinch needs this URL to send webhook notifications to your application.
Your webhook route should parse the incoming JSON payload from Sinch. Identify the event type (like message_inbound_event), extract information (sender, message content), and then implement your application logic (saving the message to a database, triggering automated replies, etc.).
Store Sinch credentials, like Project ID, Key ID, Key Secret, and App ID, as environment variables in a .env.local file. Ensure that this file is added to your .gitignore to prevent these sensitive values from being committed to version control.
For production, implement webhook signature verification using the secret key from your Sinch dashboard setup. Verify the incoming request's authenticity in your webhook handler. Store your API keys securely as environment variables. Consider using Sinch OAuth for enhanced authentication.
Sinch requires quick acknowledgement of webhook deliveries. Your webhook handler should return a 200 OK response promptly. Offload time-consuming operations (database updates, complex processing) to background jobs or asynchronous queues to avoid webhook timeouts.
Use try...catch blocks in your API routes, validate inputs, check the 'response.ok' status from Sinch API calls, and parse error details. Log these details for debugging. In your webhook route, return a generic 500 error for processing failures without exposing internal information.
The WhatsApp Sender ID, often your WhatsApp Business phone number in E.164 format, identifies your business as the sender of WhatsApp messages. It's configured in your Sinch dashboard and included in API requests to Sinch.
Complete the WhatsApp Embedded Signup process within the Sinch Customer Dashboard. This involves linking a phone number to your Sinch account and obtaining the required Sender ID (your phone number in E.164 format) and a Bearer Token for that sender.
Build robust WhatsApp messaging into your Next.js application using the Sinch Conversation API. This tutorial shows you how to send and receive WhatsApp messages, handle webhooks, manage security, and deploy a production-ready solution.
You'll create a Next.js application with API routes that interact with the Sinch Conversation API to send outbound WhatsApp messages and process inbound messages via webhooks. By the end, you'll have a functional bidirectional integration ready for testing and deployment.
What Will You Build with This Tutorial?
Objective: Implement WhatsApp messaging within your Next.js application to send outbound messages and receive inbound messages via the Sinch Conversation API.
Problem Solved: Programmatically communicate with users on WhatsApp for notifications, customer support, alerts, order updates, and marketing – directly from your web application.
Technologies & Requirements (Verified January 2025):
System Architecture:
Final Outcome – A Next.js Application With:
/api/send-whatsapp) to send WhatsApp messages via Sinch/api/sinch-webhook) to receive incoming message notifications from SinchPrerequisites:
node --versionTechnology Verification Date: All technology versions and requirements verified as of January 2025. Check official documentation for latest releases.
How Do You Set Up Your Next.js Project for Sinch WhatsApp?
Initialize your Next.js project and configure environment variables for Sinch integration.
Creating Your Next.js App with App Router
Open your terminal and run:
npx create-next-app@latest sinch-whatsapp-nextjs # Follow the prompts (e.g., select TypeScript: No, ESLint: Yes, Tailwind CSS: No, `src/` directory: No, App Router: Yes (Recommended), Import alias: No) cd sinch-whatsapp-nextjsHow to Configure Sinch Environment Variables Securely
Sinch requires several credentials. Store them securely in environment variables. Create a
.env.localfile in your project root.Never commit
.env.localto version control. Add it to.gitignoreif it's not already there.Important: Replace all
YOUR_...placeholders with your actual credentials from the Sinch dashboard (explained later)..env.localvariables intoprocess.env.NEXT_PUBLIC_APP_URL: Set this to your application's publicly accessible base URL. Sinch uses this to send webhooks. TheNEXT_PUBLIC_prefix follows Next.js convention.Understanding the Next.js App Router Project Structure
You'll work primarily in the
app/api/directory for backend logic.How Do You Send WhatsApp Messages with Sinch Conversation API?
Create the API endpoint that sends outbound WhatsApp messages via Sinch.
Understanding WhatsApp Message Types
WhatsApp enforces specific rules for message types:
text_message)template_message)When to use each type:
text_messagefor ongoing conversations where the user messaged you recently (within 24 hours)template_messageto:File:
app/api/send-whatsapp/route.js// app/api/send-whatsapp/route.js import { NextResponse } from 'next/server'; // Basic Authentication Token Generation (cache in production) function getSinchAuthToken() { const keyId = process.env.SINCH_KEY_ID; const keySecret = process.env.SINCH_KEY_SECRET; if (!keyId || !keySecret) { throw new Error("Sinch Key ID or Secret not configured in environment variables."); } const credentials = `${keyId}:${keySecret}`; return `Basic ${Buffer.from(credentials).toString('base64')}`; // Note: For production, consider Sinch OAuth for better security & rotation // See: https://developers.sinch.com/docs/conversation/authentication/ } export async function POST(request) { const { to, text } = await request.json(); // --- 1. Input Validation --- if (!to || !text) { return NextResponse.json({ error: 'Missing "to" phone number or "text" message.' }, { status: 400 }); } // Basic E.164 format check (adjust regex as needed for stricter validation) if (!/^\+\d{11,15}$/.test(to)) { return NextResponse.json({ error: 'Invalid "to" phone number format. Use E.164 (e.g., +15551234567).' }, { status: 400 }); } const projectId = process.env.SINCH_PROJECT_ID; const sinchAppId = process.env.SINCH_APP_ID; const senderId = process.env.SINCH_WHATSAPP_SENDER_ID; // Used in payload, ensure it's set const region = process.env.SINCH_REGION || 'us'; // Default to 'us' if not set if (!projectId || !sinchAppId || !senderId) { return NextResponse.json({ error: 'Sinch Project ID, App ID, or Sender ID not configured.' }, { status: 500 }); } const sinchApiUrl = `https://${region}.conversation.api.sinch.com/v1/projects/${projectId}/messages:send`; const payload = { app_id: sinchAppId, recipient: { contact_id: to // Using contact_id for direct E.164 number sending // Alternatively, use channel_identity for specific channel/number pair: // channel_identity: { channel: 'WHATSAPP', identity: to } }, message: { text_message: { text: text } // For Template Messages, use: // template_message: { // template_id: "your_template_id", // The registered template name/ID // version: "your_template_version", // e.g., "1" // language_code: "en_US", // Or relevant language // parameters: { /* template parameters if any */ } // } }, channel_priority_order: ["WHATSAPP"] // Ensure WhatsApp is prioritized }; try { // --- 2. API Call to Sinch --- const response = await fetch(sinchApiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': getSinchAuthToken() // Use Basic Auth or OAuth }, body: JSON.stringify(payload), }); const responseData = await response.json(); // --- 3. Response Handling --- if (!response.ok) { console.error('Sinch API Error:', response.status, responseData); return NextResponse.json( { error: 'Failed to send message via Sinch.', details: responseData }, { status: response.status } ); } console.log('Sinch Send Success:', responseData); return NextResponse.json({ success: true, messageId: responseData.message_id }); } catch (error) { // --- 4. Error Handling --- console.error('Error sending WhatsApp message:', error); return NextResponse.json({ error: 'Internal server error.' }, { status: 500 }); } }Code Walkthrough:
to(recipient phone number in E.164 format) andtextfields exist in the request body. Includes basic E.164 format checking.getSinchAuthTokenfunction generates the Basic Authentication header using your Key ID and Secret. For production, implement Sinch's OAuth 2.0 flow for more secure, short-lived tokens./messages:sendendpoint specification. Includes the target app, recipient (usingcontact_id), message content (text_message), and prioritizes theWHATSAPPchannel. Comments show how to structure atemplate_message(required outside the 24-hour window or for initiating conversations).fetchAPI to send the POST request to the appropriate regional Sinch API endpoint.response.ok). Logs error details returned by Sinch on failure and sends an appropriate error response to the client. On success, logs the result and returns themessage_id.try...catchblock handles network errors or unexpected issues during the process.How Do You Receive WhatsApp Messages Using Sinch Webhooks?
Sinch needs a publicly accessible endpoint to send incoming messages and delivery status updates via POST requests (webhooks).
File:
app/api/sinch-webhook/route.js// app/api/sinch-webhook/route.js import { NextResponse } from 'next/server'; export async function POST(request) { try { const payload = await request.json(); console.log('Received Sinch Webhook'); console.log('Payload:', JSON.stringify(payload, null, 2)); // Log the full payload for inspection // --- 1. Identify Event Type & Process based on Payload Structure --- // Sinch uses different payload structures for different events. // Inspect payloads in the Sinch Dashboard or your logs to handle each relevant case. if (payload.message_inbound_event) { // --- 2. Handle Incoming Messages --- const event = payload.message_inbound_event; const message = event.message; const contactId = event.contact_id; // Sender's phone number (usually) const appId = event.app_id; console.log(`---> Incoming Message from ${contactId} in App ${appId}`); if (message.text_message) { console.log(` Text: ${message.text_message.text}`); // TODO: Add your logic here: // - Store the message in a database // - Trigger automated replies // - Forward to a support system } else if (message.media_message) { console.log(` Media URL: ${message.media_message.url}`); // TODO: Handle incoming media (images, videos, etc.) } // Add handling for other message types (location, contacts, etc.) as needed } else if (payload.message_delivery_event) { // --- 3. Handle Delivery Receipts --- const event = payload.message_delivery_event; const messageId = event.message_id; const status = event.status; // e.g., DELIVERED, FAILED, READ const reason = event.reason; // Reason for failure, if any console.log(`---> Delivery Status for Message ${messageId}: ${status}`); if (reason) { console.log(` Reason: ${reason}`); } // TODO: Update message status in your database } else { // Log other event types you might receive but aren't explicitly handling yet const eventType = Object.keys(payload)[0]; // Basic way to guess event type key console.warn(`Received unhandled webhook event type: ${eventType || 'Unknown'}`); } // --- 4. Acknowledge Receipt --- // Respond quickly to Sinch to acknowledge receipt. // Process heavy logic asynchronously (e.g., using queues or background jobs). return NextResponse.json({ success: true }); } catch (error) { console.error('Error processing Sinch webhook:', error); // Avoid sending detailed errors back in the webhook response return NextResponse.json({ error: 'Webhook processing failed.' }, { status: 500 }); } } // --- Optional: GET handler for verification --- // Some services require a GET request check during webhook setup. // Sinch doesn't typically require this, but it's useful for health checks. export async function GET(request) { console.log('Received GET request on webhook endpoint.'); return NextResponse.json({ message: 'Sinch webhook endpoint is active.' }); }Code Walkthrough:
message_inbound_eventormessage_delivery_eventkeys) to determine the event type. Inspect actual webhook payloads received from Sinch (viewable in the Sinch Dashboard or your logs) to confirm the exact structure and event types you need to handle.contact_id), message content (text_message,media_message, etc.), and logs it. Integrate your application logic here – save messages, trigger replies, etc.DELIVERED,FAILED,READ), and potentially a failure reason. Use this for tracking message status.200 OKresponse (NextResponse.json({ success: true })) quickly. Sinch expects timely acknowledgement. Move time-consuming processing to asynchronous operations (e.g., job queue like BullMQ, or serverless functions) to prevent webhook timeouts.500error to Sinch. Avoid sending detailed internal error messages.Security Note: For production, implement webhook signature verification to ensure incoming requests genuinely originate from Sinch. Sinch likely provides a mechanism (e.g., using a secret key to generate an HMAC signature in request headers). Consult the official Sinch Conversation API documentation for specific details on implementing webhook signature verification and add the necessary validation logic to this webhook handler. This is a critical security step.
How to Get Your Sinch Credentials and Configure WhatsApp
Obtain the necessary credentials and configure your Sinch App. Replace
YOUR_...placeholders in.env.localwith the actual values you obtain here.How to Obtain Sinch API Credentials
SINCH_PROJECT_IDin your.env.local..env.local(SINCH_KEY_ID,SINCH_KEY_SECRET). The Key Secret is only shown once.SINCH_REGIONin your.env.local.SINCH_APP_IDin your.env.local.+15551234567). Copy this toSINCH_WHATSAPP_SENDER_ID.SINCH_WHATSAPP_BEARER_TOKEN. This token is needed for linking the sender in the dashboard setup.How to Configure Your Sinch Conversation API App for WhatsApp
Bearer Tokenassociated with that Sender ID should be automatically used or confirmed here as part of the channel configuration.NEXT_PUBLIC_APP_URLfrom your environment variables, followed by the API route path:${NEXT_PUBLIC_APP_URL}/api/sinch-webhook.ngrok. Startngrok http 3000and use the providedhttps://URL (e.g.,https://your-ngrok-subdomain.ngrok-free.app/api/sinch-webhook). UpdateNEXT_PUBLIC_APP_URLin.env.localtemporarily.https://your-app.vercel.app/api/sinch-webhook). EnsureNEXT_PUBLIC_APP_URLis set correctly in your deployment environment variables.MESSAGE_INBOUND(for incoming messages)MESSAGE_DELIVERY(for delivery receipts)CONTACT_CREATE_EVENT,CONVERSATION_START_EVENT, etc., based on your needs.sinch-webhookroute using this secret (refer to Sinch documentation).Follow the dashboard navigation paths precisely to find these settings.
What Error Handling Best Practices Should You Implement?
Robust error handling is crucial for production WhatsApp messaging systems.
Error Handling Strategy
try...catchblocks in bothsend-whatsappandsinch-webhookroutesresponse.okand parse error details from the JSON body returned by Sinch on failure. Log these details.200 OKquickly. For processing errors, log internally and return a generic500error to Sinch without revealing internal details.Logging Best Practices
console.log,console.warn, andconsole.errorstrategically.Retry Mechanisms
For transient network errors or temporary Sinch API issues (e.g., 5xx errors) when sending messages, implement a retry strategy.
Caution: Avoid retrying blindly for client-side errors (4xx errors like invalid input). Retry primarily for server-side issues (5xx errors) or network timeouts.
Example using
async-retrylibrary:First, install the library:
Then, integrate it into your sending logic:
// Inside app/api/send-whatsapp/route.js (conceptual integration) import { NextResponse } from 'next/server'; import retry from 'async-retry'; // ... getSinchAuthToken function ... export async function POST(request) { // ... input validation ... // ... variable setup (projectId, sinchApiUrl, payload) ... try { // Wrap the fetch call within the retry block const result = await retry( async (bail, attempt) => { console.log(`Attempting Sinch API call (attempt ${attempt})...`); const response = await fetch(sinchApiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': getSinchAuthToken() }, body: JSON.stringify(payload), }); if (!response.ok) { const status = response.status; // Don't retry on client errors (4xx) if (status >= 400 && status < 500) { // Use bail to stop retrying and propagate the error immediately const errorData = await response.json(); const clientError = new Error(`Sinch API client error: ${status}`); clientError.originalStatus = status; clientError.details = errorData; bail(clientError); // Stop retrying return; // Bail prevents further execution here } // For server errors (5xx) or other network issues, throw to trigger retry const serverError = new Error(`Sinch API server error or network issue: ${status}`); serverError.originalStatus = status; try { serverError.details = await response.json(); } catch { /* ignore parsing error */ } throw serverError; // Trigger retry } // If response is OK, parse and return the data const responseData = await response.json(); console.log('Sinch Send Success within retry block:', responseData); return responseData; // Return successful data }, { retries: 3, // Number of retries factor: 2, // Exponential backoff factor minTimeout: 1000, // Initial timeout in ms (1 second) onRetry: (error, attempt) => { console.warn(`Retrying Sinch API call (attempt ${attempt}) due to: ${error.message}`); } } ); // If retry succeeded, 'result' holds the responseData return NextResponse.json({ success: true, messageId: result.message_id }); } catch (error) { // This catches errors after all retries failed, or if bail was called console.error('Error sending WhatsApp message after retries:', error.message); // Log details if available (e.g., from clientError or last serverError) const status = error.originalStatus || 500; const details = error.details || null; return NextResponse.json( { error: 'Failed to send message via Sinch after retries.', details: details }, { status: status } ); } }Integrate this retry logic carefully into your existing
send-whatsapproute, replacing the simplefetchcall.Should You Store WhatsApp Message History in a Database?
While not required for basic sending/receiving, storing message history or contact information is common. Prisma is a popular choice with Next.js for database operations.
For a complete guide on implementing database storage with Prisma, including schema design and integration examples, see our Next.js database integration tutorial.
How to Set Up Prisma for Message Storage
npm install prisma --save-dev npm install @prisma/client npx prisma init --datasource-provider postgresql # Or your preferred DBConfigure your database connection URL in
.env.Example Database Schema for WhatsApp Messages
File:
prisma/schema.prismaApply Schema & Generate Client
npx prisma migrate dev --name init # Creates migration and applies to DB npx prisma generate # Generates Prisma ClientUsage in API Routes
// Example in app/api/sinch-webhook/route.js import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); // ... inside POST function, after parsing payload ... if (payload.message_inbound_event) { const event = payload.message_inbound_event; const messageData = event.message; const contactPhone = event.contact_id; // Assuming this is the E.164 phone const sinchMsgId = event.message_id; // Check if inbound events provide a unique ID // Perform DB operations asynchronously (move outside the immediate response if heavy) try { await prisma.message.create({ data: { sinchMessageId: sinchMsgId, // Ensure this is unique or handle conflicts contact: { connectOrCreate: { where: { phone: contactPhone }, create: { phone: contactPhone }, }, }, appId: event.app_id, direction: 'INBOUND', channel: event.channel, // Make sure channel is in the event payload content: messageData, // Store the whole message object timestamp: new Date(event.accepted_time), // Use Sinch timestamp status: 'RECEIVED', // Custom status }, }); console.log(`Saved inbound message ${sinchMsgId} from ${contactPhone}`); } catch (dbError) { console.error('Database error saving inbound message:', dbError); // Decide how to handle DB errors – perhaps queue for later retry? } } else if (payload.message_delivery_event) { const event = payload.message_delivery_event; const sinchMsgId = event.message_id; // Perform DB operations asynchronously try { const updatedMessage = await prisma.message.update({ where: { sinchMessageId: sinchMsgId }, data: { status: event.status, reason: event.reason, timestamp: new Date(event.processed_time), // Use Sinch timestamp }, }); console.log(`Updated status for message ${sinchMsgId} to ${event.status}`); } catch (dbError) { // Handle cases where the message might not exist (e.g., if outbound save failed) if (dbError.code === 'P2025') { // Prisma code for RecordNotFound console.warn(`Message with sinchMessageId ${sinchMsgId} not found for status update.`); } else { console.error('Database error updating message status:', dbError); } // Decide how to handle DB errors } } // Handle prisma client connection properly (e.g., singleton pattern) // https://www.prisma.io/docs/guides/database/troubleshooting-orm/help-articles/nextjs-prisma-client-dev-practices