code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / sinch

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:

  1. User authentication via Supabase Auth with cookie-based sessions
  2. Send WhatsApp messages using Sinch Conversation API (both free-form and template messages)
  3. Receive WhatsApp messages through secure webhook endpoints with HMAC signature verification
  4. Database integration for storing conversations, messages, and tracking the 24-hour customer service window
  5. Real-time updates using Supabase Realtime subscriptions
  6. 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:

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 project
cd 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
  • zod enables 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:

bash
# 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:3000

Where 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: us or eu depending on where your Conversation API app was created

Create src/lib/env.ts:

typescript
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:

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)

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

  1. Go to your Supabase project dashboard
  2. Navigate to SQL Editor
  3. Copy and paste the migration SQL
  4. Click "Run"

2.3. Enable Realtime (Optional)

For real-time message updates in the UI:

  1. Go to Database → Replication in Supabase dashboard
  2. Enable replication for messages table
  3. 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:

typescript
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:

typescript
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:

typescript
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:

typescript
'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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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

  1. Log in to Sinch Dashboard
  2. Navigate to Conversation API → Webhooks
  3. Click "Create new webhook"
  4. 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"
  5. 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.

6.1. Dashboard Page

Create src/app/dashboard/page.tsx:

typescript
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:

typescript
'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:

typescript
'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:

typescript
'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:

typescript
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

bash
# Start Next.js development server
npm run dev

# In another terminal, start ngrok for webhook testing
ngrok http 3000

8.2. Configure Sinch Webhook

  1. Copy your ngrok HTTPS URL (e.g., https://abc123.ngrok.io)
  2. Go to Sinch Dashboard → Conversation API → Webhooks
  3. Update webhook URL to: https://abc123.ngrok.io/api/webhooks/sinch
  4. Save configuration

8.3. Test Message Flow

  1. Sign up/Login: Navigate to http://localhost:3000/auth/login
  2. Send template message: Use the dashboard form with template checkbox enabled
  3. Reply from WhatsApp: Send a message from your phone to the WhatsApp Business number
  4. Check webhook: Verify webhook receives the message (check server logs)
  5. Send free-form reply: Reply within 24 hours using the dashboard
  6. 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_at timestamp in database

9. Deploying to Production (Vercel)

9.1. Deploy to Vercel

bash
# Install Vercel CLI
npm install -g vercel

# Deploy
vercel

# Set environment variables in Vercel dashboard
# Project Settings → Environment Variables

Add 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_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.


Source Citations

Sinch Conversation API:

Supabase Authentication:

Next.js Documentation:

WhatsApp Business Platform: