code examples

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

Build SMS Marketing Campaigns with Sinch, Node.js, Vite & React/Vue (2025)

Complete guide to building SMS marketing campaigns using Sinch API, Node.js, Express, and Vite with React or Vue. Learn bulk sending, contact management, delivery tracking, and webhooks for production campaigns.

Send SMS with Node.js, Express, and Vonage Messages API: Complete Guide

This comprehensive guide teaches you how to build a production-ready SMS marketing campaign system using Sinch SMS API, Node.js/Express backend, and Vite-powered React or Vue.js frontend. You'll learn to implement bulk SMS sending, contact list management, campaign scheduling, delivery tracking, and real-time status updates.

By the end of this tutorial, you'll have a complete marketing campaign platform featuring: bulk message broadcasting to up to 1,000 recipients per request, webhook-based delivery reporting, contact database integration, frontend dashboards built with Vite and React/Vue, and production-ready error handling and security.

Project Overview and Campaign Architecture

Goal: Build a full-stack SMS marketing campaign system with a Node.js/Express backend for Sinch API integration and a modern Vite/React/Vue frontend for campaign management.

Problem Solved: This application enables businesses to send targeted SMS marketing campaigns to large contact lists, track delivery status in real-time, manage unsubscribe requests, and maintain compliance with SMS marketing regulations. Perfect for promotional campaigns, event notifications, and customer engagement.

Technologies Used:

  • Node.js (v18+): JavaScript runtime for server-side execution. Required for Express v5.1.0 compatibility.
  • Express (v5.1.0): Web application framework for building RESTful APIs. Current version as of January 2025 with official LTS support.
  • Sinch SMS API: Global SMS delivery platform supporting up to 1,000 recipients per batch request (2024 limit increase from previous 100-recipient cap).
  • Vite (v5.x): Next-generation frontend build tool providing instant HMR and optimized production builds.
  • React (v18+) or Vue (v3+): Modern JavaScript frameworks for building interactive campaign management interfaces.
  • node-fetch (v3+) or native fetch: HTTP client for Sinch API requests. Node.js 18+ includes native fetch support.
  • dotenv (v17.2.3): Environment variable management for secure credential storage.

Marketing Campaign Architecture:

text
[Vite/React/Vue Frontend]
         ↓ (Campaign Create/Send Requests)
[Express API Server]
         ↓ (Batch Processing)
[Sinch SMS API] → [Carrier Networks] → [Recipients]
         ↓ (Webhook Callbacks)
[Delivery Report Handler]
         ↓ (Status Updates)
[Database/Frontend Updates]

Prerequisites:

  • Node.js v18+ and npm/yarn installed (nodejs.org)
  • Sinch Account: Sign up at sinch.com and obtain Service Plan ID and API Token from the dashboard
  • Sinch Phone Number: Provisioned virtual number for campaign sender ID
  • Modern Browser: For Vite development server and frontend testing
  • Optional: PostgreSQL/MongoDB for contact database and campaign history

1. Backend Setup: Express API with Sinch Integration

Initialize Node.js Project

Create the project structure for both backend and frontend:

bash
mkdir sinch-marketing-platform
cd sinch-marketing-platform

# Backend setup
mkdir backend
cd backend
npm init -y
npm install express dotenv node-fetch cors

For Node.js 18+ using native fetch:

bash
npm install express dotenv cors

Configure Environment Variables

Create .env file for Sinch credentials:

bash
# backend/.env

# Sinch API Configuration
SINCH_SERVICE_PLAN_ID=your_service_plan_id
SINCH_API_TOKEN=your_api_token
SINCH_BASE_URL=https://us.sms.api.sinch.com
SINCH_NUMBER=+1234567890

# Server Configuration
PORT=3001
NODE_ENV=development

# Webhook Configuration
BASE_URL=https://your-ngrok-url.ngrok.io

Build Sinch Service Layer

Create backend/services/sinchService.js:

javascript
// backend/services/sinchService.js
import fetch from 'node-fetch'; // or use native fetch in Node 18+

const {
  SINCH_SERVICE_PLAN_ID,
  SINCH_API_TOKEN,
  SINCH_BASE_URL,
  SINCH_NUMBER
} = process.env;

/**
 * Send bulk SMS campaign via Sinch API
 * @param {string[]} recipients - Array of phone numbers in E.164 format (max 1000)
 * @param {string} message - Campaign message content
 * @param {string} callbackUrl - Webhook URL for delivery reports
 * @param {string} clientRef - Unique campaign identifier
 * @returns {Promise<object>} Sinch API response with batch_id
 */
export async function sendBulkSms(recipients, message, callbackUrl, clientRef) {
  if (!recipients || recipients.length === 0) {
    throw new Error('Recipients array cannot be empty');
  }

  if (recipients.length > 1000) {
    throw new Error('Sinch API maximum is 1000 recipients per batch request');
  }

  const endpoint = `${SINCH_BASE_URL}/xms/v1/${SINCH_SERVICE_PLAN_ID}/batches`;

  const payload = {
    from: SINCH_NUMBER,
    to: recipients,
    body: message,
    delivery_report: 'full',
    callback_url: callbackUrl,
    client_reference: clientRef
  };

  try {
    const response = await fetch(endpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${SINCH_API_TOKEN}`
      },
      body: JSON.stringify(payload)
    });

    const data = await response.json();

    if (!response.ok) {
      throw new Error(`Sinch API Error: ${response.status} - ${JSON.stringify(data)}`);
    }

    return data; // Contains batch_id, status, etc.
  } catch (error) {
    console.error('Sinch API call failed:', error);
    throw error;
  }
}

/**
 * Get campaign status from Sinch
 * @param {string} batchId - Batch ID from send response
 * @returns {Promise<object>} Campaign delivery statistics
 */
export async function getCampaignStatus(batchId) {
  const endpoint = `${SINCH_BASE_URL}/xms/v1/${SINCH_SERVICE_PLAN_ID}/batches/${batchId}`;

  const response = await fetch(endpoint, {
    headers: {
      'Authorization': `Bearer ${SINCH_API_TOKEN}`
    }
  });

  return response.json();
}

Create Campaign API Endpoints

Create backend/routes/campaignRoutes.js:

javascript
// backend/routes/campaignRoutes.js
import express from 'express';
import { randomUUID } from 'crypto';
import { sendBulkSms, getCampaignStatus } from '../services/sinchService.js';

const router = express.Router();

// POST /api/campaigns/send - Send bulk SMS campaign
router.post('/send', async (req, res) => {
  const { recipients, message, campaignName } = req.body;

  // Input validation
  if (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
    return res.status(400).json({ error: 'Recipients array is required' });
  }

  if (!message || message.trim().length === 0) {
    return res.status(400).json({ error: 'Message content is required' });
  }

  // Validate E.164 format
  const invalidNumbers = recipients.filter(num => !/^\+[1-9]\d{1,14}$/.test(num));
  if (invalidNumbers.length > 0) {
    return res.status(400).json({
      error: 'Invalid phone number format',
      invalidNumbers
    });
  }

  try {
    const clientRef = randomUUID();
    const callbackUrl = `${process.env.BASE_URL}/api/webhooks/delivery-reports`;

    const result = await sendBulkSms(recipients, message, callbackUrl, clientRef);

    res.status(202).json({
      success: true,
      message: 'Campaign accepted for delivery',
      batchId: result.id,
      clientRef,
      recipientCount: recipients.length,
      details: result
    });
  } catch (error) {
    console.error('Campaign send error:', error);
    res.status(500).json({ error: error.message });
  }
});

// GET /api/campaigns/status/:batchId - Check campaign delivery status
router.get('/status/:batchId', async (req, res) => {
  try {
    const status = await getCampaignStatus(req.params.batchId);
    res.json(status);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

export default router;

Implement Webhook Handler for Delivery Reports

Create backend/routes/webhookRoutes.js:

javascript
// backend/routes/webhookRoutes.js
import express from 'express';

const router = express.Router();

// POST /api/webhooks/delivery-reports - Sinch delivery callback
router.post('/delivery-reports', async (req, res) => {
  const report = req.body;

  console.log('📨 Delivery Report Received:', {
    recipient: report.recipient,
    status: report.status,
    code: report.code,
    clientRef: report.client_reference,
    batchId: report.batch_id,
    timestamp: report.operator_status_at
  });

  // Process delivery report
  // TODO: Update database with delivery status
  // TODO: Emit real-time updates to frontend via WebSocket/SSE

  if (report.status === 'Delivered') {
    console.log(`✅ Message delivered to ${report.recipient}`);
  } else if (report.status === 'Failed') {
    console.error(`❌ Delivery failed to ${report.recipient}: Code ${report.code}`);
  }

  // Always acknowledge receipt to Sinch
  res.status(200).json({ message: 'Webhook received' });
});

export default router;

Configure Express Server

Create backend/server.js:

javascript
// backend/server.js
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import campaignRoutes from './routes/campaignRoutes.js';
import webhookRoutes from './routes/webhookRoutes.js';

const app = express();
const PORT = process.env.PORT || 3001;

// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Routes
app.get('/health', (req, res) => {
  res.json({ status: 'healthy', service: 'Sinch Marketing API' });
});

app.use('/api/campaigns', campaignRoutes);
app.use('/api/webhooks', webhookRoutes);

// Error handling
app.use((err, req, res, next) => {
  console.error('Server error:', err);
  res.status(500).json({ error: 'Internal server error' });
});

app.listen(PORT, () => {
  console.log(`🚀 Sinch Marketing API running on port ${PORT}`);
  console.log(`📍 Webhook endpoint: ${process.env.BASE_URL}/api/webhooks/delivery-reports`);
});

Update backend/package.json to enable ES modules:

json
{
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  }
}

2. Frontend Setup: Vite + React Campaign Dashboard

Initialize Vite Project with React

bash
cd ..
npm create vite@latest frontend -- --template react
cd frontend
npm install
npm install axios

For Vue instead of React:

bash
npm create vite@latest frontend -- --template vue

Build Campaign Management Component

Create frontend/src/components/CampaignManager.jsx:

jsx
// frontend/src/components/CampaignManager.jsx
import { useState } from 'react';
import axios from 'axios';

const API_BASE = 'http://localhost:3001/api';

export default function CampaignManager() {
  const [recipients, setRecipients] = useState('');
  const [message, setMessage] = useState('');
  const [campaignName, setCampaignName] = useState('');
  const [loading, setLoading] = useState(false);
  const [result, setResult] = useState(null);

  const handleSendCampaign = async (e) => {
    e.preventDefault();
    setLoading(true);
    setResult(null);

    try {
      // Parse recipients (one per line)
      const recipientList = recipients
        .split('\n')
        .map(num => num.trim())
        .filter(num => num.length > 0);

      const response = await axios.post(`${API_BASE}/campaigns/send`, {
        recipients: recipientList,
        message,
        campaignName
      });

      setResult({
        success: true,
        data: response.data
      });

      // Clear form on success
      setRecipients('');
      setMessage('');
      setCampaignName('');
    } catch (error) {
      setResult({
        success: false,
        error: error.response?.data?.error || error.message
      });
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="campaign-manager">
      <h1>📱 SMS Marketing Campaign</h1>

      <form onSubmit={handleSendCampaign} className="campaign-form">
        <div className="form-group">
          <label>Campaign Name</label>
          <input
            type="text"
            value={campaignName}
            onChange={(e) => setCampaignName(e.target.value)}
            placeholder="e.g., Spring Sale 2025"
            required
          />
        </div>

        <div className="form-group">
          <label>Recipients (E.164 format, one per line)</label>
          <textarea
            value={recipients}
            onChange={(e) => setRecipients(e.target.value)}
            placeholder="+14155551234&#10;+14155555678"
            rows={8}
            required
          />
          <small>Maximum 1,000 recipients per campaign</small>
        </div>

        <div className="form-group">
          <label>Message Content</label>
          <textarea
            value={message}
            onChange={(e) => setMessage(e.target.value)}
            placeholder="Your marketing message here..."
            rows={4}
            maxLength={1600}
            required
          />
          <small>{message.length}/1600 characters</small>
        </div>

        <button
          type="submit"
          disabled={loading}
          className="btn-primary"
        >
          {loading ? 'Sending...' : 'Send Campaign'}
        </button>
      </form>

      {result && (
        <div className={`result ${result.success ? 'success' : 'error'}`}>
          {result.success ? (
            <div>
              <h3>✅ Campaign Sent Successfully!</h3>
              <p><strong>Batch ID:</strong> {result.data.batchId}</p>
              <p><strong>Recipients:</strong> {result.data.recipientCount}</p>
              <p><strong>Reference:</strong> {result.data.clientRef}</p>
            </div>
          ) : (
            <div>
              <h3>❌ Campaign Failed</h3>
              <p>{result.error}</p>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

Add Campaign Status Tracker

Create frontend/src/components/CampaignStatus.jsx:

jsx
// frontend/src/components/CampaignStatus.jsx
import { useState } from 'react';
import axios from 'axios';

const API_BASE = 'http://localhost:3001/api';

export default function CampaignStatus() {
  const [batchId, setBatchId] = useState('');
  const [status, setStatus] = useState(null);
  const [loading, setLoading] = useState(false);

  const checkStatus = async () => {
    if (!batchId) return;

    setLoading(true);
    try {
      const response = await axios.get(`${API_BASE}/campaigns/status/${batchId}`);
      setStatus(response.data);
    } catch (error) {
      console.error('Status check failed:', error);
      setStatus({ error: 'Failed to fetch status' });
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="campaign-status">
      <h2>📊 Campaign Status Tracker</h2>

      <div className="status-lookup">
        <input
          type="text"
          value={batchId}
          onChange={(e) => setBatchId(e.target.value)}
          placeholder="Enter Batch ID"
        />
        <button onClick={checkStatus} disabled={loading}>
          {loading ? 'Checking...' : 'Check Status'}
        </button>
      </div>

      {status && !status.error && (
        <div className="status-details">
          <h3>Campaign Details</h3>
          <p><strong>Status:</strong> {status.canceled ? 'Canceled' : 'Active'}</p>
          <p><strong>Recipients:</strong> {status.to?.length || 0}</p>
          <p><strong>Message:</strong> {status.body}</p>
          <p><strong>Created:</strong> {new Date(status.created_at).toLocaleString()}</p>
        </div>
      )}
    </div>
  );
}

Main App Component

Update frontend/src/App.jsx:

jsx
// frontend/src/App.jsx
import CampaignManager from './components/CampaignManager';
import CampaignStatus from './components/CampaignStatus';
import './App.css';

function App() {
  return (
    <div className="app">
      <header>
        <h1>Sinch SMS Marketing Platform</h1>
        <p>Powered by Vite + React</p>
      </header>

      <main>
        <CampaignManager />
        <CampaignStatus />
      </main>
    </div>
  );
}

export default App;

Add Styling

Create frontend/src/App.css:

css
/* frontend/src/App.css */
.app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
}

header {
  text-align: center;
  margin-bottom: 3rem;
  padding-bottom: 2rem;
  border-bottom: 2px solid #e0e0e0;
}

.campaign-manager,
.campaign-status {
  background: #fff;
  padding: 2rem;
  margin-bottom: 2rem;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

.campaign-form {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

.form-group {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.form-group label {
  font-weight: 600;
  color: #333;
}

.form-group input,
.form-group textarea {
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-family: inherit;
  font-size: 1rem;
}

.form-group small {
  color: #666;
  font-size: 0.875rem;
}

.btn-primary {
  padding: 1rem 2rem;
  background: #0066cc;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s;
}

.btn-primary:hover {
  background: #0052a3;
}

.btn-primary:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.result {
  margin-top: 2rem;
  padding: 1.5rem;
  border-radius: 4px;
}

.result.success {
  background: #d4edda;
  border: 1px solid #c3e6cb;
  color: #155724;
}

.result.error {
  background: #f8d7da;
  border: 1px solid #f5c6cb;
  color: #721c24;
}

3. Vue.js Alternative Implementation

For Vue developers, here's the equivalent Campaign Manager component:

vue
<!-- frontend/src/components/CampaignManager.vue -->
<template>
  <div class="campaign-manager">
    <h1>📱 SMS Marketing Campaign</h1>

    <form @submit.prevent="sendCampaign">
      <div class="form-group">
        <label>Campaign Name</label>
        <input
          v-model="campaignName"
          type="text"
          placeholder="e.g., Spring Sale 2025"
          required
        />
      </div>

      <div class="form-group">
        <label>Recipients (E.164 format, one per line)</label>
        <textarea
          v-model="recipients"
          placeholder="+14155551234&#10;+14155555678"
          rows="8"
          required
        />
        <small>Maximum 1,000 recipients per campaign</small>
      </div>

      <div class="form-group">
        <label>Message Content</label>
        <textarea
          v-model="message"
          placeholder="Your marketing message here..."
          rows="4"
          maxlength="1600"
          required
        />
        <small>{{ message.length }}/1600 characters</small>
      </div>

      <button
        type="submit"
        :disabled="loading"
        class="btn-primary"
      >
        {{ loading ? 'Sending...' : 'Send Campaign' }}
      </button>
    </form>

    <div v-if="result" :class="['result', result.success ? 'success' : 'error']">
      <div v-if="result.success">
        <h3>✅ Campaign Sent Successfully!</h3>
        <p><strong>Batch ID:</strong> {{ result.data.batchId }}</p>
        <p><strong>Recipients:</strong> {{ result.data.recipientCount }}</p>
      </div>
      <div v-else>
        <h3>❌ Campaign Failed</h3>
        <p>{{ result.error }}</p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const API_BASE = 'http://localhost:3001/api';

const campaignName = ref('');
const recipients = ref('');
const message = ref('');
const loading = ref(false);
const result = ref(null);

async function sendCampaign() {
  loading.value = true;
  result.value = null;

  try {
    const recipientList = recipients.value
      .split('\n')
      .map(num => num.trim())
      .filter(num => num.length > 0);

    const response = await axios.post(`${API_BASE}/campaigns/send`, {
      recipients: recipientList,
      message: message.value,
      campaignName: campaignName.value
    });

    result.value = { success: true, data: response.data };

    // Clear form
    campaignName.value = '';
    recipients.value = '';
    message.value = '';
  } catch (error) {
    result.value = {
      success: false,
      error: error.response?.data?.error || error.message
    };
  } finally {
    loading.value = false;
  }
}
</script>

4. Testing with ngrok for Webhook Development

Expose your local backend to receive Sinch webhooks:

bash
# Install ngrok globally
npm install -g ngrok

# Start your backend server
cd backend
npm run dev

# In another terminal, expose port 3001
ngrok http 3001

Copy the HTTPS URL from ngrok (e.g., https://abc123.ngrok.io) and update your .env:

bash
BASE_URL=https://abc123.ngrok.io

Restart your backend to use the new webhook URL.

5. Production Deployment Considerations

Security Best Practices

  1. API Authentication: Add JWT tokens or API keys to protect campaign endpoints
  2. Rate Limiting: Implement express-rate-limit to prevent abuse
  3. Input Validation: Use express-validator for comprehensive input checking
  4. HTTPS Only: Deploy behind reverse proxy with TLS/SSL certificates
  5. Environment Variables: Use platform-specific secret managers (AWS Secrets Manager, etc.)

Database Integration

For production campaigns, integrate a database for:

sql
-- PostgreSQL schema example
CREATE TABLE campaigns (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  message TEXT NOT NULL,
  batch_id VARCHAR(100) UNIQUE,
  client_reference VARCHAR(100) UNIQUE,
  recipient_count INTEGER,
  status VARCHAR(50) DEFAULT 'pending',
  created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE campaign_recipients (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  campaign_id UUID REFERENCES campaigns(id),
  phone_number VARCHAR(20) NOT NULL,
  delivery_status VARCHAR(50) DEFAULT 'pending',
  delivery_code INTEGER,
  delivered_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_campaign_batch ON campaigns(batch_id);
CREATE INDEX idx_recipient_phone ON campaign_recipients(phone_number);

Deployment Platforms

Backend Options:

  • Heroku: Easy deployment with automatic SSL
  • AWS Elastic Beanstalk: Scalable Node.js hosting
  • Google Cloud Run: Serverless container deployment
  • DigitalOcean App Platform: Simple deployment with managed databases

Frontend Options:

  • Vercel: Optimized for Vite/React/Vue with automatic deployment
  • Netlify: Static site hosting with CDN
  • Cloudflare Pages: Global edge network deployment

6. Advanced Campaign Features

Contact List Management

Add contact import functionality:

javascript
// backend/routes/contactRoutes.js
router.post('/contacts/import', async (req, res) => {
  const { contacts } = req.body; // Array of { phone, firstName, lastName, tags }

  // Validate and deduplicate
  const validContacts = contacts.filter(c =>
    /^\+[1-9]\d{1,14}$/.test(c.phone)
  );

  // Save to database with opt-in status
  // TODO: Insert into contacts table

  res.json({
    imported: validContacts.length,
    duplicates: contacts.length - validContacts.length
  });
});

Campaign Scheduling

Implement scheduled campaign sending:

javascript
// backend/services/schedulerService.js
import cron from 'node-cron';

export function scheduleCampaign(campaignId, sendTime) {
  const cronTime = convertToCronExpression(sendTime);

  cron.schedule(cronTime, async () => {
    const campaign = await getCampaignById(campaignId);
    await sendBulkSms(campaign.recipients, campaign.message, ...);
    console.log(`Scheduled campaign ${campaignId} sent`);
  });
}

Opt-Out/Unsubscribe Management

Handle STOP keywords via Sinch inbound webhooks:

javascript
// backend/routes/webhookRoutes.js
router.post('/inbound-messages', async (req, res) => {
  const { from, body } = req.body;

  if (/\b(stop|unsubscribe|end)\b/i.test(body)) {
    // Add to suppression list
    await addToSuppressionList(from);

    // Send confirmation
    await sendBulkSms([from], 'You have been unsubscribed. Reply START to opt back in.');
  }

  res.sendStatus(200);
});

Frequently Asked Questions

How do I send bulk SMS campaigns with Sinch and Node.js?

Send bulk SMS campaigns with Sinch by using their batches endpoint, which accepts up to 1,000 recipients per request. Install the necessary packages (express, node-fetch), configure your Sinch Service Plan ID and API Token, create a POST endpoint that calls the Sinch API with an array of E.164-formatted phone numbers, and implement webhook handlers to track delivery status. This guide demonstrates the complete implementation with Express v5 and Node.js v18+.

What's the maximum number of recipients per Sinch SMS campaign?

Sinch supports a maximum of 1,000 recipients per batch request as of 2024 (increased from the previous 100-recipient limit). For campaigns exceeding 1,000 recipients, implement batch processing by splitting your contact list into chunks of 1,000 or fewer, sending each batch sequentially with appropriate rate-limiting delays to comply with Sinch's throughput restrictions.

How do I integrate Vite with a Node.js SMS campaign backend?

Integrate Vite with Node.js by running them as separate services: start your Express backend on port 3001 and your Vite dev server on port 5173 (default). Use axios or fetch in your React/Vue components to make API calls to http://localhost:3001/api. Configure CORS in your Express app with app.use(cors()) to allow cross-origin requests from the Vite dev server during development.

What are Sinch delivery reports and how do webhooks work?

Sinch delivery reports are HTTP POST callbacks sent to your configured webhook URL when message delivery status changes. Include a callback_url in your batch send request, and Sinch will POST delivery updates containing status (Delivered, Failed), error codes, recipient phone numbers, and your client_reference identifier. Always respond with HTTP 200 to acknowledge receipt and prevent Sinch from retrying the webhook.

Should I use React or Vue for SMS campaign management UI?

Both React and Vue work excellently with Vite for SMS campaign interfaces. React offers a larger ecosystem and more third-party libraries, while Vue provides simpler syntax and faster learning curve. This guide includes complete examples for both frameworks. Choose React if you need extensive component libraries; choose Vue if you prefer template-based components and built-in state management.

How do I handle SMS marketing compliance and opt-outs?

Handle SMS marketing compliance by: (1) obtaining explicit opt-in consent before adding contacts, (2) implementing STOP/UNSUBSCRIBE keyword handling via Sinch inbound webhooks, (3) maintaining a suppression list in your database and filtering it before sending campaigns, (4) including sender identification and opt-out instructions in messages, and (5) respecting regional regulations like TCPA (US), GDPR (EU), and carrier guidelines for commercial messaging.

What database should I use for contact and campaign management?

Use PostgreSQL for relational contact and campaign data with complex querying needs, or MongoDB for flexible schema designs. Create tables/collections for: campaigns (name, message, status, timestamps), campaign_recipients (phone numbers, delivery status), contacts (phone, name, opt-in status, tags), and suppression_list (opted-out numbers). Index phone numbers and batch IDs for fast webhook lookups.

How do I test Sinch webhooks locally during development?

Test Sinch webhooks locally using ngrok to expose your localhost to the internet. Install ngrok (npm install -g ngrok), run ngrok http 3001 to get a public HTTPS URL, update your .env file with BASE_URL=https://your-ngrok-url.ngrok.io, and restart your server. Sinch will now send delivery reports to your local development environment. Remember that ngrok URLs change on restart, so update your .env each time.

What Node.js version is required for this Sinch marketing system?

This system requires Node.js v18.0.0 or higher for Express v5 compatibility and native fetch support. Node.js v18+ includes experimental fetch (stable in v21+), eliminating the need for node-fetch. Use Node.js v22.x (current LTS, active until October 2025) or v24.x (released May 2025) for production deployments with full LTS support and security updates.

How much does it cost to send SMS campaigns with Sinch?

Sinch SMS pricing varies by destination country and volume. US/Canada messages typically cost $0.0045-$0.015 per SMS, with bulk discounts available for high-volume senders. Check Sinch Pricing for current rates. New accounts receive free trial credits. Monitor your usage in the Sinch Customer Dashboard and implement rate limiting in your application to prevent unexpected charges from runaway campaigns.