code examples
code examples
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 clientdotenv: Module for loading environment variablesuuid: Generates unique identifiersngrok: Exposes local development server to the internet for webhook testing
Required Package Versions:
| Package | Minimum Version | Recommended |
|---|---|---|
| Node.js | 16.x | 18.x LTS or 20.x LTS |
| Next.js | 13.0.0 | Latest 14.x or 15.x |
| @sinch/sdk-core | 1.0.0 | Latest 1.x |
| @supabase/supabase-js | 2.38.0 | Latest 2.x |
System Architecture:
+-----------------+ +---------------------+ +----------------+
| 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:
- Admin creates campaign via Next.js API (REST request)
- API stores campaign in Supabase PostgreSQL
- Admin triggers campaign send via
/api/campaigns/:id/sendendpoint - Backend processes contacts asynchronously, sends SMS via Sinch API
- Sinch delivers SMS to users' phones
- Users receive SMS; some may reply "STOP"
- Sinch sends delivery status webhooks to
/api/webhooks/status - Sinch sends inbound messages to
/api/webhooks/inbound - Backend updates message status and opt-out status in Supabase
Prerequisites:
- Node.js (18.x LTS recommended) and npm installed. Download Node.js
- A Sinch API account. Sign up at Sinch
- A Sinch virtual phone number capable of sending/receiving SMS. Purchase a number
- A Supabase account and project. Sign up at Supabase
ngrokinstalled and authenticated (free account sufficient). Get ngrok- Basic familiarity with REST APIs, Next.js, and SQL
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:
# 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? No1.2 Install Dependencies
Install the necessary libraries:
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 libraryuuid: Generates unique IDs (e.g., for messages)dotenv: Loads environment variables from a.env.localfile
1.3 Project Structure
Create the following directory structure within your project root:
.
├── 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.json1.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.
# .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:3000Generate a secure random API key:
# 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 uuidUse 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:
# Environment variables
.env*.local
.env
# Dependencies
node_modules/
# Next.js
.next/
out/
# Logs
logs/
*.log
npm-debug.log*
# OS generated files
.DS_Store
Thumbs.db1.6 Configure Webhooks with ngrok
Sinch webhooks require publicly accessible URLs. During development, use ngrok to expose your local server.
Start ngrok:
# Start your Next.js server first
npm run dev
# In a separate terminal, start ngrok pointing to your local port
ngrok http 3000Copy the ngrok URL (e.g., https://abc123.ngrok.io).
Update Sinch Dashboard:
- Go to Sinch Dashboard
- Navigate to SMS > Webhooks
- 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
- Inbound Messages Webhook URL:
- 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).
// 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:
// 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:
// 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:
// 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:
-- Enable uuid-ossp extension if not already enabled
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";Create Tables:
-- 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):
// 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):
// 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):
// 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:
// 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)
// 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):
// 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):
// 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):
// 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):
// 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):
// 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
npm run dev5.2 Test Endpoints with curl
Create a Contact:
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):
{
"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:
curl -X GET "http://localhost:3000/api/contacts?limit=50&offset=0" \
-H "x-api-key: your-secret-api-key"Create a Campaign:
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:
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):
{
"message": "Campaign send initiated. Check campaign status and message logs for progress.",
"campaign_id": "123e4567-e89b-12d3-a456-426614174000"
}Get Campaign Messages:
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:
# Install Vercel CLI
npm install -g vercel
# Login to Vercel
vercel login
# Deploy
vercel --prodUsing GitHub Integration:
- Push your code to GitHub
- Go to Vercel Dashboard
- Click "Import Project"
- Select your GitHub repository
- Configure environment variables
- Deploy
6.2 Environment Variables for Production
Set these in Vercel Dashboard (Settings > Environment Variables):
SINCH_PROJECT_IDSINCH_KEY_IDSINCH_KEY_SECRETSINCH_NUMBERNEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEYAPI_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:
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:
docker build -t sinch-sms-campaign-app .
docker run -p 3000:3000 --env-file .env.local sinch-sms-campaign-app7. Monitoring and Observability
Implement monitoring to track API health, performance, and errors.
7.1 Vercel Analytics
Enable Vercel Analytics in your project:
npm install @vercel/analyticsAdd to src/app/layout.tsx:
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:
-- 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
npm install @sentry/nextjs
# Initialize
npx @sentry/wizard@latest -i nextjsOption 2: Datadog
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.
Related Resources
Sinch SMS Integration Guides:
- Sinch Express Basic SMS Sending
- Sinch Bulk Broadcasting with Express
- Sinch Delivery Status Webhooks
- Sinch Inbound Two-Way Messaging
Next.js SMS Campaign Guides:
- Twilio Marketing Campaigns with Next.js
- Plivo Campaign Management with Next.js
- MessageBird Campaign Automation
- Vonage Bulk SMS Campaigns
Supabase & Database Best Practices:
- Supabase Connection Pooling Guide
- Database Schema Design for SMS Systems
- Supabase Row Level Security (RLS)
- Next.js API Routes with Supabase
SMS Compliance & Best Practices: