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.
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.
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:
Code
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
(Development) Webhook Testing:ngrok or similar tool to expose local server for webhook testing
Prerequisites Verification:
Bash
# 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/projects
Final 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
Bash
# 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 projectcd sinch-whatsapp-app
When 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
Bash
# 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 zod
Dependency Notes:
@supabase/ssr is the current recommended package (replaces deprecated @supabase/auth-helpers-nextjs)
axios provides cleaner error handling and interceptors compared to native fetch
-- Enable UUID extensionCREATE EXTENSION IF NOTEXISTS "uuid-ossp";
-- Contacts table: stores WhatsApp contact informationCREATE TABLE contacts (
id UUID PRIMARY KEYDEFAULT uuid_generate_v4(),
user_id UUID REFERENCES auth.users(id) ONDELETE 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 windowCREATE TABLE conversations (
id UUID PRIMARY KEYDEFAULT uuid_generate_v4(),
contact_id UUID REFERENCES contacts(id) ONDELETE CASCADE NOT NULL,
sinch_conversation_id TEXT UNIQUE,
last_inbound_message_at TIMESTAMPTZ,
last_outbound_message_at TIMESTAMPTZ,
is_active BOOLEANDEFAULTTRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Messages table: stores all messages (inbound and outbound)CREATE TABLE messages (
id UUID PRIMARY KEYDEFAULT uuid_generate_v4(),
conversation_id UUID REFERENCES conversations(id) ONDELETE CASCADE NOT NULL,
sinch_message_id TEXT UNIQUENOT NULL,
direction TEXT NOT NULLCHECK (direction IN ('inbound', 'outbound')),
message_type TEXT NOT NULLCHECK (message_type IN ('text', 'template', 'media', 'interactive')),
content JSONB NOT NULL,
status TEXT NOT NULLDEFAULT'pending'CHECK (status IN ('pending', 'delivered', 'read', 'failed')),
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes for performanceCREATE 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 windowCREATEOR REPLACE FUNCTION is_within_24h_window(conversation_id UUID)
RETURNSBOOLEANAS $$
DECLARE
last_inbound TIMESTAMPTZ;
BEGINSELECT last_inbound_message_at INTO last_inbound
FROM conversations
WHERE id = conversation_id;
-- If no inbound message, window is closed
IF last_inbound ISNULLTHENRETURNFALSE;
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 timestampCREATEOR REPLACE FUNCTION update_updated_at_column()
RETURNSTRIGGERAS $$
BEGIN
NEW.updated_at = NOW();
RETURNNEW;
END;
$$ LANGUAGE plpgsql;
-- Triggers for updated_atCREATETRIGGER update_contacts_updated_at BEFORE UPDATEON contacts
FOREACHROWEXECUTEFUNCTION update_updated_at_column();
CREATETRIGGER update_conversations_updated_at BEFORE UPDATEON conversations
FOREACHROWEXECUTEFUNCTION update_updated_at_column();
CREATETRIGGER update_messages_updated_at BEFORE UPDATEON messages
FOREACHROWEXECUTEFUNCTION update_updated_at_column();
-- Row Level Security (RLS) policiesALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- Contacts policiesCREATE POLICY "Users can view own contacts"
ON contacts FORSELECTUSING (auth.uid() = user_id);
CREATE POLICY "Users can insert own contacts"
ON contacts FORINSERTWITHCHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own contacts"
ON contacts FORUPDATEUSING (auth.uid() = user_id);
-- Conversations policiesCREATE POLICY "Users can view own conversations"
ON conversations FORSELECTUSING (contact_id IN (SELECT id FROM contacts WHERE user_id = auth.uid()));
CREATE POLICY "Users can insert own conversations"
ON conversations FORINSERTWITHCHECK (contact_id IN (SELECT id FROM contacts WHERE user_id = auth.uid()));
CREATE POLICY "Users can update own conversations"
ON conversations FORUPDATEUSING (contact_id IN (SELECT id FROM contacts WHERE user_id = auth.uid()));
-- Messages policiesCREATE POLICY "Users can view own messages"
ON messages FORSELECTUSING (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 FORINSERTWITHCHECK (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 FORUPDATEUSING (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)
Bash
# Install Supabase CLI
npm install -g supabase
# Link to your project
supabase link --project-ref your-project-ref
# Run migration
supabase db push
Option 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 messages table
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:
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"
Save webhook configuration
For local development with ngrok:
Bash
# 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/sinch
6. Building the WhatsApp Dashboard User Interface
Create the user interface for sending and viewing WhatsApp messages.
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_id matches auth.uid() in RLS policies
Use service role key only in server-side code, never client-side
Conversation window not tracking:
Check last_inbound_message_at is being updated by webhook handler
Verify is_within_24h_window function 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.
Use the Vonage SMS API with the Node.js SDK and the Express framework. Set up an Express server with an endpoint to handle incoming requests, validate input, and send messages using the SDK's vonage.sms.send method. Initialize the Vonage SDK with API credentials from environment variables.
The Vonage API, accessed via its Node.js SDK, enables sending SMS messages, receiving inbound SMS, making and receiving calls, and more. It's a Communication Platform as a Service (CPaaS) that simplifies integrating communication functionalities into Node.js applications.
Dotenv loads environment variables from a .env file into process.env, allowing you to store sensitive data like Vonage API keys outside your codebase. This enhances security by preventing accidental exposure of credentials in version control.
A database is not strictly required for basic SMS sending through Vonage. A database becomes useful when you need to store message logs for analytics or auditing, manage scheduled messages, keep track of user preferences, or handle delivery receipts and callbacks.
Yes, Vonage supports alphanumeric sender IDs (like "MyBrand") for enhanced branding. However, support and regulations vary by country and carrier. Some countries don't allow them, and they often can't receive replies. Always verify compatibility with Vonage's documentation and local regulations.
Store your Vonage API Key, API Secret, and virtual number in a .env file. Use the dotenv package to load these variables into process.env within your Node.js application. Then, use these environment variables to initialize the Vonage Node.js SDK client.
A Vonage virtual number is a phone number you purchase through Vonage that can be used as a sender ID for your SMS messages. This gives a recognizable and consistent identity to your outgoing communications.
Wrap the Vonage API call within a try...catch block in your Express route handler. Log the error for debugging, and return an appropriate error response (e.g., 500 status code) to the client with a descriptive message derived from the caught error object.
A typical project structure includes an index.js as the main entry point for the Express server, a vonageService.js module to encapsulate Vonage SDK interactions, a .env file for credentials, and a .gitignore file for excluding sensitive files from version control.
This error usually occurs with trial Vonage accounts. Add the recipient's phone number to your whitelisted test numbers in the Vonage Dashboard. Vonage will send a verification code; enter it in the dashboard to whitelist the number.
The express.json() middleware is crucial for parsing incoming requests with a JSON payload (Content-Type: application/json). It parses the request body and makes the data available in req.body within your Express route handlers.
Use a library like libphonenumber-js for robust phone number validation. This ensures the numbers are in a valid format (ideally E.164) and can help prevent issues with sending messages to invalid numbers. Basic format validation within the API handler also provides another layer of filtering.
Standard SMS messages have limits (160 for GSM-7 encoding, 70 for UCS-2 if using extended characters). Vonage handles segmentation for longer messages automatically, but be mindful of costs as each segment is billed.
Vonage's SMS API uses '0' to indicate a successfully submitted message. Any other status code represents an error that requires investigation, even if the HTTP request to the API is successful.
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:
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:
System Architecture:
Prerequisites:
node --versionPrerequisites 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:
src/directory: Yes1.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 validation1.3. Project Structure
Create the following directory structure:
1.4. Environment Variables
Create
.env.localin 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:
usoreudepending on where your Conversation API app was created1.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
2.3. Enable Realtime (Optional)
For real-time message updates in the UI:
messagestable3. 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.tsfor 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.tsfor 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
https://your-domain.com/api/webhooks/sinch(use ngrok URL for local development)SINCH_WEBHOOK_SECRET)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
https://abc123.ngrok.io)https://abc123.ngrok.io/api/webhooks/sinch8.3. Test Message Flow
http://localhost:3000/auth/login8.4. Common Testing Issues
Webhook not receiving events:
.env.localAuthentication failures:
24-hour window errors:
last_inbound_message_attimestamp in database9. 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.localto Vercel environment variables.9.2. Update Sinch Webhook URL
After deployment, update webhook URL to production:
https://your-domain.vercel.app/api/webhooks/sinch9.3. Production Considerations
Security:
Performance:
Monitoring:
10. Common Issues and Troubleshooting Guide
10.1. Message Sending Failures
Error: "Outside 24-hour window"
Error: "Invalid template ID"
Error: "Authentication failed"
.env.local(encoding happens in auth.ts)10.2. Webhook Issues
Webhooks not arriving:
Signature verification failing:
10.3. Database Issues
RLS policy errors:
user_idmatchesauth.uid()in RLS policiesConversation window not tracking:
last_inbound_message_atis being updated by webhook handleris_within_24h_windowfunction logicFrequently 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:
Supabase Authentication:
Next.js Documentation:
WhatsApp Business Platform: