Building Node.js & Express SMS Marketing Campaigns with Sinch
This guide provides a comprehensive walkthrough for building a robust SMS marketing campaign system using Node.js, Express, and the Sinch SMS API. We'll cover everything from initial project setup to sending messages, handling delivery reports, security considerations, and deployment.
By the end of this tutorial, you will have a functional Express application capable of accepting campaign details via an API endpoint, sending SMS messages to a list of recipients using Sinch, and handling delivery status updates via webhooks. This solves the common need for businesses to programmatically send targeted SMS communications for marketing or notification purposes.
Technologies Used:
- Node.js: A JavaScript runtime environment for building server-side applications.
- Express.js: A minimal and flexible Node.js web application framework for building APIs and web servers.
- Sinch SMS API: A powerful REST API for sending and receiving SMS messages globally.
- dotenv: A module to load environment variables from a
.env
file intoprocess.env
. - node-fetch: A module that brings the browser
fetch
API to Node.js, used for making HTTP requests to the Sinch API. - (Optional) ngrok: A utility to expose local servers to the internet, essential for testing webhooks during development.
System Architecture:
+-------------+ +----------------------+ +---------------+ +------------------+
| Client | ----> | Node.js/Express API| ----> | Sinch Service | ----> | Sinch SMS API |
| (e.g., CURL,| | (Your Application) | | (Wrapper) | | (External Cloud) |
| Postman) | +----------------------+ +---------------+ +------------------+
+-------------+ | |
| | (Webhook POST)
| V
+--------------------------------------------------+ +-----------------+
| Webhook Handler (Express Route) | | User's Phone |
+--------------------------------------------------+ +-----------------+
Prerequisites:
- Node.js and npm (or yarn) installed.
- A free or paid Sinch account (https://www.sinch.com/).
- A Sinch Service Plan ID and API Token (found in your Sinch Customer Dashboard under SMS -> APIs).
- A provisioned Sinch phone number capable of sending SMS.
- (Optional but recommended for webhook testing)
ngrok
installed globally (npm install ngrok -g
). - Basic familiarity with Node.js, Express, REST APIs, and terminal commands.
1. Project Setup
Let's initialize our Node.js project and install the necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for the project.
mkdir sinch-marketing-app cd sinch-marketing-app
-
Initialize npm: Initialize the project using npm. You can accept the defaults.
npm init -y
This creates a
package.json
file. -
Install Dependencies: Install Express for the web server,
dotenv
for environment variables, andnode-fetch
for making API requests.npm install express dotenv node-fetch
-
Project Structure: Create a basic directory structure for organization.
mkdir src mkdir src/routes mkdir src/controllers mkdir src/services touch src/app.js touch src/server.js touch src/routes/campaignRoutes.js touch src/routes/webhookRoutes.js touch src/controllers/campaignController.js touch src/controllers/webhookController.js touch src/services/sinchService.js touch .env touch .gitignore
src/
: Contains all source code.src/routes/
: Defines API endpoints.src/controllers/
: Handles request logic.src/services/
: Contains business logic, like interacting with Sinch.src/app.js
: Configures the Express application (middleware, routes).src/server.js
: Starts the HTTP server..env
: Stores sensitive configuration (API keys, etc.). Never commit this file..gitignore
: Specifies files/folders Git should ignore.
-
Configure
.gitignore
: Addnode_modules
and.env
to your.gitignore
file to prevent committing them.# .gitignore node_modules/ .env npm-debug.log* yarn-debug.log* yarn-error.log*
2. Sinch Configuration
Securely store your Sinch credentials and configuration using environment variables.
-
Edit
.env
file: Open the.env
file and add your Sinch details. Replace the placeholder values with your actual credentials.# .env # Sinch API Credentials SINCH_SERVICE_PLAN_ID=YOUR_SERVICE_PLAN_ID SINCH_API_TOKEN=YOUR_API_TOKEN SINCH_BASE_URL=https://us.sms.api.sinch.com # Or your region: eu, ca, au, etc. SINCH_NUMBER=+1XXXXXXXXXX # Your provisioned Sinch number (include country code) # Server Configuration PORT=3000 # Webhook Configuration (Base URL for ngrok or deployment) # Set this to your public URL (ngrok for testing, deployed URL for production) # Example ngrok: BASE_URL=https://YOUR_NGROK_HTTPS_URL # Example Production: BASE_URL=https://yourapp.yourdomain.com BASE_URL=
SINCH_SERVICE_PLAN_ID
: Found on your Sinch Customer Dashboard (SMS -> APIs -> Select your API).SINCH_API_TOKEN
: Found on the same page as the Service Plan ID. Click ""Show"" to reveal it. Keep this secret.SINCH_BASE_URL
: The regional endpoint for the Sinch SMS API. Ensure this matches your account's region (e.g.,https://us.sms.api.sinch.com
,https://eu.sms.api.sinch.com
).SINCH_NUMBER
: The virtual phone number you acquired from Sinch, in E.164 format (e.g.,+12025550142
). This will be the sender ID for your messages.PORT
: The port your Express server will listen on.BASE_URL
: The public base URL where your application is accessible. This is crucial for constructing the webhookcallback_url
. Use yourngrok
HTTPS URL during development/testing, and your actual domain name in production.
-
Load Environment Variables: Configure
dotenv
at the very beginning of your application entry point (src/server.js
) to load these variables.
3. Implementing Core Functionality (Sinch Service)
Create a dedicated service to handle communication with the Sinch API. This encapsulates the logic and makes it reusable.
-
Edit
src/services/sinchService.js
: Implement the function to send SMS messages.// src/services/sinchService.js import fetch from 'node-fetch'; const { SINCH_SERVICE_PLAN_ID, SINCH_API_TOKEN, SINCH_BASE_URL, SINCH_NUMBER, } = process.env; /** * Sends an SMS message via the Sinch API. * @param {string[]} recipients - Array of phone numbers in E.164 format. * @param {string} messageBody - The text message content. * @param {string} [callbackUrl] - Optional URL for delivery reports. * @param {string} [clientReference] - Optional unique identifier for tracking. * @returns {Promise<object>} - The response object from the Sinch API. */ const sendSms = async (recipients, messageBody, callbackUrl, clientReference) => { if (!SINCH_SERVICE_PLAN_ID || !SINCH_API_TOKEN || !SINCH_BASE_URL || !SINCH_NUMBER) { console.error("Sinch credentials or base URL not configured in .env"); throw new Error("Sinch service not configured."); } if (!recipients || recipients.length === 0) { throw new Error("Recipient list cannot be empty."); } if (!messageBody) { throw new Error("Message body cannot be empty."); } const endpoint = `${SINCH_BASE_URL}/xms/v1/${SINCH_SERVICE_PLAN_ID}/batches`; const payload = { from: SINCH_NUMBER, to: recipients, body: messageBody, delivery_report: "full", // Request delivery reports (summary, full, per_recipient) }; // Add callback URL if provided if (callbackUrl) { payload.callback_url = callbackUrl; } // Add client reference if provided if (clientReference) { payload.client_reference = clientReference; } console.log(`Sending SMS via Sinch to ${recipients.length} recipients...`); // DEBUG: Log full payload. **IMPORTANT: Remove or sanitize sensitive data (like recipient numbers) in production logs!** console.log('Payload:', JSON.stringify(payload, null, 2)); try { const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${SINCH_API_TOKEN}`, }, body: JSON.stringify(payload), }); // Attempt to parse JSON, handle potential non-JSON responses gracefully let responseBody; const contentType = response.headers.get("content-type"); if (contentType && contentType.includes("application/json")) { responseBody = await response.json(); } else { responseBody = await response.text(); // Get text for non-JSON responses } if (!response.ok) { console.error(`Sinch API Error (${response.status}):`, responseBody); const errorDetails = typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody); throw new Error(`Failed to send SMS via Sinch. Status: ${response.status}. Details: ${errorDetails}`); } console.log('Sinch API Response:', responseBody); return responseBody; // Contains batch_id, etc. } catch (error) { console.error("Error calling Sinch API:", error); // Re-throw the error to be handled by the controller throw error; } }; export { sendSms };
- Environment Check: Ensures necessary config is present.
- Input Validation: Basic checks for recipients and message body.
- Endpoint Construction: Builds the correct Sinch API URL.
- Payload Creation: Constructs the JSON body required by the Sinch API.
from
: Your Sinch number.to
: An array of recipient E.164 phone numbers.body
: The message content.delivery_report
: Set to"full"
to receive detailed status updates via webhook.callback_url
: The URL Sinch will POST delivery reports to (we'll define this route later).client_reference
: (Optional) A unique ID you generate (e.g., UUID, database message ID) to correlate delivery reports back to your internal records. Useful for reliable lookups in the webhook handler.
- API Request: Uses
node-fetch
to make a POST request with the correct headers (including theAuthorization: Bearer
token). - Error Handling: Checks the HTTP response status and logs/throws errors if the API call fails. Handles both JSON and non-JSON error responses.
- Logging: Includes basic logging for debugging.
- Security Warning: The example logs the entire payload for debugging. In a production environment, avoid logging sensitive information like recipient phone numbers or potentially the full API token (if it were part of the payload) to prevent security and privacy risks.
-
Enable ES Modules: Since we are using
import/export
syntax, add"type": "module"
to yourpackage.json
:// package.json (add this line) { "name": "sinch-marketing-app", "version": "1.0.0", "description": "", "main": "src/server.js", "type": "module", "scripts": { "start": "node src/server.js", "dev": "node --watch src/server.js" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "dotenv": "^16.4.5", "express": "^4.19.2", "node-fetch": "^3.3.2" } }
(Ensure your Node.js version supports ES Modules, v14+ recommended, v18+ for
--watch
).
4. Building the API Layer (Express)
Set up the Express server and define the API endpoints for sending campaigns and receiving webhooks.
-
Configure Express App (
src/app.js
): Set up middleware and routes.// src/app.js import express from 'express'; import campaignRoutes from './routes/campaignRoutes.js'; import webhookRoutes from './routes/webhookRoutes.js'; const app = express(); // Middleware // Use express.json() BEFORE your webhook route if validating signatures that need raw body // If not validating signatures requiring raw body, order is less critical but generally good practice early. app.use(express.json()); // Parse JSON request bodies app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies // Basic Logging Middleware (Example) app.use((req, res, next) => { console.log(`${new Date().toISOString()} - ${req.method} ${req.originalUrl}`); next(); }); // Routes app.get('/', (req, res) => { res.send('Sinch Marketing Campaign API is running!'); }); app.get('/healthz', (req, res) => { // Add checks for DB connection, etc. if needed res.status(200).json({ status: 'OK', timestamp: new Date().toISOString() }); }); app.use('/api/campaigns', campaignRoutes); app.use('/api/webhooks', webhookRoutes); // --- Error Handling Middleware (Should be last) --- // Basic 404 Handler app.use((req, res, next) => { res.status(404).json({ message: 'Not Found' }); }); // General Error Handler app.use((err, req, res, next) => { console.error(""Unhandled Error:"", err); const statusCode = err.statusCode || 500; res.status(statusCode).json({ message: err.message || 'Internal Server Error', // Optionally include stack trace in development ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }), // Show stack unless in production }); }); export default app;
- Imports necessary modules and route handlers.
- Uses
express.json()
andexpress.urlencoded()
to parse incoming request bodies. - Includes basic logging middleware.
- Adds a simple
/healthz
endpoint for monitoring. - Mounts the campaign and webhook routes under
/api/campaigns
and/api/webhooks
. - Includes basic 404 and general error handling middleware (placed last).
-
Create Server Entry Point (
src/server.js
): Load environment variables and start the server.// src/server.js import dotenv from 'dotenv'; dotenv.config(); // Load .env variables BEFORE importing app import app from './app.js'; const PORT = process.env.PORT || 3000; const BASE_URL = process.env.BASE_URL; // Load BASE_URL app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); console.log(`Sinch Service Plan ID: ${process.env.SINCH_SERVICE_PLAN_ID ? 'Loaded' : 'MISSING'}`); console.log(`Sinch API Token: ${process.env.SINCH_API_TOKEN ? 'Loaded' : 'MISSING'}`); console.log(`Sinch Number: ${process.env.SINCH_NUMBER || 'MISSING'}`); console.log(`Sinch Base URL: ${process.env.SINCH_BASE_URL || 'MISSING'}`); if (!BASE_URL) { console.warn(`WARN: BASE_URL environment variable is not set. Webhook callback URL may not be generated correctly.`); } else { console.log(`Public Base URL for Webhooks: ${BASE_URL}`); } });
- Crucially,
dotenv.config()
is called first. - Imports the configured
app
fromapp.js
. - Starts the server listening on the configured
PORT
. - Adds startup logs to verify environment variables are loaded, including a check for
BASE_URL
.
- Crucially,
-
Define Campaign Routes (
src/routes/campaignRoutes.js
): Create the endpoint for sending campaigns.// src/routes/campaignRoutes.js import express from 'express'; import { sendCampaign } from '../controllers/campaignController.js'; const router = express.Router(); // POST /api/campaigns/send router.post('/send', sendCampaign); export default router;
-
Implement Campaign Controller (
src/controllers/campaignController.js
): Handle the logic for the/send
endpoint.// src/controllers/campaignController.js import { sendSms } from '../services/sinchService.js'; import { randomUUID } from 'crypto'; // For generating unique client_reference const sendCampaign = async (req, res, next) => { const { recipients, message } = req.body; // --- Basic Input Validation --- if (!recipients || !Array.isArray(recipients) || recipients.length === 0) { return res.status(400).json({ message: 'Invalid or empty `recipients` array is required.' }); } // Basic E.164 check (can be more robust) const invalidNumbers = recipients.filter(num => !/^\+[1-9]\d{1,14}$/.test(num)); if (invalidNumbers.length > 0) { return res.status(400).json({ message: `Invalid E.164 phone number format detected for: ${invalidNumbers.join(', ')}` }); } if (!message || typeof message !== 'string' || message.trim() === '') { return res.status(400).json({ message: 'A non-empty `message` string is required.' }); } // Add more validation as needed (e.g., using express-validator - see Security section) // --- Construct Callback URL (Important!) --- const webhookBaseUrl = process.env.BASE_URL; // Use the configured public base URL let callbackUrl = null; if (webhookBaseUrl) { // Ensure no double slashes if BASE_URL ends with / and path starts with / const base = webhookBaseUrl.endsWith('/') ? webhookBaseUrl.slice(0, -1) : webhookBaseUrl; callbackUrl = `${base}/api/webhooks/delivery-reports`; console.log(`Using Callback URL: ${callbackUrl}`); } else { console.warn(""BASE_URL not set, cannot generate webhook callback URL. Delivery reports may not be received.""); // Decide if you want to proceed without a callback URL or return an error // return res.status(500).json({ message: ""Server configuration error: BASE_URL is not set."" }); } // --- Generate a unique client_reference for tracking --- // This helps correlate the delivery report back to your specific message/batch const clientReference = randomUUID(); console.log(`Generated client_reference: ${clientReference}`); // TODO: Store this clientReference in your database alongside message details BEFORE sending. try { console.log(`Received campaign request: Send '${message}' to ${recipients.join(', ')}`); // TODO: In a real app, save campaign/message details to DB here, including the clientReference. const sinchResponse = await sendSms(recipients, message, callbackUrl, clientReference); // TODO: Update message status in DB to 'Sent' or similar, store batch_id. res.status(202).json({ // 202 Accepted - Processing started message: 'Campaign accepted for delivery.', batch_id: sinchResponse.id, // Include the batch ID from Sinch client_reference: clientReference, // Return the reference used details: sinchResponse, }); } catch (error) { // Pass error to the central error handler in app.js // TODO: Update message status in DB to 'FailedToSend' or similar. next(error); } }; export { sendCampaign };
- Extracts
recipients
andmessage
from the request body. - Performs basic validation, including a simple E.164 format check. Production systems need more robust validation (see Security Section).
- Constructs the
callbackUrl
dynamically using theBASE_URL
environment variable. Warns ifBASE_URL
is not set. Production Note: For production environments,ngrok
is unsuitable. You'll need a publicly accessible server with a static IP or domain name. YourBASE_URL
environment variable should then be set to this public URL (e.g.,https://yourapp.yourdomain.com
) so Sinch can reach your webhook. - Generates a unique
client_reference
usingcrypto.randomUUID()
. Crucially, this should be stored in your database before callingsendSms
so you can look it up when the webhook arrives. - Calls the
sinchService.sendSms
function, passing thecallbackUrl
andclientReference
. - Returns a
202 Accepted
status, indicating the request is processing, along with thebatch_id
andclient_reference
. - Includes
// TODO:
comments indicating where database interactions would typically occur. - Uses
next(error)
to delegate error handling.
- Extracts
-
Test Sending:
- Start the server:
npm start
(ornpm run dev
if using Node >= 18). - Use
curl
or Postman to send a POST request:
curl -X POST http://localhost:3000/api/campaigns/send \ -H ""Content-Type: application/json"" \ -d '{ ""recipients"": [""+1RECIPIENT_PHONE_NUMBER""], ""message"": ""Hello from our Sinch Marketing App!"" }'
(Replace
+1RECIPIENT_PHONE_NUMBER
with a valid test number in E.164 format).You should see logs in your terminal and receive an SMS on the recipient phone. The response should look similar to:
{ ""message"": ""Campaign accepted for delivery."", ""batch_id"": ""01ARZ3NDEK5MNVQB9712DG5K2H"", ""client_reference"": ""f47ac10b-58cc-4372-a567-0e02b2c3d479"", ""details"": { ""id"": ""01ARZ3NDEK5MNVQB9712DG5K2H"", ""to"": [ ""+1RECIPIENT_PHONE_NUMBER"" ], ""from"": ""+1YOUR_SINCH_NUMBER"", ""canceled"": false, ""body"": ""Hello from our Sinch Marketing App!"", ""type"": ""mt_batch"", ""client_reference"": ""f47ac10b-58cc-4372-a567-0e02b2c3d479"" } }
- Start the server:
5. Integrating Third-Party Services (Sinch Webhooks)
Configure and handle incoming delivery reports from Sinch.
-
Define Webhook Routes (
src/routes/webhookRoutes.js
):// src/routes/webhookRoutes.js import express from 'express'; import { handleDeliveryReport } from '../controllers/webhookController.js'; const router = express.Router(); // POST /api/webhooks/delivery-reports // Sinch sends delivery reports here router.post('/delivery-reports', handleDeliveryReport); export default router;
-
Implement Webhook Controller (
src/controllers/webhookController.js
):// src/controllers/webhookController.js const handleDeliveryReport = async (req, res, next) => { const report = req.body; console.log('--- Received Sinch Delivery Report ---'); console.log(JSON.stringify(report, null, 2)); // Log the full report // --- Process the Report (Example) --- // In a real application, you would: // 1. **Validate the request:** Check for expected fields. Implement signature validation if available/configured (see Security section). // 2. **Find the corresponding message/campaign in your database:** Use the `client_reference` (preferred, if you stored it) or `batch_id`. Look up the message record you created before sending. // ```javascript // // Example DB lookup (pseudo-code) // // const messageRecord = await db.findMessageByClientRef(report.client_reference); // // if (!messageRecord) { console.error(`Message not found for client_reference: ${report.client_reference}`); /* Handle error */ } // ``` // 3. **Update the status of the message:** Mark it as 'Delivered', 'Failed', etc., in your database based on `report.status`. Store `report.code` and `report.operator_status_at` if relevant. // ```javascript // // Example DB update (pseudo-code) // // await db.updateMessageStatus(messageRecord.id, report.status, report.code, report.operator_status_at); // ``` // 4. **Handle specific failure codes (`code`):** Refer to Sinch documentation for meanings and potentially trigger specific actions (e.g., mark number as invalid, alert admin). // 5. **Trigger follow-up actions:** E.g., update analytics, notify users/admins. // 6. **Implement robust error handling:** What happens if the database update fails? Log the error, potentially enqueue the report for retry using a job queue, or send an alert. Avoid letting internal processing errors prevent sending the 200 OK back to Sinch unless absolutely necessary, as Sinch might retry. const { batch_id, status, code, recipient, operator_status_at, client_reference } = report; console.log(`Status report for client_reference [${client_reference || 'N/A'}], batch [${batch_id}], recipient [${recipient}]: ${status} (Code: ${code || 'N/A'}) at ${operator_status_at || 'N/A'}`); // Example: Log specific statuses if (status === 'Delivered') { console.log(`Message to ${recipient} (Ref: ${client_reference}) successfully delivered.`); } else if (status === 'Failed') { console.error(`Message to ${recipient} (Ref: ${client_reference}) failed. Reason code: ${code}.`); // Refer to Sinch documentation for error code meanings: // https://developers.sinch.com/docs/sms/api-reference/error-codes/ } else { console.log(`Received status [${status}] for recipient ${recipient} (Ref: ${client_reference})`); } // --- Respond to Sinch --- // Sinch expects a 2xx response quickly to acknowledge receipt. // Failure to respond, or responding with an error (e.g., 5xx) *may* cause Sinch to retry sending the webhook. // Handle internal processing errors gracefully (log, potentially retry later via a queue) while still acknowledging receipt with a 200 OK where possible to prevent excessive retries from Sinch. res.status(200).json({ message: 'Webhook received successfully.' }); // Note: If an unrecoverable error occurs *during* processing (e.g., DB connection lost), // the global error handler in app.js might catch it and send a 5xx, which could trigger Sinch retries. // Consider using `try...catch` around your database logic if you want finer control over the response code sent to Sinch even when internal processing fails. }; export { handleDeliveryReport };
- Logs the incoming report data from Sinch.
- Includes detailed comments on how a production system would process this data (validation, database lookups using
client_reference
orbatch_id
, status updates, error handling within the handler). - Highlights key fields like
batch_id
,status
,code
,recipient
, andclient_reference
. - Emphasizes looking up messages using the
client_reference
for reliability. - Crucially, sends a
200 OK
response back to Sinch quickly to acknowledge receipt and prevent unnecessary retries. Discusses how to handle internal processing errors gracefully.
-
Expose Localhost with
ngrok
(Development/Testing Only):- If your server is running (e.g.,
npm run dev
), open another terminal window. - Run ngrok to forward traffic to your local server's port (default 3000).
ngrok http 3000
- ngrok will display forwarding URLs. Copy the
https
URL (e.g.,https://abcd-1234-5678.ngrok.io
). - Important:
ngrok
is for development and testing only. For production, you need a publicly hosted server with a stable URL.
- If your server is running (e.g.,
-
Set
BASE_URL
Environment Variable:- Stop your Node.js server (Ctrl+C).
- Edit your
.env
file and set theBASE_URL
to thengrok
HTTPS URL you copied.
# .env (example update) BASE_URL=https://abcd-1234-5678.ngrok.io
- Restart your Node.js server (
npm run dev
ornpm start
). It should now log the correctBASE_URL
.
-
Configure Callback URL in Sinch Dashboard:
- Navigate to your Sinch Customer Dashboard.
- Go to SMS -> APIs.
- Click on your Service Plan ID.
- Scroll down to the Callback URLs section.
- Click Add Callback URL.
- Paste the full webhook endpoint URL, which is your
BASE_URL
plus the route path:https://YOUR_NGROK_HTTPS_URL/api/webhooks/delivery-reports
(e.g.,https://abcd-1234-5678.ngrok.io/api/webhooks/delivery-reports
) - Click Save.
-
Test Webhook:
- Ensure your Node.js server (with the correct
BASE_URL
set) andngrok
are running. - Send another test SMS using the
curl
command from Step 4.5. Make sure the server logs show it's using the correctngrok
basedcallbackUrl
. - Observe the terminal running your Node.js server. After a short delay (seconds to minutes depending on the carrier), you should see the
'--- Received Sinch Delivery Report ---'
log message followed by the JSON payload from Sinch indicating the message status (Delivered
,Failed
, etc.) and including theclient_reference
you sent.
- Ensure your Node.js server (with the correct
6. Database Schema and Data Layer (Conceptual)
While this guide uses in-memory processing, a production system requires persistent storage.
-
Conceptual Schema (e.g., PostgreSQL):
-- Campaigns Table CREATE TABLE campaigns ( campaign_id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, message_body TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, scheduled_at TIMESTAMPTZ NULL, -- For future scheduling status VARCHAR(50) DEFAULT 'Draft' -- e.g., Draft, Sending, Sent, Failed ); -- Recipients Table CREATE TABLE recipients ( recipient_id SERIAL PRIMARY KEY, phone_number VARCHAR(20) UNIQUE NOT NULL, -- E.164 format first_name VARCHAR(100), last_name VARCHAR(100), -- Other relevant data, opt-in status, etc. created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, is_opted_out BOOLEAN DEFAULT FALSE -- For opt-out handling ); -- Campaign Recipients (Many-to-Many linking table - if needed for complex campaign structures) -- Alternatively, store campaign_id directly in the messages table if simpler -- CREATE TABLE campaign_recipients ( -- campaign_recipient_id SERIAL PRIMARY KEY, -- campaign_id INT REFERENCES campaigns(campaign_id), -- recipient_id INT REFERENCES recipients(recipient_id), -- UNIQUE (campaign_id, recipient_id) -- Prevent duplicates -- ); -- Messages Table (Tracks individual message status) CREATE TABLE messages ( message_id SERIAL PRIMARY KEY, campaign_id INT NULL REFERENCES campaigns(campaign_id), -- Link to campaign if applicable recipient_id INT NOT NULL REFERENCES recipients(recipient_id), sinch_batch_id VARCHAR(100) NULL, -- Store the batch ID from Sinch API response sinch_message_id VARCHAR(100) NULL, -- If available per recipient in webhook client_reference VARCHAR(100) UNIQUE NOT NULL, -- Store the unique reference generated before sending status VARCHAR(50) DEFAULT 'Pending', -- e.g., Pending, Sent, Delivered, Failed, Unknown status_code INT NULL, -- Store the Sinch status code (e.g., 401xx) status_details TEXT NULL, -- Store any extra details if needed sent_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, finalized_at TIMESTAMPTZ NULL -- Timestamp of final delivery report (Delivered/Failed) );