code examples

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

SMS Marketing Campaigns with Sinch, Next.js & Supabase: Complete Tutorial

Build production-ready SMS marketing campaign management with Sinch Messages API, Next.js, and Supabase. Learn bulk SMS sending, opt-out handling, delivery tracking, and campaign analytics with PostgreSQL.

Build SMS Marketing Campaigns with Vonage, Express, and PostgreSQL: Complete Production Guide

Build a robust SMS marketing campaign system using Sinch Messages API, Next.js, and Supabase PostgreSQL. This comprehensive guide covers everything from initial setup and bulk SMS sending to implementing TCPA-compliant opt-out handling, real-time delivery tracking, and production deployment.

By the end of this tutorial, you will have a functional SMS marketing platform capable of:

  • Managing a contact list with opt-out status in Supabase PostgreSQL
  • Creating SMS marketing campaigns targeting specific contacts
  • Sending bulk SMS messages reliably via the Sinch Messages API
  • Receiving and processing message status updates (delivery receipts)
  • Handling inbound messages for opt-out requests (e.g., "STOP")
  • Implementing basic security, logging, and error handling
  • Tracking campaign analytics and engagement metrics

This guide provides a solid foundation for a production-ready system, going beyond simple send/receive examples.

Target Audience: Developers familiar with Node.js, Next.js API Routes, REST APIs, SQL databases, and basic web security concepts.

Technologies Used:

  • Next.js: React framework with built-in API routes and server-side rendering
  • Sinch Messages API: Unified API for sending and receiving messages across various channels (we focus on SMS)
  • Supabase: Open-source Firebase alternative providing PostgreSQL database, authentication, and real-time subscriptions
  • @sinch/sdk-core: Official Sinch Node.js SDK
  • @supabase/supabase-js: Supabase JavaScript client
  • dotenv: Module for loading environment variables
  • uuid: Generates unique identifiers
  • ngrok: Exposes local development server to the internet for webhook testing

Required Package Versions:

PackageMinimum VersionRecommended
Node.js16.x18.x LTS or 20.x LTS
Next.js13.0.0Latest 14.x or 15.x
@sinch/sdk-core1.0.0Latest 1.x
@supabase/supabase-js2.38.0Latest 2.x

System Architecture:

text
+-----------------+      +---------------------+      +----------------+
|  Admin / User   |----->| Next.js API Routes  |----->|   Supabase     |
| (e.g., Postman) |      |      (Backend)      |<-----|   PostgreSQL   |
+-----------------+      +---------------------+      +----------------+
                          |          ^
                          |          | (Webhook Callbacks)
                          v          |
                     +-----------------+
                     | Sinch Messages  |
                     |      API        |
                     +-----------------+
                          |          ^
                          | (SMS)    | (SMS)
                          v          |
                     +-----------------+
                     |   User's Phone  |
                     +-----------------+

Data Flow:

  1. Admin creates campaign via Next.js API (REST request)
  2. API stores campaign in Supabase PostgreSQL
  3. Admin triggers campaign send via /api/campaigns/:id/send endpoint
  4. Backend processes contacts asynchronously, sends SMS via Sinch API
  5. Sinch delivers SMS to users' phones
  6. Users receive SMS; some may reply "STOP"
  7. Sinch sends delivery status webhooks to /api/webhooks/status
  8. Sinch sends inbound messages to /api/webhooks/inbound
  9. Backend updates message status and opt-out status in Supabase

Prerequisites:

1. Setting up the Project

Create the project structure, install dependencies, and configure the environment.

1.1 Create Next.js Project

Open your terminal and run:

bash
# Create Next.js project with TypeScript support
npx create-next-app@latest sinch-sms-campaign-app
cd sinch-sms-campaign-app

# Select the following options during setup:
# ✓ Would you like to use TypeScript? Yes
# ✓ Would you like to use ESLint? Yes
# ✓ Would you like to use Tailwind CSS? Yes (optional)
# ✓ Would you like to use `src/` directory? Yes
# ✓ Would you like to use App Router? Yes
# ✓ Would you like to customize the default import alias? No

1.2 Install Dependencies

Install the necessary libraries:

bash
npm install @sinch/sdk-core @supabase/supabase-js uuid dotenv
npm install --save-dev @types/uuid
  • @sinch/sdk-core: Sinch SDK for Node.js
  • @supabase/supabase-js: Supabase client library
  • uuid: Generates unique IDs (e.g., for messages)
  • dotenv: Loads environment variables from a .env.local file

1.3 Project Structure

Create the following directory structure within your project root:

text
.
├── src/
│   ├── app/
│   │   ├── api/
│   │   │   ├── contacts/
│   │   │   │   └── route.ts
│   │   │   ├── campaigns/
│   │   │   │   ├── route.ts
│   │   │   │   └── [id]/
│   │   │   │       ├── route.ts
│   │   │   │       ├── send/
│   │   │   │       │   └── route.ts
│   │   │   │       └── messages/
│   │   │   │           └── route.ts
│   │   │   └── webhooks/
│   │   │       ├── inbound/
│   │   │       │   └── route.ts
│   │   │       └── status/
│   │   │           └── route.ts
│   │   ├── layout.tsx
│   │   └── page.tsx
│   ├── lib/
│   │   ├── supabase.ts      # Supabase client initialization
│   │   ├── sinch.ts          # Sinch client initialization
│   │   ├── logger.ts         # Simple logger utility
│   │   └── types.ts          # TypeScript type definitions
│   └── services/
│       ├── contactService.ts
│       ├── campaignService.ts
│       └── sinchService.ts
├── .env.local              # Environment variables (DO NOT COMMIT)
├── .gitignore
├── package.json
└── tsconfig.json

1.4 Environment Configuration (.env.local)

Create a file named .env.local in the project root. This file stores sensitive credentials and configuration settings. Never commit this file to version control.

Environment variables follow the 12-factor app methodology, separating config from code for portability and security.

dotenv
# .env.local

# Sinch API Credentials
SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID
SINCH_KEY_ID=YOUR_SINCH_KEY_ID
SINCH_KEY_SECRET=YOUR_SINCH_KEY_SECRET
SINCH_NUMBER=YOUR_SINCH_VIRTUAL_NUMBER # The number used for sending SMS

# 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

# Server Configuration
PORT=3000

# Basic API Key Authentication
API_KEY=your-secret-api-key # Change this to a strong, random key

# Base URL for webhooks (will be ngrok URL during development)
NEXT_PUBLIC_BASE_URL=http://localhost:3000

Generate a secure random API key:

bash
# Option 1: Using openssl
openssl rand -hex 32

# Option 2: Using Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

# Option 3: Using uuid
npx uuid

Use the output as your API_KEY value.

  • Obtaining Sinch Credentials:
    • Go to your Sinch Dashboard
    • Navigate to Settings > API Credentials
    • Copy your Project ID, Key ID, and Key Secret
    • Purchase a virtual number under Numbers section
  • Obtaining Supabase Credentials:
    • Go to your Supabase Dashboard
    • Select or create a project
    • Navigate to Settings > API
    • Copy your Project URL, anon public key, and service_role secret key
  • API_KEY: A simple key for basic API authentication (replace with a secure, generated key).

1.5 Git Ignore (.gitignore)

Ensure your .gitignore file (created by Next.js) includes:

text
# Environment variables
.env*.local
.env

# Dependencies
node_modules/

# Next.js
.next/
out/

# Logs
logs/
*.log
npm-debug.log*

# OS generated files
.DS_Store
Thumbs.db

1.6 Configure Webhooks with ngrok

Sinch webhooks require publicly accessible URLs. During development, use ngrok to expose your local server.

Start ngrok:

bash
# Start your Next.js server first
npm run dev

# In a separate terminal, start ngrok pointing to your local port
ngrok http 3000

Copy the ngrok URL (e.g., https://abc123.ngrok.io).

Update Sinch Dashboard:

  1. Go to Sinch Dashboard
  2. Navigate to SMS > Webhooks
  3. Configure the following webhooks:
    • Inbound Messages Webhook URL: https://abc123.ngrok.io/api/webhooks/inbound
    • Delivery Reports Webhook URL: https://abc123.ngrok.io/api/webhooks/status
  4. Save the configuration

Note: ngrok URLs change on restart unless you have a paid account with reserved domains. Update the webhooks each time you restart ngrok.

2. Implementing Core Functionality

Set up the Supabase connection, Sinch client, and basic logging utilities.

2.1 Basic Logger (src/lib/logger.ts)

A simple logger for now. For production applications, upgrade to winston or pino for features like log levels, file rotation, structured logging, and transport to log aggregation services (ELK stack, Datadog, CloudWatch).

typescript
// src/lib/logger.ts
export const logger = {
    info: (message: string, ...args: any[]) => {
        console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
    },
    error: (message: string, ...args: any[]) => {
        console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
    },
    warn: (message: string, ...args: any[]) => {
        console.warn(`[WARN] ${new Date().toISOString()} - ${message}`, ...args);
    }
};

2.2 TypeScript Types (src/lib/types.ts)

Define common types for type safety:

typescript
// src/lib/types.ts
export interface Contact {
    id: string;
    phone_number: string;
    first_name?: string;
    last_name?: string;
    opted_out: boolean;
    created_at: string;
    updated_at: string;
}

export interface Campaign {
    id: string;
    name: string;
    message_text: string;
    created_at: string;
    scheduled_at?: string;
    status: 'draft' | 'sending' | 'completed' | 'failed';
}

export interface Message {
    id: string;
    campaign_id?: string;
    contact_id: string;
    sinch_message_id?: string;
    status: 'submitted' | 'delivered' | 'expired' | 'failed' | 'rejected' | 'accepted' | 'unknown';
    status_timestamp?: string;
    error_code?: number;
    error_message?: string;
    sent_at: string;
    updated_at: string;
}

2.3 Supabase Client Initialization (src/lib/supabase.ts)

Set up the Supabase client using credentials from the .env.local file:

typescript
// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
import { logger } from './logger';

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;

if (!supabaseUrl || !supabaseServiceRoleKey) {
    logger.error('Missing Supabase environment variables');
    throw new Error('Missing required Supabase credentials');
}

// Use service role key for server-side operations
export const supabase = createClient(supabaseUrl, supabaseServiceRoleKey, {
    auth: {
        autoRefreshToken: false,
        persistSession: false
    }
});

// Test connection
supabase.from('contacts').select('count', { count: 'exact', head: true })
    .then(() => logger.info('Supabase connection established'))
    .catch(err => logger.error('Supabase connection failed:', err));

2.4 Sinch Client Initialization (src/lib/sinch.ts)

Initialize the Sinch SDK using credentials from the .env.local file:

typescript
// src/lib/sinch.ts
import { SinchClient } from '@sinch/sdk-core';
import { logger } from './logger';

const projectId = process.env.SINCH_PROJECT_ID!;
const keyId = process.env.SINCH_KEY_ID!;
const keySecret = process.env.SINCH_KEY_SECRET!;

if (!projectId || !keyId || !keySecret) {
    logger.error('Missing Sinch environment variables');
    throw new Error('Missing required Sinch credentials');
}

export const sinchClient = new SinchClient({
    projectId,
    keyId,
    keySecret
});

logger.info('Sinch client initialized');

3. Creating a Database Schema and Data Layer

Create tables in Supabase PostgreSQL to store contacts, campaigns, and individual message statuses.

3.1 Database Schema (SQL)

Execute these SQL commands in your Supabase SQL Editor (Dashboard > SQL Editor).

Enable UUID Extension:

sql
-- Enable uuid-ossp extension if not already enabled
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

Create Tables:

sql
-- contacts table
-- IMPORTANT: Phone numbers MUST be stored in E.164 format (e.g., +14155552671)
-- E.164 is the international telephone numbering standard (ITU-T Recommendation E.164)
-- Format: + [country code] [subscriber number including area code]
-- Maximum length: 15 digits (excluding the + symbol)
CREATE TABLE contacts (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    phone_number VARCHAR(20) UNIQUE NOT NULL,
    first_name VARCHAR(100),
    last_name VARCHAR(100),
    opted_out BOOLEAN DEFAULT FALSE NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
    updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);

-- campaigns table
CREATE TABLE campaigns (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    name VARCHAR(255) NOT NULL,
    message_text TEXT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
    scheduled_at TIMESTAMPTZ,
    status VARCHAR(20) DEFAULT 'draft' NOT NULL
);

-- messages table (to track individual SMS status)
CREATE TABLE messages (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    campaign_id UUID REFERENCES campaigns(id) ON DELETE SET NULL,
    contact_id UUID REFERENCES contacts(id) ON DELETE CASCADE,
    sinch_message_id VARCHAR(100) UNIQUE,
    status VARCHAR(20) DEFAULT 'submitted' NOT NULL,
    status_timestamp TIMESTAMPTZ,
    error_code INTEGER,
    error_message TEXT,
    sent_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
    updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);

-- Add indexes for performance
CREATE INDEX idx_contacts_phone_number ON contacts(phone_number);
CREATE INDEX idx_messages_sinch_id ON messages(sinch_message_id);
CREATE INDEX idx_messages_campaign_id ON messages(campaign_id);
CREATE INDEX idx_messages_contact_id ON messages(contact_id);

-- Function to automatically update updated_at timestamps
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_messages_updated_at BEFORE UPDATE
ON messages FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

3.2 Data Services (src/services/)

Create service files to handle database interactions.

Contact Service (src/services/contactService.ts):

typescript
// src/services/contactService.ts
import { supabase } from '@/lib/supabase';
import { Contact } from '@/lib/types';

export const contactService = {
    async create(data: { phone_number: string; first_name?: string; last_name?: string }): Promise<Contact> {
        const { data: contact, error } = await supabase
            .from('contacts')
            .insert([{ ...data, opted_out: false }])
            .select()
            .single();

        if (error) throw error;
        return contact;
    },

    async findById(id: string): Promise<Contact | null> {
        const { data, error } = await supabase
            .from('contacts')
            .select('*')
            .eq('id', id)
            .single();

        if (error && error.code !== 'PGRST116') throw error; // Ignore not found
        return data;
    },

    async findByPhoneNumber(phone_number: string): Promise<Contact | null> {
        const { data, error } = await supabase
            .from('contacts')
            .select('*')
            .eq('phone_number', phone_number)
            .single();

        if (error && error.code !== 'PGRST116') throw error;
        return data;
    },

    async getAll(limit: number = 100, offset: number = 0): Promise<Contact[]> {
        const { data, error } = await supabase
            .from('contacts')
            .select('*')
            .order('created_at', { ascending: false })
            .range(offset, offset + limit - 1);

        if (error) throw error;
        return data || [];
    },

    async updateOptOut(id: string, opted_out: boolean): Promise<Contact> {
        const { data, error } = await supabase
            .from('contacts')
            .update({ opted_out })
            .eq('id', id)
            .select()
            .single();

        if (error) throw error;
        return data;
    }
};

Campaign Service (src/services/campaignService.ts):

typescript
// src/services/campaignService.ts
import { supabase } from '@/lib/supabase';
import { Campaign, Contact, Message } from '@/lib/types';
import { sinchService } from './sinchService';
import { logger } from '@/lib/logger';

// Throttling configuration to respect Sinch rate limits
const SEND_DELAY_MS = 150; // 150ms delay = ~6.6 messages/second
const MAX_CONCURRENT = 5; // Maximum concurrent sends

const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

export const campaignService = {
    async create(data: { name: string; message_text: string }): Promise<Campaign> {
        const { data: campaign, error } = await supabase
            .from('campaigns')
            .insert([data])
            .select()
            .single();

        if (error) throw error;
        return campaign;
    },

    async findById(id: string): Promise<Campaign | null> {
        const { data, error } = await supabase
            .from('campaigns')
            .select('*')
            .eq('id', id)
            .single();

        if (error && error.code !== 'PGRST116') throw error;
        return data;
    },

    async getAll(limit: number = 100, offset: number = 0): Promise<Campaign[]> {
        const { data, error } = await supabase
            .from('campaigns')
            .select('*')
            .order('created_at', { ascending: false })
            .range(offset, offset + limit - 1);

        if (error) throw error;
        return data || [];
    },

    async updateStatus(id: string, status: Campaign['status']): Promise<Campaign> {
        const { data, error } = await supabase
            .from('campaigns')
            .update({ status })
            .eq('id', id)
            .select()
            .single();

        if (error) throw error;
        return data;
    },

    async sendCampaignToAll(campaignId: string): Promise<void> {
        try {
            // Update campaign status to 'sending'
            await this.updateStatus(campaignId, 'sending');
            logger.info(`Campaign ${campaignId} status: sending`);

            const campaign = await this.findById(campaignId);
            if (!campaign) {
                throw new Error(`Campaign ${campaignId} not found`);
            }

            // Get all non-opted-out contacts
            const { data: allContacts, error } = await supabase
                .from('contacts')
                .select('*')
                .eq('opted_out', false)
                .limit(10000);

            if (error) throw error;

            const eligibleContacts = allContacts || [];

            if (eligibleContacts.length === 0) {
                logger.warn(`Campaign ${campaignId} has no eligible contacts`);
                await this.updateStatus(campaignId, 'completed');
                return;
            }

            logger.info(`Sending campaign ${campaignId} to ${eligibleContacts.length} contacts`);

            let successCount = 0;
            let failureCount = 0;

            // Process contacts with controlled concurrency and throttling
            for (let i = 0; i < eligibleContacts.length; i += MAX_CONCURRENT) {
                const batch = eligibleContacts.slice(i, i + MAX_CONCURRENT);

                await Promise.all(batch.map(async (contact: Contact) => {
                    try {
                        const result = await sinchService.sendSms(
                            contact.phone_number,
                            process.env.SINCH_NUMBER!,
                            campaign.message_text
                        );

                        if (result.success) {
                            await supabase.from('messages').insert([{
                                campaign_id: campaignId,
                                contact_id: contact.id,
                                sinch_message_id: result.message_id,
                                status: 'submitted'
                            }]);
                            successCount++;
                            logger.info(`Message sent to ${contact.phone_number} (${successCount}/${eligibleContacts.length})`);
                        } else {
                            await supabase.from('messages').insert([{
                                campaign_id: campaignId,
                                contact_id: contact.id,
                                status: 'failed',
                                error_code: result.error_code,
                                error_message: result.error_message
                            }]);
                            failureCount++;
                            logger.error(`Failed to send to ${contact.phone_number}: ${result.error_message}`);
                        }
                    } catch (error) {
                        failureCount++;
                        logger.error(`Error sending to ${contact.phone_number}:`, error);

                        await supabase.from('messages').insert([{
                            campaign_id: campaignId,
                            contact_id: contact.id,
                            status: 'failed',
                            error_message: error instanceof Error ? error.message : 'Unknown error'
                        }]);
                    }
                }));

                // Throttle between batches
                if (i + MAX_CONCURRENT < eligibleContacts.length) {
                    await delay(SEND_DELAY_MS * MAX_CONCURRENT);
                }
            }

            // Update campaign status to completed
            await this.updateStatus(campaignId, 'completed');
            logger.info(`Campaign ${campaignId} completed. Success: ${successCount}, Failed: ${failureCount}`);

        } catch (error) {
            logger.error(`Campaign ${campaignId} processing failed:`, error);
            await this.updateStatus(campaignId, 'failed');
        }
    },

    async getCampaignMessages(campaignId: string): Promise<any[]> {
        const { data, error } = await supabase
            .from('messages')
            .select(`
                *,
                contacts!inner(phone_number)
            `)
            .eq('campaign_id', campaignId)
            .order('sent_at', { ascending: true });

        if (error) throw error;
        return data || [];
    }
};

Sinch Service (src/services/sinchService.ts):

typescript
// src/services/sinchService.ts
import { sinchClient } from '@/lib/sinch';
import { logger } from '@/lib/logger';

export const sinchService = {
    async sendSms(
        toNumber: string,
        fromNumber: string,
        text: string
    ): Promise<{ success: boolean; message_id?: string; error_code?: number; error_message?: string }> {
        try {
            const response = await sinchClient.sms.batches.send({
                to: [toNumber],
                from: fromNumber,
                body: text
            });

            logger.info(`SMS sent successfully. Batch ID: ${response.id}`);

            return {
                success: true,
                message_id: response.id
            };
        } catch (error: any) {
            logger.error('Sinch API error:', error);

            return {
                success: false,
                error_code: error.statusCode || 500,
                error_message: error.message || 'Unknown error'
            };
        }
    }
};

4. Building the API Layer (Next.js API Routes)

Define the RESTful API endpoints using Next.js API Routes for managing contacts, campaigns, and handling webhooks.

4.1 Authentication Middleware

Create a utility function for API key authentication:

typescript
// src/lib/auth.ts
import { NextRequest, NextResponse } from 'next/server';
import { logger } from './logger';

export function authenticateApiKey(request: NextRequest): NextResponse | null {
    const apiKey = request.headers.get('x-api-key');

    if (!apiKey) {
        logger.warn('Authentication failed: No API key provided');
        return NextResponse.json(
            {
                error: 'Unauthorized',
                message: 'API key required. Include your API key in the x-api-key header.'
            },
            { status: 401 }
        );
    }

    if (apiKey !== process.env.API_KEY) {
        logger.warn('Authentication failed: Invalid API key');
        return NextResponse.json(
            {
                error: 'Forbidden',
                message: 'Your API key is incorrect or expired.'
            },
            { status: 403 }
        );
    }

    return null; // Authentication successful
}

4.2 Contact Routes (src/app/api/contacts/route.ts)

typescript
// src/app/api/contacts/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { contactService } from '@/services/contactService';
import { authenticateApiKey } from '@/lib/auth';
import { logger } from '@/lib/logger';

export async function POST(request: NextRequest) {
    const authError = authenticateApiKey(request);
    if (authError) return authError;

    try {
        const body = await request.json();
        const { phone_number, first_name, last_name } = body;

        if (!phone_number || !phone_number.match(/^\+[1-9]\d{1,14}$/)) {
            return NextResponse.json(
                { error: 'Phone number must be in E.164 format (e.g., +14155552671)' },
                { status: 400 }
            );
        }

        const existingContact = await contactService.findByPhoneNumber(phone_number);
        if (existingContact) {
            return NextResponse.json(
                { error: 'A contact with this phone number already exists' },
                { status: 409 }
            );
        }

        const contact = await contactService.create({ phone_number, first_name, last_name });
        logger.info(`Contact created: ${contact.id}`);

        return NextResponse.json(contact, { status: 201 });
    } catch (error) {
        logger.error('Error creating contact:', error);
        return NextResponse.json(
            { error: 'Internal server error' },
            { status: 500 }
        );
    }
}

export async function GET(request: NextRequest) {
    const authError = authenticateApiKey(request);
    if (authError) return authError;

    try {
        const searchParams = request.nextUrl.searchParams;
        const limit = parseInt(searchParams.get('limit') || '100');
        const offset = parseInt(searchParams.get('offset') || '0');

        const contacts = await contactService.getAll(limit, offset);

        return NextResponse.json({
            data: contacts,
            pagination: {
                limit,
                offset,
                returned: contacts.length
            }
        });
    } catch (error) {
        logger.error('Error fetching contacts:', error);
        return NextResponse.json(
            { error: 'Internal server error' },
            { status: 500 }
        );
    }
}

4.3 Campaign Routes

List/Create Campaigns (src/app/api/campaigns/route.ts):

typescript
// src/app/api/campaigns/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { campaignService } from '@/services/campaignService';
import { authenticateApiKey } from '@/lib/auth';
import { logger } from '@/lib/logger';

export async function POST(request: NextRequest) {
    const authError = authenticateApiKey(request);
    if (authError) return authError;

    try {
        const body = await request.json();
        const { name, message_text } = body;

        if (!name || !message_text) {
            return NextResponse.json(
                { error: 'Campaign name and message text are required' },
                { status: 400 }
            );
        }

        if (message_text.length > 1600) {
            return NextResponse.json(
                { error: 'SMS message must be 1600 characters or less' },
                { status: 400 }
            );
        }

        const campaign = await campaignService.create({ name, message_text });
        logger.info(`Campaign created: ${campaign.id}`);

        return NextResponse.json(campaign, { status: 201 });
    } catch (error) {
        logger.error('Error creating campaign:', error);
        return NextResponse.json(
            { error: 'Internal server error' },
            { status: 500 }
        );
    }
}

export async function GET(request: NextRequest) {
    const authError = authenticateApiKey(request);
    if (authError) return authError;

    try {
        const searchParams = request.nextUrl.searchParams;
        const limit = parseInt(searchParams.get('limit') || '100');
        const offset = parseInt(searchParams.get('offset') || '0');

        const campaigns = await campaignService.getAll(limit, offset);

        return NextResponse.json({
            data: campaigns,
            pagination: {
                limit,
                offset,
                returned: campaigns.length
            }
        });
    } catch (error) {
        logger.error('Error fetching campaigns:', error);
        return NextResponse.json(
            { error: 'Internal server error' },
            { status: 500 }
        );
    }
}

Send Campaign (src/app/api/campaigns/[id]/send/route.ts):

typescript
// src/app/api/campaigns/[id]/send/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { campaignService } from '@/services/campaignService';
import { authenticateApiKey } from '@/lib/auth';
import { logger } from '@/lib/logger';

export async function POST(
    request: NextRequest,
    { params }: { params: { id: string } }
) {
    const authError = authenticateApiKey(request);
    if (authError) return authError;

    try {
        const campaignId = params.id;
        const campaign = await campaignService.findById(campaignId);

        if (!campaign) {
            return NextResponse.json(
                { error: 'Campaign not found' },
                { status: 404 }
            );
        }

        if (campaign.status !== 'draft') {
            return NextResponse.json(
                { error: `Campaign cannot be sent. Current status: ${campaign.status}` },
                { status: 400 }
            );
        }

        logger.info(`Initiating campaign send: ${campaignId}`);

        // Execute sending asynchronously
        campaignService.sendCampaignToAll(campaignId)
            .then(() => logger.info(`Campaign ${campaignId} processing started`))
            .catch(err => logger.error(`Error during campaign ${campaignId} processing:`, err));

        // Immediately respond to client
        return NextResponse.json({
            message: 'Campaign send initiated. Check campaign status and message logs for progress.',
            campaign_id: campaignId
        }, { status: 202 });

    } catch (error) {
        logger.error('Error sending campaign:', error);
        return NextResponse.json(
            { error: 'Internal server error' },
            { status: 500 }
        );
    }
}

Get Campaign Messages (src/app/api/campaigns/[id]/messages/route.ts):

typescript
// src/app/api/campaigns/[id]/messages/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { campaignService } from '@/services/campaignService';
import { authenticateApiKey } from '@/lib/auth';
import { logger } from '@/lib/logger';

export async function GET(
    request: NextRequest,
    { params }: { params: { id: string } }
) {
    const authError = authenticateApiKey(request);
    if (authError) return authError;

    try {
        const campaignId = params.id;
        const messages = await campaignService.getCampaignMessages(campaignId);

        return NextResponse.json(messages);
    } catch (error) {
        logger.error('Error fetching campaign messages:', error);
        return NextResponse.json(
            { error: 'Internal server error' },
            { status: 500 }
        );
    }
}

4.4 Webhook Routes

Inbound Messages (src/app/api/webhooks/inbound/route.ts):

typescript
// src/app/api/webhooks/inbound/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { contactService } from '@/services/contactService';
import { logger } from '@/lib/logger';

// TCPA-compliant opt-out keywords
const OPT_OUT_KEYWORDS = ['STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT'];
const OPT_IN_KEYWORDS = ['START', 'UNSTOP', 'YES'];
const HELP_KEYWORDS = ['HELP', 'INFO'];

export async function POST(request: NextRequest) {
    const params = await request.json();
    logger.info('Received inbound SMS:', JSON.stringify(params, null, 2));

    try {
        if (params.type === 'mo_text' && params.from && params.body) {
            const fromNumber = params.from;
            const messageText = params.body.trim().toUpperCase();

            // Handle Opt-Out Keywords
            if (OPT_OUT_KEYWORDS.includes(messageText)) {
                logger.info(`Opt-out request from ${fromNumber}`);
                let contact = await contactService.findByPhoneNumber(fromNumber);

                if (contact) {
                    if (!contact.opted_out) {
                        await contactService.updateOptOut(contact.id, true);
                        logger.info(`Contact ${contact.id} (${fromNumber}) opted out`);
                    }
                } else {
                    await contactService.create({ phone_number: fromNumber, opted_out: true });
                    logger.info(`Created opted-out contact for ${fromNumber}`);
                }
            }
            // Handle Opt-In Keywords
            else if (OPT_IN_KEYWORDS.includes(messageText)) {
                logger.info(`Opt-in request from ${fromNumber}`);
                let contact = await contactService.findByPhoneNumber(fromNumber);

                if (contact && contact.opted_out) {
                    await contactService.updateOptOut(contact.id, false);
                    logger.info(`Contact ${contact.id} (${fromNumber}) opted back in`);
                }
            }
            // Handle HELP Keywords
            else if (HELP_KEYWORDS.includes(messageText)) {
                logger.info(`Help request from ${fromNumber}`);
            }
            // Handle other inbound messages
            else {
                logger.info(`Inbound text from ${fromNumber}: "${params.body}"`);
            }
        }
    } catch (error) {
        logger.error('Error processing inbound SMS webhook:', error);
    }

    return NextResponse.json({ status: 'ok' });
}

Status Updates (src/app/api/webhooks/status/route.ts):

typescript
// src/app/api/webhooks/status/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { logger } from '@/lib/logger';

export async function POST(request: NextRequest) {
    const params = await request.json();
    logger.info('Received message status:', JSON.stringify(params, null, 2));

    try {
        if (params.batch_id && params.statuses && Array.isArray(params.statuses)) {
            for (const statusUpdate of params.statuses) {
                const { code, status, recipient } = statusUpdate;

                const { error } = await supabase
                    .from('messages')
                    .update({
                        status: status.toLowerCase(),
                        status_timestamp: new Date().toISOString(),
                        error_code: code !== 0 ? code : null
                    })
                    .eq('sinch_message_id', params.batch_id);

                if (error) {
                    logger.error('Error updating message status:', error);
                } else {
                    logger.info(`Updated message ${params.batch_id} to status: ${status}`);
                }
            }
        }
    } catch (error) {
        logger.error('Error processing status webhook:', error);
    }

    return NextResponse.json({ status: 'ok' });
}

5. Testing Your API

Test your endpoints using curl or Postman.

5.1 Start the Development Server

bash
npm run dev

5.2 Test Endpoints with curl

Create a Contact:

bash
curl -X POST http://localhost:3000/api/contacts \
  -H "Content-Type: application/json" \
  -H "x-api-key: your-secret-api-key" \
  -d '{
    "phone_number": "+14155552671",
    "first_name": "Alice",
    "last_name": "Johnson"
  }'

Expected Response (201 Created):

json
{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "phone_number": "+14155552671",
  "first_name": "Alice",
  "last_name": "Johnson",
  "opted_out": false,
  "created_at": "2025-01-15T10:30:00.000Z",
  "updated_at": "2025-01-15T10:30:00.000Z"
}

Get All Contacts:

bash
curl -X GET "http://localhost:3000/api/contacts?limit=50&offset=0" \
  -H "x-api-key: your-secret-api-key"

Create a Campaign:

bash
curl -X POST http://localhost:3000/api/campaigns \
  -H "Content-Type: application/json" \
  -H "x-api-key: your-secret-api-key" \
  -d '{
    "name": "January Sale",
    "message_text": "Flash sale! 50% off all items today only. Shop now: https://example.com/sale Reply STOP to unsubscribe."
  }'

Send Campaign:

bash
curl -X POST http://localhost:3000/api/campaigns/123e4567-e89b-12d3-a456-426614174000/send \
  -H "x-api-key: your-secret-api-key"

Expected Response (202 Accepted):

json
{
  "message": "Campaign send initiated. Check campaign status and message logs for progress.",
  "campaign_id": "123e4567-e89b-12d3-a456-426614174000"
}

Get Campaign Messages:

bash
curl -X GET http://localhost:3000/api/campaigns/123e4567-e89b-12d3-a456-426614174000/messages \
  -H "x-api-key: your-secret-api-key"

6. Deployment Considerations

Deploy your SMS campaign API to production using Vercel, the recommended platform for Next.js applications.

6.1 Deploy to Vercel

Using Vercel CLI:

bash
# Install Vercel CLI
npm install -g vercel

# Login to Vercel
vercel login

# Deploy
vercel --prod

Using GitHub Integration:

  1. Push your code to GitHub
  2. Go to Vercel Dashboard
  3. Click "Import Project"
  4. Select your GitHub repository
  5. Configure environment variables
  6. Deploy

6.2 Environment Variables for Production

Set these in Vercel Dashboard (Settings > Environment Variables):

  • SINCH_PROJECT_ID
  • SINCH_KEY_ID
  • SINCH_KEY_SECRET
  • SINCH_NUMBER
  • NEXT_PUBLIC_SUPABASE_URL
  • NEXT_PUBLIC_SUPABASE_ANON_KEY
  • SUPABASE_SERVICE_ROLE_KEY
  • API_KEY

6.3 Update Sinch Webhooks

After deployment, update your Sinch webhook URLs to point to your production domain:

  • Inbound URL: https://your-domain.vercel.app/api/webhooks/inbound
  • Status URL: https://your-domain.vercel.app/api/webhooks/status

6.4 Alternative Deployment Options

Docker Deployment:

dockerfile
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .
RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]

Build and run:

bash
docker build -t sinch-sms-campaign-app .
docker run -p 3000:3000 --env-file .env.local sinch-sms-campaign-app

7. Monitoring and Observability

Implement monitoring to track API health, performance, and errors.

7.1 Vercel Analytics

Enable Vercel Analytics in your project:

bash
npm install @vercel/analytics

Add to src/app/layout.tsx:

typescript
import { Analytics } from '@vercel/analytics/react';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  );
}

7.2 Supabase Logging

Query logs in Supabase Dashboard:

sql
-- View recent campaign activity
SELECT
    c.name as campaign_name,
    COUNT(m.id) as total_messages,
    SUM(CASE WHEN m.status = 'delivered' THEN 1 ELSE 0 END) as delivered,
    SUM(CASE WHEN m.status = 'failed' THEN 1 ELSE 0 END) as failed
FROM campaigns c
LEFT JOIN messages m ON c.id = m.campaign_id
GROUP BY c.id, c.name
ORDER BY c.created_at DESC;

7.3 Application Performance Monitoring (APM)

Option 1: Sentry

bash
npm install @sentry/nextjs

# Initialize
npx @sentry/wizard@latest -i nextjs

Option 2: Datadog

bash
npm install dd-trace

# Add to instrumentation.ts
import tracer from 'dd-trace';
tracer.init();

Frequently Asked Questions (FAQ)

How do I build an SMS marketing campaign system with Sinch and Next.js?

Build an SMS marketing system by creating a Next.js application with API routes that manage contacts in Supabase PostgreSQL, use the Sinch Messages API to send bulk SMS via @sinch/sdk-core, implement webhooks for delivery receipts and opt-out handling, and include campaign tracking tables to monitor message status and engagement metrics.

What database schema do I need for SMS campaign management with Supabase?

An SMS campaign database requires three core tables: contacts (storing phone numbers in E.164 format with opt-out status), campaigns (tracking campaign name, message text, status, and scheduling), and messages (logging individual SMS delivery with Sinch message IDs, status updates, timestamps, and error codes). Add indexes on phone_number, sinch_message_id, and foreign keys for optimal query performance.

How do I handle STOP and opt-out requests in SMS marketing?

Handle opt-out requests by creating a webhook endpoint that receives inbound SMS messages, checks for keywords like "STOP", "UNSUBSCRIBE", "CANCEL", "END", or "QUIT", updates the contact's opted_out status in your Supabase database, and excludes opted-out contacts from future campaign sends. This ensures compliance with TCPA regulations and carrier requirements.

What is the Sinch Messages API authentication method?

Sinch Messages API uses project-based authentication requiring a Project ID, Key ID, and Key Secret. Generate these in the Sinch Dashboard under Settings > API Credentials, and initialize the SDK with new SinchClient({ projectId, keyId, keySecret }). Store credentials securely using environment variables. Never commit API keys to version control.

How do I send bulk SMS campaigns without hitting rate limits?

Send bulk campaigns by implementing asynchronous processing with controlled concurrency, adding delays between sends (e.g., 100–200ms), using campaign status tracking to resume failed sends, implementing exponential backoff for API errors, and monitoring Sinch's rate limits (typically 30–60 SMS per second depending on account tier). Consider using a job queue like BullMQ for large campaigns.

How do I track SMS delivery status with Sinch webhooks?

Track delivery status by configuring a status webhook URL in your Sinch Dashboard settings, creating a POST endpoint (e.g., /api/webhooks/status) that receives Sinch delivery receipts, parsing the batch_id and status fields (delivered, expired, failed, rejected), and updating your messages table. Store status_timestamp, error_code, and error_message for failed deliveries to analyze campaign performance.

What are E.164 phone number format requirements for SMS?

E.164 is the international telephone numbering standard (ITU-T Recommendation E.164) requiring phone numbers in format: + [country code] [subscriber number including area code], with maximum 15 digits excluding the + symbol. Store numbers as VARCHAR(20) in PostgreSQL, validate format using libphonenumber-js library before database insertion, and reject invalid numbers to prevent API errors and wasted credits.

How do I secure my SMS marketing API endpoints in Next.js?

Secure SMS marketing APIs by implementing API key authentication middleware for campaign and contact routes (using x-api-key header), excluding webhooks from API key auth since Sinch calls them, validating all input with Zod or similar validation libraries, using Supabase RLS (Row Level Security) policies for data access control, and implementing webhook signature verification or IP allowlisting for production environments.

How do I integrate Supabase with Next.js for SMS campaigns?

Integrate Supabase with Next.js by installing @supabase/supabase-js, creating a client instance with your Project URL and service role key for server-side operations, using the service role key in API routes for bypassing RLS, implementing TypeScript types matching your database schema, and leveraging Supabase's real-time features for live campaign monitoring and status updates.

What is the best way to handle SMS campaign analytics in Supabase?

Handle SMS campaign analytics by creating PostgreSQL views that aggregate message statuses by campaign, implementing SQL queries for delivery rates and engagement metrics, using Supabase functions to calculate KPIs like open rates and opt-out rates, creating materialized views for performance optimization, and exposing analytics through Next.js API routes with proper caching and pagination.

Sinch SMS Integration Guides:

Next.js SMS Campaign Guides:

Supabase & Database Best Practices:

SMS Compliance & Best Practices: