code examples
code examples
Sinch WhatsApp Business API Integration with Next.js 15 and Supabase
Learn how to build a production-ready WhatsApp Business messaging app using Sinch Conversation API, Next.js 15 App Router, and Supabase. Step-by-step tutorial covering authentication, real-time webhooks, message templates, and deployment.
Sinch WhatsApp Integration: Next.js + Supabase Complete Guide
Learn how to build a production-ready WhatsApp Business messaging application using the Sinch Conversation API, Next.js 15, and Supabase. This comprehensive tutorial covers user authentication, sending and receiving WhatsApp messages, secure webhook handling, PostgreSQL database integration, real-time updates, and production deployment.
Learning outcomes: By completing this guide, you will build a full-stack WhatsApp messaging application with user authentication, message persistence, real-time updates, secure webhook handling, and production deployment capabilities. Estimated time: 3-4 hours.
Related guides: Looking for other messaging integrations? Check out our guides for Twilio WhatsApp with Next.js, MessageBird WhatsApp integration, and Plivo WhatsApp messaging.
What You'll Build: WhatsApp Messaging Platform Overview
What We're Building:
A full-stack WhatsApp Business messaging application with:
- User authentication via Supabase Auth with cookie-based sessions
- Send WhatsApp messages using Sinch Conversation API (both free-form and template messages)
- Receive WhatsApp messages through secure webhook endpoints with HMAC signature verification
- Database integration for storing conversations, messages, and tracking the 24-hour customer service window
- Real-time updates using Supabase Realtime subscriptions
- Next.js App Router with Server Actions and Route Handlers
Problem Solved:
This integration enables businesses to leverage WhatsApp's 2.7+ billion active users for customer communication, support, notifications, and engagement through a unified, scalable platform managed via the Sinch Conversation API.
Technologies Used:
- Next.js 15: React framework with App Router, Server Components, and Server Actions. Chosen for server-side rendering, API routes, simplified data fetching, and excellent developer experience.
- Supabase: Open-source Firebase alternative providing PostgreSQL database, authentication, and real-time subscriptions. Chosen for its integrated auth system, real-time capabilities, and PostgreSQL foundation.
- Sinch Conversation API: Unified messaging platform supporting WhatsApp, SMS, RCS, and other channels. Chosen for production-grade WhatsApp Business API access with simplified authentication and multi-channel support.
- TypeScript: For type safety and improved developer experience
- Tailwind CSS: Utility-first CSS framework for rapid UI development
System Architecture:
User Browser
↓
Next.js App (App Router)
↓ (Server Actions)
Next.js API Routes ← Supabase Auth (Cookie-based sessions)
↓
Sinch Conversation API → WhatsApp Business Platform → User's WhatsApp
↓ (Webhooks)
Next.js Webhook Route (HMAC verification) → Supabase Database
Prerequisites:
- Node.js v18+: Required for Next.js 15. Download from nodejs.org. Verify:
node --version - Sinch Account: Registered with postpay billing enabled (required for WhatsApp Business API access). Sign up at Sinch Dashboard
- Sinch Conversation API Setup:
- Project created in Sinch Customer Dashboard
- Conversation API App configured
- Access Key ID and Access Secret generated (store secret securely—shown only once)
- WhatsApp Business number provisioned and approved (Sender ID)
- Supabase Project: Free tier sufficient for development. Create at supabase.com
- Basic Understanding: TypeScript/JavaScript, React, Next.js App Router, REST APIs, async/await patterns
- (Development) Webhook Testing: ngrok or similar tool to expose local server for webhook testing
Prerequisites Verification:
# Check Node.js version (should be v18+)
node --version
# Check npm version
npm --version
# Verify you can access Sinch dashboard
# Navigate to: https://dashboard.sinch.com/convapi/overview
# Verify Supabase project created
# Navigate to: https://supabase.com/dashboard/projectsFinal Outcome:
A production-ready WhatsApp messaging application with authenticated users, persistent message storage in Supabase, real-time UI updates, secure webhook handling with signature verification, 24-hour window tracking, and template message support for conversation initiation.
1. Setting Up Your Next.js 15 WhatsApp Project
Initialize your Next.js 15 project with TypeScript and configure Supabase integration.
1.1. Create Next.js Project
# Create Next.js 15 project with TypeScript and Tailwind CSS
npx create-next-app@latest sinch-whatsapp-app --typescript --tailwind --app --use-npm
# Navigate into project
cd sinch-whatsapp-appWhen prompted:
- ✅ Use TypeScript: Yes
- ✅ Use ESLint: Yes
- ✅ Use Tailwind CSS: Yes
- ✅ Use
src/directory: Yes - ✅ Use App Router: Yes
- ❌ Customize default import alias: No
1.2. Install Dependencies
# Supabase client for Next.js (uses @supabase/ssr package)
npm install @supabase/supabase-js @supabase/ssr
# HTTP client for Sinch API calls
npm install axios
# Environment variable validation (optional but recommended)
npm install zodDependency Notes:
@supabase/ssris the current recommended package (replaces deprecated@supabase/auth-helpers-nextjs)axiosprovides cleaner error handling and interceptors compared to nativefetchzodenables runtime environment variable validation
1.3. Project Structure
Create the following directory structure:
sinch-whatsapp-app/
├── src/
│ ├── app/
│ │ ├── api/
│ │ │ ├── webhooks/
│ │ │ │ └── sinch/
│ │ │ │ └── route.ts # Webhook endpoint
│ │ │ └── messages/
│ │ │ └── send/
│ │ │ └── route.ts # Message sending API
│ │ ├── auth/
│ │ │ ├── login/
│ │ │ │ └── page.tsx # Login page
│ │ │ └── callback/
│ │ │ └── route.ts # OAuth callback
│ │ ├── dashboard/
│ │ │ └── page.tsx # Main dashboard
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── components/
│ │ ├── MessageList.tsx
│ │ ├── SendMessageForm.tsx
│ │ └── AuthButton.tsx
│ ├── lib/
│ │ ├── supabase/
│ │ │ ├── client.ts # Browser client
│ │ │ ├── server.ts # Server client
│ │ │ └── middleware.ts # Auth middleware
│ │ ├── sinch/
│ │ │ ├── client.ts # Sinch API client
│ │ │ ├── auth.ts # HMAC signature generation
│ │ │ └── webhooks.ts # Webhook verification
│ │ ├── types.ts # TypeScript types
│ │ └── env.ts # Environment validation
│ └── middleware.ts # Next.js middleware
├── supabase/
│ └── migrations/
│ └── 20250115000000_initial_schema.sql
├── .env.local # Environment variables
├── next.config.js
└── tsconfig.json
1.4. Environment Variables
Create .env.local in the project root:
# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
# Sinch Conversation API Configuration
SINCH_PROJECT_ID=your_sinch_project_id
SINCH_ACCESS_KEY_ID=your_access_key_id
SINCH_ACCESS_KEY_SECRET=your_access_key_secret
SINCH_APP_ID=your_conversation_api_app_id
SINCH_WEBHOOK_SECRET=your_webhook_secret
SINCH_REGION=us # or 'eu' for Europe region
# Application Configuration
NEXT_PUBLIC_APP_URL=http://localhost:3000Where to find these values:
- Supabase values: Project Settings → API in your Supabase dashboard
- Sinch Project ID: Sinch Dashboard → Settings → Project Management
- Sinch Access Keys: Sinch Dashboard → Settings → Access Keys → Create new access key
- Sinch App ID: Sinch Dashboard → Conversation API → Apps → Your App ID
- Sinch Webhook Secret: Generated when you create webhook configuration (Section 6)
- Sinch Region:
usoreudepending on where your Conversation API app was created
1.5. Environment Validation (Optional but Recommended)
Create src/lib/env.ts:
import { z } from 'zod';
const envSchema = z.object({
// Supabase
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
// Sinch
SINCH_PROJECT_ID: z.string().uuid(),
SINCH_ACCESS_KEY_ID: z.string().min(1),
SINCH_ACCESS_KEY_SECRET: z.string().min(1),
SINCH_APP_ID: z.string().min(1),
SINCH_WEBHOOK_SECRET: z.string().min(1),
SINCH_REGION: z.enum(['us', 'eu']).default('us'),
// Application
NEXT_PUBLIC_APP_URL: z.string().url(),
});
export const env = envSchema.parse({
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
SINCH_PROJECT_ID: process.env.SINCH_PROJECT_ID,
SINCH_ACCESS_KEY_ID: process.env.SINCH_ACCESS_KEY_ID,
SINCH_ACCESS_KEY_SECRET: process.env.SINCH_ACCESS_KEY_SECRET,
SINCH_APP_ID: process.env.SINCH_APP_ID,
SINCH_WEBHOOK_SECRET: process.env.SINCH_WEBHOOK_SECRET,
SINCH_REGION: process.env.SINCH_REGION,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
});
export type Env = z.infer<typeof envSchema>;This validates environment variables at startup, providing clear error messages if any are missing or invalid.
2. Configuring PostgreSQL Database Schema for WhatsApp Messages
Design and implement the PostgreSQL schema for storing conversations and messages.
2.1. Database Schema
Create supabase/migrations/20250115000000_initial_schema.sql:
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Contacts table: stores WhatsApp contact information
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
phone_number TEXT NOT NULL,
display_name TEXT,
sinch_contact_id TEXT UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, phone_number)
);
-- Conversations table: tracks conversation state and 24-hour window
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
contact_id UUID REFERENCES contacts(id) ON DELETE CASCADE NOT NULL,
sinch_conversation_id TEXT UNIQUE,
last_inbound_message_at TIMESTAMPTZ,
last_outbound_message_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Messages table: stores all messages (inbound and outbound)
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE NOT NULL,
sinch_message_id TEXT UNIQUE NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
message_type TEXT NOT NULL CHECK (message_type IN ('text', 'template', 'media', 'interactive')),
content JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'delivered', 'read', 'failed')),
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes for performance
CREATE INDEX idx_contacts_user_id ON contacts(user_id);
CREATE INDEX idx_contacts_phone_number ON contacts(phone_number);
CREATE INDEX idx_contacts_sinch_contact_id ON contacts(sinch_contact_id);
CREATE INDEX idx_conversations_contact_id ON conversations(contact_id);
CREATE INDEX idx_conversations_sinch_id ON conversations(sinch_conversation_id);
CREATE INDEX idx_messages_conversation_id ON messages(conversation_id);
CREATE INDEX idx_messages_sinch_message_id ON messages(sinch_message_id);
CREATE INDEX idx_messages_created_at ON messages(created_at DESC);
-- Function to check if conversation is within 24-hour window
CREATE OR REPLACE FUNCTION is_within_24h_window(conversation_id UUID)
RETURNS BOOLEAN AS $$
DECLARE
last_inbound TIMESTAMPTZ;
BEGIN
SELECT last_inbound_message_at INTO last_inbound
FROM conversations
WHERE id = conversation_id;
-- If no inbound message, window is closed
IF last_inbound IS NULL THEN
RETURN FALSE;
END IF;
-- Check if within 24 hours (86400 seconds)
RETURN (EXTRACT(EPOCH FROM (NOW() - last_inbound)) < 86400);
END;
$$ LANGUAGE plpgsql;
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Triggers for updated_at
CREATE TRIGGER update_contacts_updated_at BEFORE UPDATE ON contacts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_conversations_updated_at BEFORE UPDATE ON conversations
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_messages_updated_at BEFORE UPDATE ON messages
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Row Level Security (RLS) policies
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- Contacts policies
CREATE POLICY "Users can view own contacts"
ON contacts FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own contacts"
ON contacts FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own contacts"
ON contacts FOR UPDATE
USING (auth.uid() = user_id);
-- Conversations policies
CREATE POLICY "Users can view own conversations"
ON conversations FOR SELECT
USING (contact_id IN (SELECT id FROM contacts WHERE user_id = auth.uid()));
CREATE POLICY "Users can insert own conversations"
ON conversations FOR INSERT
WITH CHECK (contact_id IN (SELECT id FROM contacts WHERE user_id = auth.uid()));
CREATE POLICY "Users can update own conversations"
ON conversations FOR UPDATE
USING (contact_id IN (SELECT id FROM contacts WHERE user_id = auth.uid()));
-- Messages policies
CREATE POLICY "Users can view own messages"
ON messages FOR SELECT
USING (conversation_id IN (
SELECT c.id FROM conversations c
JOIN contacts ct ON c.contact_id = ct.id
WHERE ct.user_id = auth.uid()
));
CREATE POLICY "Users can insert own messages"
ON messages FOR INSERT
WITH CHECK (conversation_id IN (
SELECT c.id FROM conversations c
JOIN contacts ct ON c.contact_id = ct.id
WHERE ct.user_id = auth.uid()
));
CREATE POLICY "Users can update own messages"
ON messages FOR UPDATE
USING (conversation_id IN (
SELECT c.id FROM conversations c
JOIN contacts ct ON c.contact_id = ct.id
WHERE ct.user_id = auth.uid()
));2.2. Run Migration
Apply the migration to your Supabase project:
Option 1: Using Supabase CLI (Recommended)
# Install Supabase CLI
npm install -g supabase
# Link to your project
supabase link --project-ref your-project-ref
# Run migration
supabase db pushOption 2: Via Supabase Dashboard
- Go to your Supabase project dashboard
- Navigate to SQL Editor
- Copy and paste the migration SQL
- Click "Run"
2.3. Enable Realtime (Optional)
For real-time message updates in the UI:
- Go to Database → Replication in Supabase dashboard
- Enable replication for
messagestable - Select "Insert", "Update", and "Delete" events
3. Implementing Supabase Authentication in Next.js
Implement cookie-based authentication using Supabase Auth with Next.js 15 App Router.
3.1. Supabase Client Utilities
Create src/lib/supabase/client.ts for browser-side client:
import { createBrowserClient } from '@supabase/ssr';
import { env } from '@/lib/env';
export function createClient() {
return createBrowserClient(
env.NEXT_PUBLIC_SUPABASE_URL,
env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
}Create src/lib/supabase/server.ts for server-side client:
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { cookies } from 'next/headers';
import { env } from '@/lib/env';
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
env.NEXT_PUBLIC_SUPABASE_URL,
env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
try {
cookieStore.set({ name, value, ...options });
} catch (error) {
// Cookie setting can fail in Server Components
// This is expected behavior
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: '', ...options });
} catch (error) {
// Cookie removal can fail in Server Components
}
},
},
}
);
}3.2. Next.js Middleware for Auth
Create src/middleware.ts:
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
import { env } from '@/lib/env';
export async function middleware(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createServerClient(
env.NEXT_PUBLIC_SUPABASE_URL,
env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
request.cookies.set({
name,
value,
...options,
});
response = NextResponse.next({
request: {
headers: request.headers,
},
});
response.cookies.set({
name,
value,
...options,
});
},
remove(name: string, options: CookieOptions) {
request.cookies.set({
name,
value: '',
...options,
});
response = NextResponse.next({
request: {
headers: request.headers,
},
});
response.cookies.set({
name,
value: '',
...options,
});
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
// Redirect to login if accessing protected routes without auth
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/auth/login', request.url));
}
// Redirect to dashboard if accessing auth pages while logged in
if (user && request.nextUrl.pathname.startsWith('/auth')) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return response;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};3.3. Auth Pages
Create src/app/auth/login/page.tsx:
'use client';
import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const supabase = createClient();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
setError(error.message);
setLoading(false);
} else {
router.push('/dashboard');
router.refresh();
}
};
const handleSignup = async () => {
setLoading(true);
setError(null);
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) {
setError(error.message);
setLoading(false);
} else {
setError('Check your email for the confirmation link!');
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md space-y-8 p-8 bg-white rounded-lg shadow-md">
<div>
<h2 className="text-3xl font-bold text-center">Sign in to WhatsApp Dashboard</h2>
</div>
<form className="space-y-6" onSubmit={handleLogin}>
{error && (
<div className="p-3 text-sm text-red-800 bg-red-100 rounded-md">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={loading}
className="flex-1 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{loading ? 'Loading...' : 'Sign in'}
</button>
<button
type="button"
onClick={handleSignup}
disabled={loading}
className="flex-1 py-2 px-4 border border-indigo-600 rounded-md shadow-sm text-sm font-medium text-indigo-600 bg-white hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{loading ? 'Loading...' : 'Sign up'}
</button>
</div>
</form>
</div>
</div>
);
}Create src/app/auth/callback/route.ts:
import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get('code');
if (code) {
const supabase = await createClient();
await supabase.auth.exchangeCodeForSession(code);
}
// URL to redirect to after sign in process completes
return NextResponse.redirect(new URL('/dashboard', request.url));
}4. Integrating Sinch WhatsApp Conversation API
Implement authentication and message sending using the Sinch Conversation API with HMAC-SHA256 signatures.
4.1. Sinch Authentication
According to Sinch Conversation API documentation, authentication requires HMAC-SHA256 signature generation. Create src/lib/sinch/auth.ts:
import crypto from 'crypto';
import { env } from '@/lib/env';
export interface SinchAuthHeaders {
'Content-Type': string;
'x-timestamp': string;
'Authorization': string;
}
/**
* Generate HMAC-SHA256 signature for Sinch Conversation API authentication
* Documentation: https://developers.sinch.com/docs/conversation/api-reference/#authentication
*/
export function generateSinchAuthHeaders(
method: string,
path: string,
body?: object
): SinchAuthHeaders {
const timestamp = new Date().toISOString();
const contentType = 'application/json';
// Calculate MD5 hash of request body
const bodyString = body ? JSON.stringify(body) : '';
const bodyMd5 = crypto
.createHash('md5')
.update(bodyString)
.digest('base64');
// Create string to sign
// Format: HTTP_METHOD\nMD5_BODY\nCONTENT_TYPE\nx-timestamp:TIMESTAMP\nCANONICALIZED_RESOURCE
const stringToSign = [
method.toUpperCase(),
bodyMd5,
contentType,
`x-timestamp:${timestamp}`,
path,
].join('\n');
// Decode the base64-encoded access key secret
const decodedSecret = Buffer.from(env.SINCH_ACCESS_KEY_SECRET, 'base64');
// Generate HMAC-SHA256 signature
const signature = crypto
.createHmac('sha256', decodedSecret)
.update(stringToSign, 'utf8')
.digest('base64');
// Format: Application {ACCESS_KEY_ID}:{SIGNATURE}
const authHeader = `Application ${env.SINCH_ACCESS_KEY_ID}:${signature}`;
return {
'Content-Type': contentType,
'x-timestamp': timestamp,
'Authorization': authHeader,
};
}4.2. Sinch API Client
Create src/lib/sinch/client.ts:
import axios, { AxiosInstance } from 'axios';
import { env } from '@/lib/env';
import { generateSinchAuthHeaders } from './auth';
export interface SendTextMessageParams {
recipient: string; // E.164 format phone number
message: string;
contactId?: string; // Optional Sinch contact ID
}
export interface SendTemplateMessageParams {
recipient: string;
templateId: string;
languageCode: string;
parameters: Record<string, string>;
contactId?: string;
}
export interface SinchMessageResponse {
message_id: string;
accepted_time: string;
[key: string]: any;
}
class SinchConversationClient {
private baseUrl: string;
private projectId: string;
private appId: string;
constructor() {
const region = env.SINCH_REGION || 'us';
this.baseUrl = `https://${region}.conversation.api.sinch.com/v1`;
this.projectId = env.SINCH_PROJECT_ID;
this.appId = env.SINCH_APP_ID;
}
/**
* Send a text message within the 24-hour customer service window
* Reference: https://developers.sinch.com/docs/conversation/getting-started/node-sdk/send-message
*/
async sendTextMessage(params: SendTextMessageParams): Promise<SinchMessageResponse> {
const path = `/projects/${this.projectId}/messages:send`;
const url = `${this.baseUrl}${path}`;
const body = {
app_id: this.appId,
recipient: params.contactId
? {
contact_id: params.contactId,
}
: {
identified_by: {
channel_identities: [
{
channel: 'WHATSAPP',
identity: params.recipient,
},
],
},
},
message: {
text_message: {
text: params.message,
},
},
};
const headers = generateSinchAuthHeaders('POST', path, body);
try {
const response = await axios.post(url, body, { headers });
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(
`Sinch API error: ${error.response?.data?.message || error.message}`
);
}
throw error;
}
}
/**
* Send a WhatsApp template message (for conversation initiation or outside 24h window)
* Reference: https://developers.sinch.com/docs/conversation/channel-support/whatsapp/template-support
*/
async sendTemplateMessage(params: SendTemplateMessageParams): Promise<SinchMessageResponse> {
const path = `/projects/${this.projectId}/messages:send`;
const url = `${this.baseUrl}${path}`;
const body = {
app_id: this.appId,
recipient: params.contactId
? {
contact_id: params.contactId,
}
: {
identified_by: {
channel_identities: [
{
channel: 'WHATSAPP',
identity: params.recipient,
},
],
},
},
message: {
template_message: {
channel_template: {
WHATSAPP: {
template_id: params.templateId,
language_code: params.languageCode,
parameters: params.parameters,
},
},
},
},
};
const headers = generateSinchAuthHeaders('POST', path, body);
try {
const response = await axios.post(url, body, { headers });
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(
`Sinch API error: ${error.response?.data?.message || error.message}`
);
}
throw error;
}
}
}
export const sinchClient = new SinchConversationClient();4.3. TypeScript Types
Create src/lib/types.ts:
export interface Contact {
id: string;
user_id: string;
phone_number: string;
display_name: string | null;
sinch_contact_id: string | null;
created_at: string;
updated_at: string;
}
export interface Conversation {
id: string;
contact_id: string;
sinch_conversation_id: string | null;
last_inbound_message_at: string | null;
last_outbound_message_at: string | null;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface Message {
id: string;
conversation_id: string;
sinch_message_id: string;
direction: 'inbound' | 'outbound';
message_type: 'text' | 'template' | 'media' | 'interactive';
content: {
text?: string;
template_id?: string;
media_url?: string;
[key: string]: any;
};
status: 'pending' | 'delivered' | 'read' | 'failed';
error_message: string | null;
created_at: string;
updated_at: string;
}
export interface SinchWebhookEvent {
app_id: string;
accepted_time: string;
event_time: string;
project_id: string;
message?: {
id: string;
direction: 'TO_APP' | 'TO_CONTACT';
contact_message?: {
text_message?: {
text: string;
};
media_message?: {
url: string;
};
choice_response_message?: {
message_id: string;
postback_data: string;
};
};
conversation_id: string;
contact_id: string;
metadata: string;
accept_time: string;
};
message_delivery_report?: {
message_id: string;
conversation_id: string;
status: 'QUEUED' | 'DISPATCHED' | 'DELIVERED' | 'READ' | 'FAILED';
channel_identity: {
channel: string;
identity: string;
};
error_details?: {
code: string;
description: string;
};
};
}5. Setting Up WhatsApp Webhooks for Incoming Messages
Implement secure webhook endpoint with HMAC-SHA256 signature verification for receiving WhatsApp messages.
5.1. Webhook Signature Verification
Create src/lib/sinch/webhooks.ts:
import crypto from 'crypto';
import { env } from '@/lib/env';
/**
* Verify Sinch webhook signature using HMAC-SHA256
* Reference: https://developers.sinch.com/docs/conversation/callbacks#validating-callback-requests
*/
export function verifyWebhookSignature(
body: string,
signature: string,
timestamp: string
): boolean {
try {
// Validate timestamp (within 5 minutes)
const timestampMs = new Date(timestamp).getTime();
const nowMs = Date.now();
const fiveMinutesMs = 5 * 60 * 1000;
if (Math.abs(nowMs - timestampMs) > fiveMinutesMs) {
console.error('Webhook timestamp too old or in future');
return false;
}
// Create string to sign: raw_body|timestamp
const stringToSign = `${body}|${timestamp}`;
// Generate HMAC-SHA256 signature
const expectedSignature = crypto
.createHmac('sha256', env.SINCH_WEBHOOK_SECRET)
.update(stringToSign, 'utf8')
.digest('base64');
// Use timing-safe comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
} catch (error) {
console.error('Error verifying webhook signature:', error);
return false;
}
}5.2. Webhook Route Handler
Create src/app/api/webhooks/sinch/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { verifyWebhookSignature } from '@/lib/sinch/webhooks';
import type { SinchWebhookEvent } from '@/lib/types';
export async function POST(request: NextRequest) {
try {
// Get raw body for signature verification
const body = await request.text();
const signature = request.headers.get('x-sinch-signature');
const timestamp = request.headers.get('x-sinch-timestamp');
if (!signature || !timestamp) {
console.error('Missing signature or timestamp headers');
return NextResponse.json(
{ error: 'Missing required headers' },
{ status: 401 }
);
}
// Verify webhook signature
if (!verifyWebhookSignature(body, signature, timestamp)) {
console.error('Invalid webhook signature');
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
// Parse webhook payload
const event: SinchWebhookEvent = JSON.parse(body);
// Handle different event types
if (event.message) {
await handleInboundMessage(event);
} else if (event.message_delivery_report) {
await handleDeliveryReport(event);
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
async function handleInboundMessage(event: SinchWebhookEvent) {
if (!event.message || event.message.direction !== 'TO_APP') {
return; // Only process inbound messages
}
const supabase = await createClient();
const message = event.message;
try {
// Find or create contact
const { data: contact, error: contactError } = await supabase
.from('contacts')
.select('id')
.eq('sinch_contact_id', message.contact_id)
.single();
if (contactError || !contact) {
console.error('Contact not found:', message.contact_id);
return;
}
// Find or create conversation
let conversationId: string;
const { data: conversation, error: convError } = await supabase
.from('conversations')
.select('id')
.eq('sinch_conversation_id', message.conversation_id)
.single();
if (convError || !conversation) {
// Create new conversation
const { data: newConv, error: createError } = await supabase
.from('conversations')
.insert({
contact_id: contact.id,
sinch_conversation_id: message.conversation_id,
last_inbound_message_at: new Date().toISOString(),
})
.select('id')
.single();
if (createError || !newConv) {
console.error('Error creating conversation:', createError);
return;
}
conversationId = newConv.id;
} else {
conversationId = conversation.id;
// Update last inbound message timestamp
await supabase
.from('conversations')
.update({ last_inbound_message_at: new Date().toISOString() })
.eq('id', conversationId);
}
// Extract message content
let messageContent: any = {};
let messageType: 'text' | 'media' | 'interactive' = 'text';
if (message.contact_message?.text_message) {
messageContent = { text: message.contact_message.text_message.text };
messageType = 'text';
} else if (message.contact_message?.media_message) {
messageContent = { media_url: message.contact_message.media_message.url };
messageType = 'media';
} else if (message.contact_message?.choice_response_message) {
messageContent = {
postback_data: message.contact_message.choice_response_message.postback_data,
reply_to: message.contact_message.choice_response_message.message_id,
};
messageType = 'interactive';
}
// Store message in database
await supabase.from('messages').insert({
conversation_id: conversationId,
sinch_message_id: message.id,
direction: 'inbound',
message_type: messageType,
content: messageContent,
status: 'delivered',
});
console.log(`Stored inbound message ${message.id}`);
} catch (error) {
console.error('Error handling inbound message:', error);
}
}
async function handleDeliveryReport(event: SinchWebhookEvent) {
if (!event.message_delivery_report) {
return;
}
const supabase = await createClient();
const report = event.message_delivery_report;
try {
// Update message status in database
const status = report.status.toLowerCase() as 'delivered' | 'read' | 'failed';
const updateData: any = { status };
if (report.error_details) {
updateData.error_message = `${report.error_details.code}: ${report.error_details.description}`;
}
await supabase
.from('messages')
.update(updateData)
.eq('sinch_message_id', report.message_id);
console.log(`Updated message ${report.message_id} status to ${status}`);
} catch (error) {
console.error('Error handling delivery report:', error);
}
}5.3. Configure Webhook in Sinch Dashboard
- Log in to Sinch Dashboard
- Navigate to Conversation API → Webhooks
- Click "Create new webhook"
- Configure:
- Target URL:
https://your-domain.com/api/webhooks/sinch(use ngrok URL for local development) - Target type: HTTP
- Secret: Generate a secure random string (save as
SINCH_WEBHOOK_SECRET) - Triggers: Select "MESSAGE_INBOUND" and "MESSAGE_DELIVERY"
- Target URL:
- Save webhook configuration
For local development with ngrok:
# Install ngrok
npm install -g ngrok
# Start Next.js dev server
npm run dev
# In another terminal, expose local server
ngrok http 3000
# Use the ngrok HTTPS URL in Sinch webhook configuration
# Example: https://abc123.ngrok.io/api/webhooks/sinch6. Building the WhatsApp Dashboard User Interface
Create the user interface for sending and viewing WhatsApp messages.
6.1. Dashboard Page
Create src/app/dashboard/page.tsx:
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import MessageList from '@/components/MessageList';
import SendMessageForm from '@/components/SendMessageForm';
import AuthButton from '@/components/AuthButton';
export default async function DashboardPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect('/auth/login');
}
// Fetch user's contacts with conversations and messages
const { data: contacts } = await supabase
.from('contacts')
.select(`
*,
conversations (
*,
messages (*)
)
`)
.eq('user_id', user.id)
.order('created_at', { ascending: false });
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">WhatsApp Dashboard</h1>
<AuthButton />
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Conversations</h2>
<MessageList contacts={contacts || []} />
</div>
</div>
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Send Message</h2>
<SendMessageForm />
</div>
</div>
</div>
</main>
</div>
);
}6.2. Message List Component
Create src/components/MessageList.tsx:
'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import type { Message } from '@/lib/types';
interface MessageListProps {
contacts: any[];
}
export default function MessageList({ contacts: initialContacts }: MessageListProps) {
const [contacts, setContacts] = useState(initialContacts);
const supabase = createClient();
useEffect(() => {
// Subscribe to real-time message updates
const channel = supabase
.channel('messages-changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'messages',
},
(payload) => {
console.log('Message change:', payload);
// Refresh contacts data
// In production, update state more efficiently
window.location.reload();
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [supabase]);
if (!contacts || contacts.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
No conversations yet. Send a message to get started!
</div>
);
}
return (
<div className="space-y-4">
{contacts.map((contact) => (
<div key={contact.id} className="border rounded-lg p-4">
<div className="flex justify-between items-start mb-3">
<div>
<h3 className="font-semibold text-lg">
{contact.display_name || contact.phone_number}
</h3>
<p className="text-sm text-gray-500">{contact.phone_number}</p>
</div>
</div>
{contact.conversations?.map((conversation: any) => (
<div key={conversation.id} className="space-y-2 mt-4">
{conversation.messages?.map((message: Message) => (
<div
key={message.id}
className={`p-3 rounded-lg ${
message.direction === 'inbound'
? 'bg-gray-100 mr-12'
: 'bg-blue-100 ml-12'
}`}
>
<p className="text-sm">{message.content.text}</p>
<div className="flex justify-between items-center mt-2">
<span className="text-xs text-gray-500">
{new Date(message.created_at).toLocaleString()}
</span>
<span
className={`text-xs px-2 py-1 rounded ${
message.status === 'delivered'
? 'bg-green-200 text-green-800'
: message.status === 'failed'
? 'bg-red-200 text-red-800'
: 'bg-yellow-200 text-yellow-800'
}`}
>
{message.status}
</span>
</div>
</div>
))}
</div>
))}
</div>
))}
</div>
);
}6.3. Send Message Form Component
Create src/components/SendMessageForm.tsx:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function SendMessageForm() {
const [phoneNumber, setPhoneNumber] = useState('');
const [message, setMessage] = useState('');
const [useTemplate, setUseTemplate] = useState(false);
const [templateId, setTemplateId] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(false);
try {
const response = await fetch('/api/messages/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
phoneNumber,
message: useTemplate ? undefined : message,
templateId: useTemplate ? templateId : undefined,
useTemplate,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to send message');
}
setSuccess(true);
setMessage('');
setPhoneNumber('');
setTemplateId('');
// Refresh the page to show new message
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-800 bg-red-100 rounded-md">
{error}
</div>
)}
{success && (
<div className="p-3 text-sm text-green-800 bg-green-100 rounded-md">
Message sent successfully!
</div>
)}
<div>
<label htmlFor="phoneNumber" className="block text-sm font-medium text-gray-700">
Phone Number (E.164 format)
</label>
<input
id="phoneNumber"
type="tel"
placeholder="+14155551234"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
<p className="mt-1 text-xs text-gray-500">
Include country code (e.g., +1 for US)
</p>
</div>
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={useTemplate}
onChange={(e) => setUseTemplate(e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm font-medium text-gray-700">
Use template message (for conversation initiation)
</span>
</label>
</div>
{useTemplate ? (
<div>
<label htmlFor="templateId" className="block text-sm font-medium text-gray-700">
Template ID
</label>
<input
id="templateId"
type="text"
placeholder="your_template_id"
value={templateId}
onChange={(e) => setTemplateId(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
<p className="mt-1 text-xs text-gray-500">
Template must be approved in Sinch dashboard
</p>
</div>
) : (
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700">
Message
</label>
<textarea
id="message"
rows={4}
placeholder="Type your message here..."
value={message}
onChange={(e) => setMessage(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
<p className="mt-1 text-xs text-gray-500">
Free-form messages only work within 24-hour customer service window
</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{loading ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}6.4. Auth Button Component
Create src/components/AuthButton.tsx:
'use client';
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
export default function AuthButton() {
const supabase = createClient();
const router = useRouter();
const handleSignOut = async () => {
await supabase.auth.signOut();
router.push('/auth/login');
router.refresh();
};
return (
<button
onClick={handleSignOut}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Sign Out
</button>
);
}7. Creating the Message Sending API with 24-Hour Window Support
Create the API endpoint for sending messages with 24-hour window validation.
Create src/app/api/messages/send/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { sinchClient } from '@/lib/sinch/client';
export async function POST(request: NextRequest) {
try {
const supabase = await createClient();
// Verify user is authenticated
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { phoneNumber, message, templateId, useTemplate } = body;
// Validate phone number (E.164 format)
const e164Regex = /^\+[1-9]\d{1,14}$/;
if (!e164Regex.test(phoneNumber)) {
return NextResponse.json(
{ error: 'Invalid phone number. Use E.164 format (e.g., +14155551234)' },
{ status: 400 }
);
}
// Find or create contact
let contact = await supabase
.from('contacts')
.select('*')
.eq('user_id', user.id)
.eq('phone_number', phoneNumber)
.single();
if (!contact.data) {
const { data: newContact, error: createError } = await supabase
.from('contacts')
.insert({
user_id: user.id,
phone_number: phoneNumber,
display_name: phoneNumber,
})
.select()
.single();
if (createError) {
throw new Error('Failed to create contact');
}
contact.data = newContact;
}
// Find or create conversation
let conversation = await supabase
.from('conversations')
.select('*')
.eq('contact_id', contact.data.id)
.eq('is_active', true)
.single();
if (!conversation.data) {
const { data: newConv, error: convError } = await supabase
.from('conversations')
.insert({
contact_id: contact.data.id,
})
.select()
.single();
if (convError) {
throw new Error('Failed to create conversation');
}
conversation.data = newConv;
}
// Check 24-hour window
const { data: windowCheck } = await supabase.rpc('is_within_24h_window', {
conversation_id: conversation.data.id,
});
const within24Hours = windowCheck === true;
// Determine message type and validate
let sinchResponse;
let messageType: 'text' | 'template';
let messageContent: any;
if (useTemplate || !within24Hours) {
// Use template message
if (!templateId) {
return NextResponse.json(
{
error: 'Template message required (outside 24-hour window or explicitly requested)',
within24Hours,
},
{ status: 400 }
);
}
sinchResponse = await sinchClient.sendTemplateMessage({
recipient: phoneNumber,
templateId,
languageCode: 'en_US',
parameters: {}, // Add template parameters as needed
contactId: contact.data.sinch_contact_id || undefined,
});
messageType = 'template';
messageContent = { template_id: templateId };
} else {
// Use free-form text message
if (!message) {
return NextResponse.json(
{ error: 'Message text is required' },
{ status: 400 }
);
}
sinchResponse = await sinchClient.sendTextMessage({
recipient: phoneNumber,
message,
contactId: contact.data.sinch_contact_id || undefined,
});
messageType = 'text';
messageContent = { text: message };
}
// Store message in database
await supabase.from('messages').insert({
conversation_id: conversation.data.id,
sinch_message_id: sinchResponse.message_id,
direction: 'outbound',
message_type: messageType,
content: messageContent,
status: 'pending',
});
// Update conversation timestamp
await supabase
.from('conversations')
.update({
last_outbound_message_at: new Date().toISOString(),
})
.eq('id', conversation.data.id);
return NextResponse.json({
success: true,
messageId: sinchResponse.message_id,
within24Hours,
usedTemplate: useTemplate || !within24Hours,
});
} catch (error) {
console.error('Error sending message:', error);
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Failed to send message',
},
{ status: 500 }
);
}
}8. Testing Your WhatsApp Integration
8.1. Local Development Setup
# Start Next.js development server
npm run dev
# In another terminal, start ngrok for webhook testing
ngrok http 30008.2. Configure Sinch Webhook
- Copy your ngrok HTTPS URL (e.g.,
https://abc123.ngrok.io) - Go to Sinch Dashboard → Conversation API → Webhooks
- Update webhook URL to:
https://abc123.ngrok.io/api/webhooks/sinch - Save configuration
8.3. Test Message Flow
- Sign up/Login: Navigate to
http://localhost:3000/auth/login - Send template message: Use the dashboard form with template checkbox enabled
- Reply from WhatsApp: Send a message from your phone to the WhatsApp Business number
- Check webhook: Verify webhook receives the message (check server logs)
- Send free-form reply: Reply within 24 hours using the dashboard
- Verify database: Check Supabase dashboard for stored messages
8.4. Common Testing Issues
Webhook not receiving events:
- Verify ngrok is running and URL is correct in Sinch dashboard
- Check Next.js server logs for incoming requests
- Ensure webhook secret matches
.env.local
Authentication failures:
- Verify Sinch Access Key and Secret are correct
- Check that secret is base64-encoded in authentication
- Confirm project ID and app ID are correct
24-hour window errors:
- Send a template message first to initiate conversation
- Wait for user to reply before sending free-form messages
- Check
last_inbound_message_attimestamp in database
9. Deploying to Production (Vercel)
9.1. Deploy to Vercel
# Install Vercel CLI
npm install -g vercel
# Deploy
vercel
# Set environment variables in Vercel dashboard
# Project Settings → Environment VariablesAdd all variables from .env.local to Vercel environment variables.
9.2. Update Sinch Webhook URL
After deployment, update webhook URL to production:
https://your-domain.vercel.app/api/webhooks/sinch
9.3. Production Considerations
Security:
- Enable Supabase RLS policies (already configured in migration)
- Use HTTPS only (Vercel provides this automatically)
- Rotate webhook secrets regularly
- Monitor failed authentication attempts
Performance:
- Implement message queueing for high volume (Redis/BullMQ)
- Add database connection pooling
- Enable Supabase connection pooler for Prisma
- Implement rate limiting on API routes
Monitoring:
- Set up Sentry or similar for error tracking
- Monitor Sinch API usage and costs
- Track webhook delivery failures
- Set up alerts for authentication failures
10. Common Issues and Troubleshooting Guide
10.1. Message Sending Failures
Error: "Outside 24-hour window"
- Cause: Attempting to send free-form message when user hasn't replied recently
- Solution: Use template message to reinitiate conversation or wait for user to reply
Error: "Invalid template ID"
- Cause: Template not approved or doesn't exist in Sinch
- Solution: Create and approve template in Sinch dashboard first
Error: "Authentication failed"
- Cause: Incorrect Access Key or Secret
- Solution: Verify credentials in Sinch dashboard, ensure secret is not base64-encoded in
.env.local(encoding happens in auth.ts)
10.2. Webhook Issues
Webhooks not arriving:
- Verify webhook URL is publicly accessible (use ngrok for local)
- Check Sinch dashboard webhook logs for delivery attempts
- Ensure webhook route doesn't have middleware blocking it
Signature verification failing:
- Confirm webhook secret matches Sinch dashboard configuration
- Check timestamp validation (must be within 5 minutes)
- Verify raw body is being used for signature calculation
10.3. Database Issues
RLS policy errors:
- Ensure user is authenticated when accessing data
- Verify
user_idmatchesauth.uid()in RLS policies - Use service role key only in server-side code, never client-side
Conversation window not tracking:
- Check
last_inbound_message_atis being updated by webhook handler - Verify
is_within_24h_windowfunction logic - Test with manual database timestamp updates
Frequently Asked Questions (FAQ)
How do I set up WhatsApp Business API with Sinch?
To set up WhatsApp Business API with Sinch, you need a postpay Sinch account, create a Conversation API app in the Sinch dashboard, provision a WhatsApp Business number (Sender ID), and generate access keys for authentication. Follow Section 1 for detailed setup instructions.
What is the WhatsApp 24-hour messaging window?
WhatsApp enforces a 24-hour customer service window for free-form messages. You can only send generic text messages within 24 hours of the customer's last inbound message. Outside this window, you must use pre-approved template messages to initiate conversations.
How do I create WhatsApp message templates in Sinch?
WhatsApp message templates are created and approved through the Sinch Customer Dashboard under Conversation API → Templates. Templates must follow WhatsApp's guidelines and receive Meta approval before use in production.
Can I use this integration with other messaging platforms?
Yes, the Sinch Conversation API supports multiple channels including SMS, RCS, Facebook Messenger, Viber, and more. You can extend this integration to support additional messaging platforms by configuring additional channel credentials in your Sinch app.
What are the costs of using WhatsApp Business API?
WhatsApp Business API pricing is based on conversation-based pricing from Meta, plus Sinch platform fees. Costs vary by country and conversation type (marketing, utility, authentication, service). Check the WhatsApp Business Platform pricing documentation for current rates.
Source Citations
Sinch Conversation API:
- Sinch Conversation API Overview - Official documentation for unified messaging platform
- Node.js SDK Reference - Official SDK syntax and authentication
- WhatsApp Channel Support - WhatsApp-specific configuration and limitations
- WhatsApp Template Messages - Template message formatting and parameters
- Send Messages with Node.js SDK - Message sending examples
Supabase Authentication:
- Setting up Server-Side Auth for Next.js - Official Supabase Next.js integration guide
- Supabase SSR Package Migration - Current recommended package (@supabase/ssr)
Next.js Documentation:
- Next.js App Router Authentication Guide - Official authentication patterns for App Router
- Route Handlers and Middleware - API route implementation
- Building APIs with Next.js - Best practices for API routes (February 2025)
WhatsApp Business Platform:
- WhatsApp Business API Overview - Sinch guide to WhatsApp Business API (April 2025)
- WhatsApp Business Platform Pricing - Meta pricing documentation