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
ngrok installed 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:
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
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
# .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
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).
-- View recent campaign activitySELECT
c.name as campaign_name,
COUNT(m.id) as total_messages,
SUM(CASEWHEN m.status ='delivered'THEN1ELSE0END) as delivered,
SUM(CASEWHEN m.status ='failed'THEN1ELSE0END) as failed
FROM campaigns c
LEFTJOIN messages m ON c.id = m.campaign_id
GROUPBY c.id, c.name
ORDERBY c.created_at DESC;
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.
Use Node.js with Express and the Vonage Messages API to build a backend API. This API can manage contacts, create targeted campaigns, send messages, and handle delivery receipts and opt-out requests. The provided tutorial walks through the entire process of building a production-ready SMS campaign system.
The Vonage Messages API is a unified platform for sending and receiving messages across multiple channels, including SMS. In this tutorial, it's the core component for sending SMS messages within your Node.js application and enables features such as delivery receipts and inbound message handling.
PostgreSQL is used as the database to store contact information, campaign details, and individual message logs. Its robust features ensure data integrity and efficient querying for managing large-scale SMS campaigns and tracking message statuses effectively.
ngrok is essential during development for exposing your local server to the internet. This allows Vonage to send webhooks (message status updates and inbound messages) to your local development environment, which is crucial for testing your application's webhook handling logic.
Although not explicitly covered in the base tutorial, the architecture allows for personalization. By integrating contact data into your message sending logic, you can tailor messages to individual recipients or segments. Further enhancements can be made by incorporating templating engines or other customization tools.
The API includes functionality for managing a contact list and handling opt-out requests. Users can opt out by sending keywords like "STOP". The system checks the opt-out status before sending messages, ensuring compliance with regulations and user preferences.
Express simplifies the creation of the Node.js backend API by providing a minimalist web framework. It handles routing, middleware, and request/response management, making the API development process more efficient and structured.
Vonage sends delivery receipts (message status updates) to your application via webhooks. You'll set up a specific route in your Express app to handle these status updates and update the message logs in your PostgreSQL database accordingly.
Install the @vonage/server-sdk package using npm. Initialize the Vonage client in your Node.js application using your Vonage API key, secret, application ID, and private key. Ensure your private key is stored securely, preferably outside the project root.
You'll need Node.js, npm, a Vonage API account with a virtual phone number, access to a PostgreSQL database, ngrok, and basic knowledge of REST APIs and SQL. These components are essential for building and running the SMS campaign API.
The tutorial recommends a structured project layout including folders for controllers, models, routes, services, config, and utils. This organization enhances code maintainability and scalability, making the project easier to manage and extend.
Set up a webhook route in your Express app that Vonage can call when a user sends an SMS to your virtual number. The tutorial demonstrates how to process inbound messages, particularly for handling opt-out keywords like "STOP".
The dotenv package loads environment variables from a .env file. This keeps sensitive data like API keys and database credentials separate from your code, improving security practices and making configuration easier.
The tutorial provides SQL commands for creating the necessary database tables: contacts, campaigns, and messages. It also covers creating indexes and triggers for optimized performance and data integrity.
While deployment is not extensively covered in the tutorial, it provides a solid foundation for a production-ready system. Deployment would involve configuring your chosen environment, setting up environment variables securely, and ensuring your database and Vonage API are accessible.
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:
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:
@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 testingRequired Package Versions:
System Architecture:
Data Flow:
/api/campaigns/:id/sendendpoint/api/webhooks/status/api/webhooks/inboundPrerequisites:
ngrokinstalled and authenticated (free account sufficient). Get ngrok1. 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:
@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.localfile1.3 Project Structure
Create the following directory structure within your project root:
1.4 Environment Configuration (
.env.local)Create a file named
.env.localin 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.
Generate 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_KEYvalue.API_KEY: A simple key for basic API authentication (replace with a secure, generated key).1.5 Git Ignore (
.gitignore)Ensure your
.gitignorefile (created by Next.js) includes:1.6 Configure Webhooks with ngrok
Sinch webhooks require publicly accessible URLs. During development, use
ngrokto 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:
https://abc123.ngrok.io/api/webhooks/inboundhttps://abc123.ngrok.io/api/webhooks/statusNote: 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
winstonorpinofor 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.localfile:// 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.localfile:// 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
5.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:
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_KEY6.3 Update Sinch Webhooks
After deployment, update your Sinch webhook URLs to point to your production domain:
https://your-domain.vercel.app/api/webhooks/inboundhttps://your-domain.vercel.app/api/webhooks/status6.4 Alternative Deployment Options
Docker Deployment:
Build and run:
7. Monitoring and Observability
Implement monitoring to track API health, performance, and errors.
7.1 Vercel Analytics
Enable Vercel Analytics in your project:
Add 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), andmessages(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_outstatus 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 thebatch_idandstatusfields (delivered, expired, failed, rejected), and updating your messages table. Storestatus_timestamp,error_code, anderror_messagefor 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-keyheader), 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:
Next.js SMS Campaign Guides:
Supabase & Database Best Practices:
SMS Compliance & Best Practices: