code examples
code examples
Sinch SMS Marketing with Node.js Express: Complete Tutorial & API Integration Guide
Learn how to build SMS marketing campaigns with Sinch API, Node.js, Express, PostgreSQL, and Prisma. Production-ready code with webhooks, batch sending, and delivery tracking.
Learn how to build a production-ready SMS marketing campaign system using the Sinch SMS API, Node.js, Express, and PostgreSQL. This comprehensive tutorial covers everything from basic SMS sending to advanced features like webhook integration, batch messaging, contact management, and delivery tracking.
This guide shows you how to create a scalable backend application for managing SMS marketing campaigns. You'll implement contact list management, send personalized bulk SMS messages through Sinch, process delivery reports via webhooks, and store campaign data in PostgreSQL using Prisma ORM. Perfect for developers building customer engagement platforms, notification systems, or marketing automation tools.
Project Overview and Goals
Build an Express application that exposes a REST API for managing SMS marketing campaigns and contacts. The system integrates with the Sinch SMS API for sending messages and relies on webhooks to receive delivery status updates and inbound replies. Store data in a PostgreSQL database using Prisma as the ORM.
Technologies Used:
| Technology | Purpose | Details |
|---|---|---|
| Node.js | Asynchronous JavaScript runtime | Use v18+ (v22 LTS recommended as of January 2025) |
| Express | Web framework | Build REST API and handle webhooks |
| Sinch SMS API | SMS service provider | Global SMS sending/receiving via REST API v1 (/xms/v1/). Chosen for robust REST API and webhook capabilities. |
| PostgreSQL | Relational database | Open-source, ACID-compliant data storage |
| Prisma | ORM | Simplify database interactions (v5.14.0+ or v6.x with Rust-free query engine and ESM support) |
| Axios | HTTP client | Make requests to Sinch API |
| dotenv | Environment config | Load environment variables from .env file |
| Pino | Logger | Fast, low-overhead JSON logging |
| pino-pretty | Development tool | Format Pino logs for readability |
| express-validator | Validation middleware | Validate and sanitize request data |
| Ngrok | Development tool | Expose local server for webhook testing |
System Architecture:
(Note: A Mermaid diagram illustrating the architecture was present in the original text but has been removed for standard Markdown compatibility.)
The system involves these components and interactions:
- Admin/Client interacts with the Express API
- Express API uses a Sinch Service module
- Sinch Service interacts with Prisma ORM
- Prisma ORM manages data in PostgreSQL Database
- Sinch Service sends SMS requests to external Sinch SMS API
- Sinch SMS API delivers SMS messages to User Mobile Phones
- Users reply, sending messages back through Sinch SMS API
- Sinch SMS API sends delivery reports and incoming SMS via webhooks to Webhook Handler in Express
- Webhook Handler updates database via Prisma ORM
Prerequisites:
- Node.js (v18 or later) and npm installed. As of January 2025, Node.js 22 is the current LTS version recommended for production. Node.js 18.x reaches end-of-life on April 30, 2025 – plan to upgrade to Node.js 20 or 22 for continued support.
- A Sinch account (https://www.sinch.com/). You'll need your Service Plan ID and API Token from the Sinch Dashboard under APIs → REST configuration.
- Access to a PostgreSQL database (local or cloud-based).
ngrokinstalled globally (npm install -g ngrok) for local webhook testing.- Basic familiarity with Node.js, Express, REST APIs, and SQL.
- Note: This guide uses Prisma ORM v5.14.0+ (compatible with v6.x which introduced Rust-free query engine and ESM support). Minimum Node.js versions for Prisma 6.x: 18.18.0, 20.9.0, or 22.11.0.
Final Outcome:
By the end of this guide, you'll have a functional backend system capable of:
- Creating, reading, updating, and deleting contacts and campaigns via a REST API
- Sending SMS messages to specified contacts using Sinch
- Receiving and processing delivery reports from Sinch webhooks
- Receiving and processing inbound SMS messages from users via Sinch webhooks
- Storing campaign and contact data persistently
- Basic security, logging, and error handling mechanisms
What Are the Prerequisites for Building a Sinch SMS Marketing System?
Prerequisites:
- Node.js (v18 or later) and npm installed. As of January 2025, Node.js 22 is the current LTS version recommended for production. Node.js 18.x reaches end-of-life on April 30, 2025 – plan to upgrade to Node.js 20 or 22 for continued support.
- A Sinch account (https://www.sinch.com/). You'll need your Service Plan ID and API Token from the Sinch Dashboard under APIs → REST configuration.
- Access to a PostgreSQL database (local or cloud-based).
ngrokinstalled globally (npm install -g ngrok) for local webhook testing.- Basic familiarity with Node.js, Express, REST APIs, and SQL.
- Note: This guide uses Prisma ORM v5.14.0+ (compatible with v6.x which introduced Rust-free query engine and ESM support). Minimum Node.js versions for Prisma 6.x: 18.18.0, 20.9.0, or 22.11.0.
How Do You Set Up a Node.js Project for Sinch SMS Campaigns?
Initialize your Node.js project and install the necessary dependencies for building your SMS marketing application with Sinch.
-
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
bashmkdir sinch-sms-campaign-app cd sinch-sms-campaign-app -
Initialize Node.js Project: Create a
package.jsonfile to manage dependencies and project metadata.bashnpm init -y -
Install Dependencies: Install Express for the server, Axios for HTTP requests, dotenv for environment variables, Prisma for the ORM, the PostgreSQL driver, Pino for logging, and
express-validatorfor request validation.bashnpm install express axios dotenv pg prisma pino pino-http express-validator -
Install Development Dependencies: Install
nodemonto automatically restart the server during development,@prisma/clientfor Prisma's type generation, andpino-prettyto format logs during development.bashnpm install --save-dev nodemon @prisma/client pino-pretty -
Initialize Prisma: Set up Prisma in your project. This creates a
prismadirectory with aschema.prismafile and a.envfile (if one doesn't exist).bashnpx prisma init --datasource-provider postgresql -
Configure
.envFile: Open the.envfile that Prisma created and add placeholders for your Sinch credentials and other configurations. Replace the placeholder values with your actual credentials.dotenv# .env # Database Connection # Example: DATABASE_URL="postgresql://user:password@host:port/database?schema=public" DATABASE_URL="postgresql://postgres:password@localhost:5432/sms_campaigns?schema=public" # Sinch API Credentials (Replace with your actual values from Sinch Dashboard) SINCH_SERVICE_PLAN_ID=YOUR_SINCH_SERVICE_PLAN_ID SINCH_API_TOKEN=YOUR_SINCH_API_TOKEN # A secret string you create, used to verify incoming webhooks (Generate a strong random string) SINCH_WEBHOOK_SECRET=YOUR_STRONG_RANDOM_WEBHOOK_SECRET # Application Port PORT=3000 # Sinch API Base URL (Adjust region if needed: e.g., eu, us, ca, au) # See: [Sinch API reference](https://developers.sinch.com/docs/sms/api-reference/#base-url) SINCH_API_BASE_URL="https://us.sms.api.sinch.com/xms/v1/"DATABASE_URL: Replace with your actual PostgreSQL connection string.SINCH_SERVICE_PLAN_ID,SINCH_API_TOKEN: Obtain these from your Sinch Dashboard (explained in Section 4). Use the actual values, not the placeholders.SINCH_WEBHOOK_SECRET: Generate a strong, random string (e.g., using a password manager or online generator). You'll use this later to configure webhook security in Sinch. Use the actual secret, not the placeholder.SINCH_API_BASE_URL: Ensure this matches the region of your Sinch account/Service Plan.
Sinch API Rate Limits (Official Specifications):
Limit Type Specification Details Messages per Second (MPS) Varies by service plan Each recipient in a batch counts toward limit (10 recipients = 10 messages). Check your Sinch Dashboard for allocated rate limits. Status Query Limit 1 request/second per IP HTTP 429 returned if exceeded Maximum API Concurrency 700 requests/second per IP Overall request rate limit Batch Recipient Limit 1000 recipients per batch Increased from 100 in 2025 Rate Limit Increases Contact Sinch support Available for higher volumes -
Create
.gitignoreFile: Prevent sensitive files and unnecessary directories from being committed to version control.text# .gitignore node_modules/ .env .env.* !/.env.example dist/ npm-debug.log* yarn-debug.log* yarn-error.log* -
Define Project Structure: Create the following directory structure within your project root:
sinch-sms-campaign-app/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── controllers/ │ ├── routes/ │ ├── services/ │ ├── middleware/ │ ├── utils/ │ ├── app.js # Main Express application setup │ └── server.js # Server startup logic ├── .env ├── .gitignore └── package.jsonprisma/: Contains database schema definition.src/: Holds all application source code.controllers/: Handles incoming requests, interacts with services, and sends responses.routes/: Defines API endpoints and maps them to controllers.services/: Contains business logic, including interactions with external APIs (Sinch) and the database (Prisma).middleware/: Custom Express middleware (e.g., error handling, authentication, webhook validation).utils/: Utility functions (e.g., logger setup).app.js: Configures the Express application (middleware, routes).server.js: Initializes and starts the HTTP server.
-
Add Run Scripts to
package.json: Update thescriptssection in yourpackage.json:json{ "name": "sinch-sms-campaign-app", "version": "1.0.0", "description": "", "main": "src/server.js", "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", "prisma:migrate": "npx prisma migrate dev", "prisma:generate": "npx prisma generate", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "axios": "^1.6.8", "dotenv": "^16.4.5", "express": "^4.19.2", "express-validator": "^7.1.0", "pg": "^8.11.5", "pino": "^9.1.0", "pino-http": "^10.0.0", "prisma": "^5.14.0" }, "devDependencies": { "@prisma/client": "^5.14.0", "nodemon": "^3.1.2", "pino-pretty": "^11.1.0" } }(Note: Replace version numbers
^...with actual installed versions if different)start: Runs the application in production mode.dev: Runs the application usingnodemonfor development, automatically restarting on file changes.prisma:migrate: Applies database schema changes.prisma:generate: Generates the Prisma Client based on your schema.
How Do You Implement SMS Sending with the Sinch API?
Implement the service responsible for interacting with the Sinch SMS API to send messages programmatically from your Node.js application.
-
Create Logger Utility: Set up Pino for structured logging, using
pino-prettyfor development readability.javascript// src/utils/logger.js const pino = require('pino'); const isProduction = process.env.NODE_ENV === 'production'; const logger = pino({ level: process.env.LOG_LEVEL || 'info', // Only use pino-pretty in development for nice console output transport: !isProduction ? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard', ignore: 'pid,hostname', }, } : undefined, // Use default JSON output in production }); module.exports = logger; -
Create Sinch Service: This service contains the logic to send SMS messages via the Sinch API.
javascript// src/services/sinchService.js const axios = require('axios'); const logger = require('../utils/logger'); const { SINCH_SERVICE_PLAN_ID, SINCH_API_TOKEN, SINCH_API_BASE_URL } = process.env; if (!SINCH_SERVICE_PLAN_ID || SINCH_SERVICE_PLAN_ID === 'YOUR_SINCH_SERVICE_PLAN_ID' || !SINCH_API_TOKEN || SINCH_API_TOKEN === 'YOUR_SINCH_API_TOKEN' || !SINCH_API_BASE_URL) { logger.error('Sinch credentials or Base URL are missing or placeholders in environment variables. Check your .env file.'); // In a real app, throw an error or exit to prevent startup // process.exit(1); } const sinchClient = axios.create({ baseURL: SINCH_API_BASE_URL, headers: { 'Authorization': `Bearer ${SINCH_API_TOKEN}`, 'Content-Type': 'application/json', }, }); /** * Sends an SMS message using the Sinch API. * @param {string} to - The recipient's phone number in E.164 format (e.g., +15551234567). * @param {string} messageBody - The text content of the message. * @param {string|null} [from=null] - Optional. The sender number (must be a Sinch virtual number). If null, Sinch selects one. * @returns {Promise<object>} - The response data from the Sinch API. * @throws {Error} - If the API request fails. */ const sendSms = async (to, messageBody, from = null) => { const endpoint = `${SINCH_SERVICE_PLAN_ID}/batches`; const payload = { to: [to], // Note: Sinch supports up to 1000 recipients per batch (increased from 100 in 2025) body: messageBody, // delivery_report: 'full', // Request detailed delivery reports (optional) // client_reference: 'your_internal_ref_123', // Optional tracking reference }; // Only add 'from' if it's provided and not null/empty if (from) { payload.from = from; } // Redact sensitive info in logs const logPayload = { ...payload, to: `[REDACTED ${payload.to.length} recipients]` }; logger.info({ payload: logPayload }, 'Attempting to send SMS via Sinch'); try { const response = await sinchClient.post(endpoint, payload); // Redact sensitive info in logs const logResponseData = { ...response.data, to: '[REDACTED]' }; logger.info({ responseData: logResponseData }, `Successfully sent SMS request. Batch ID: ${response.data.id}`); // Store the batch_id from response.data for tracking return response.data; } catch (error) { const errorData = error.response?.data; const status = error.response?.status; logger.error({ errorData, status, recipient: '[REDACTED]' }, 'Failed to send SMS'); // Re-throw a more specific error or handle it as needed throw new Error(`Sinch API error: ${status} – ${JSON.stringify(errorData || error.message)}`); } }; // Placeholder for batch sending – implement based on Section 9 // Note: Sinch batch API supports up to 1000 recipients per batch (as of 2025) const sendSmsBatch = async (recipients, messageBody, from = null) => { // Implementation details in Section 9 // Important: Each recipient counts toward your service plan's rate limit logger.warn('sendSmsBatch function not fully implemented yet. Refer to Section 9.'); // For now, could potentially just loop through sendSms, but batch endpoint is preferred throw new Error('Batch sending not implemented'); }; module.exports = { sendSms, sendSmsBatch, // Export placeholder // We will add webhook handling functions here later };Explanation:
- Import necessary modules and load Sinch credentials from environment variables, adding a check for missing or placeholder values.
- Create an
axiosinstance (sinchClient) with the Sinch API base URL and necessary headers (Authorization Bearer token). - The
sendSmsfunction constructs the correct API endpoint (/xms/v1/{SERVICE_PLAN_ID}/batches) and payload according to the Sinch SMS API reference. - It sends a POST request using
sinchClient. Logging redacts recipient numbers for privacy. - Error handling extracts details from the Axios error response for better debugging.
- Optional parameters like
from,delivery_report, andclient_referenceare mentioned. Using a specificfromnumber requires owning a virtual number in your Sinch account. - A placeholder for
sendSmsBatchis included, to be implemented later based on Section 9.
How Do You Build the REST API Layer for Campaign Management?
Create the Express routes and controllers to manage contacts and SMS campaigns through a REST API.
-
Create Prisma Client Utility: Instantiate the Prisma client.
javascript// src/utils/dbClient.js const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); module.exports = prisma; -
Define Contact Controller: Handle CRUD operations for contacts.
javascript// src/controllers/contactController.js const prisma = require('../utils/dbClient'); const logger = require('../utils/logger'); const createContact = async (req, res, next) => { const { phoneNumber, firstName, lastName, lists } = req.body; // 'lists' could be an array of list names/IDs // Basic validation (more robust validation in routes) if (!phoneNumber) { return res.status(400).json({ message: 'Phone number is required.' }); } try { // TODO: Add phone number normalization (e.g., to E.164) before saving const newContact = await prisma.contact.create({ data: { phoneNumber, // Store normalized number firstName, lastName, // Add logic here to connect to existing lists or create new ones if needed optedOut: false, // Default to opted-in }, }); logger.info({ contactId: newContact.id }, 'New contact created'); // Redact phone number in response res.status(201).json({ ...newContact, phoneNumber: '[REDACTED]' }); } catch (error) { if (error.code === 'P2002' && error.meta?.target?.includes('phoneNumber')) { logger.warn({ phoneNumber: '[REDACTED]' }, 'Attempted to create duplicate contact phone number'); return res.status(409).json({ message: 'Contact with this phone number already exists.' }); } logger.error(error, 'Error creating contact'); next(error); // Pass to global error handler } }; const getContacts = async (req, res, next) => { // TODO: Implement pagination with query params (page, limit) // Example: const page = parseInt(req.query.page) || 1; // const limit = parseInt(req.query.limit) || 50; // const skip = (page - 1) * limit; // Use Prisma's skip and take for pagination try { const contacts = await prisma.contact.findMany({ where: { optedOut: false, // Optionally filter out opted-out contacts by default }, orderBy: { createdAt: 'desc'} }); // Redact phone numbers in response if necessary for privacy const safeContacts = contacts.map(c => ({ ...c, phoneNumber: '[REDACTED]' })); res.status(200).json(safeContacts); } catch (error) { logger.error(error, 'Error fetching contacts'); next(error); } }; const getContactById = async (req, res, next) => { const { id } = req.params; try { const contact = await prisma.contact.findUnique({ where: { id: parseInt(id, 10) }, // Ensure ID is an integer }); if (!contact) { return res.status(404).json({ message: 'Contact not found' }); } // Redact phone number res.status(200).json({ ...contact, phoneNumber: '[REDACTED]' }); } catch (error) { logger.error(error, `Error fetching contact ${id}`); next(error); } }; const updateContact = async (req, res, next) => { const { id } = req.params; const { firstName, lastName, optedOut } = req.body; try { const updatedContact = await prisma.contact.update({ where: { id: parseInt(id, 10) }, data: { firstName, lastName, optedOut, // Allow updating opt-out status // Prevent changing phone number here, use a separate process if needed }, }); logger.info({ contactId: updatedContact.id }, 'Contact updated'); res.status(200).json({ ...updatedContact, phoneNumber: '[REDACTED]' }); } catch (error) { if (error.code === 'P2025') { // Prisma code for record not found on update logger.warn({ contactId: id }, 'Attempted to update non-existent contact'); return res.status(404).json({ message: 'Contact not found' }); } logger.error(error, `Error updating contact ${id}`); next(error); } }; const deleteContact = async (req, res, next) => { const { id } = req.params; try { // Consider soft delete (marking as deleted) instead of hard delete await prisma.contact.delete({ where: { id: parseInt(id, 10) }, }); logger.info({ contactId: id }, 'Contact deleted'); res.status(204).send(); // No content on successful deletion } catch (error) { if (error.code === 'P2025') { // Prisma code for record not found on delete logger.warn({ contactId: id }, 'Attempted to delete non-existent contact'); return res.status(404).json({ message: 'Contact not found' }); } // Handle potential foreign key constraints if relationships exist and aren't handled by Prisma schema (e.g., onDelete) logger.error(error, `Error deleting contact ${id}`); next(error); } }; module.exports = { createContact, getContacts, getContactById, updateContact, deleteContact, }; -
Define Campaign Controller: Handle CRUD for campaigns and triggering the sending process.
javascript// src/controllers/campaignController.js const prisma = require('../utils/dbClient'); const logger = require('../utils/logger'); const { sendSms, sendSmsBatch } = require('../services/sinchService'); // Use sendSms for now, switch to sendSmsBatch later const createCampaign = async (req, res, next) => { const { name, messageBody, targetListId /*, scheduleAt */ } = req.body; // Add scheduling later if (!name || !messageBody) { return res.status(400).json({ message: 'Campaign name and message body are required.' }); } try { const newCampaign = await prisma.campaign.create({ data: { name, messageBody, status: 'DRAFT', // Initial status // targetListId: targetListId ? parseInt(targetListId) : null, // scheduledAt: scheduleAt ? new Date(scheduleAt) : null, }, }); logger.info({ campaignId: newCampaign.id }, 'New campaign created'); res.status(201).json(newCampaign); } catch (error) { logger.error(error, 'Error creating campaign'); next(error); } }; const getCampaigns = async (req, res, next) => { // TODO: Implement pagination try { const campaigns = await prisma.campaign.findMany({ orderBy: { createdAt: 'desc' } }); res.status(200).json(campaigns); } catch (error) { logger.error(error, 'Error fetching campaigns'); next(error); } }; const getCampaignById = async (req, res, next) => { const { id } = req.params; try { const campaign = await prisma.campaign.findUnique({ where: { id: parseInt(id, 10) }, // include: { targetList: true } // Include related list if implementing lists }); if (!campaign) { return res.status(404).json({ message: 'Campaign not found' }); } res.status(200).json(campaign); } catch (error) { logger.error(error, `Error fetching campaign ${id}`); next(error); } }; // Sends the campaign. NOTE: Initial version sends to ONE hardcoded number for testing. // Production use requires implementing contact list fetching and batching (see Section 9). const sendCampaign = async (req, res, next) => { const { id } = req.params; try { const campaign = await prisma.campaign.findUnique({ where: { id: parseInt(id, 10) }, }); if (!campaign) { return res.status(404).json({ message: 'Campaign not found' }); } // Allow sending DRAFT or previously FAILED campaigns if (!['DRAFT', 'FAILED'].includes(campaign.status)) { return res.status(400).json({ message: `Cannot send campaign (current status: ${campaign.status})` }); } await prisma.campaign.update({ where: { id: campaign.id }, data: { status: 'SENDING' } // Mark as sending immediately }); logger.info({ campaignId: campaign.id }, `Starting to send campaign: ${campaign.name}`); // --- Sending Logic --- // WARNING: THIS IS A PLACEHOLDER FOR TESTING ONLY. // It sends to one hardcoded number. // Replace this with proper logic before production use. const testRecipient = '+15559876543'; // <<< IMPORTANT: REPLACE with a valid test number you own! logger.warn({ campaignId: campaign.id, recipient: '[REDACTED]' }, 'Using hardcoded testRecipient. Replace this logic for production!'); // TODO: Implement logic to fetch contacts for the campaign's target list (e.g., from database based on targetListId). // TODO: Implement batching (using sendSmsBatch) and rate limiting for large lists (see Section 9). Ensure opted-out contacts are excluded. try { // Using single sendSms for this simplified version const sinchResponse = await sendSms(testRecipient, campaign.messageBody); // Log the message send attempt await prisma.messageLog.create({ data: { campaignId: campaign.id, contactPhoneNumber: testRecipient, // In real scenario, link to contactId or use actual fetched number sinchBatchId: sinchResponse.id, status: 'SUBMITTED', // Initial status after sending to Sinch direction: 'OUTBOUND', } }); // Update campaign status after successful submission. // For batching (Section 9), this status update should happen after all batches are submitted. await prisma.campaign.update({ where: { id: campaign.id }, data: { status: 'SENT', sentAt: new Date() } // Mark as SENT (or PARTIALLY_SENT/SENDING if batching) }); logger.info({ campaignId: campaign.id, batchId: sinchResponse.id }, `Campaign ${campaign.id} submitted to Sinch.`); res.status(200).json({ message: 'Campaign sending initiated.', batchId: sinchResponse.id }); } catch (sendError) { // If sending fails, mark campaign as FAILED await prisma.campaign.update({ where: { id: campaign.id }, data: { status: 'FAILED' } }); logger.error({ campaignId: campaign.id, error: sendError.message }, `Failed to send campaign ${campaign.id}`); // Don't pass to next(error) here if we handled it and sent a specific response res.status(500).json({ message: 'Failed to send campaign message.', error: sendError.message }); } } catch (error) { // Handle errors fetching the campaign or initial status updates logger.error(error, `Error processing send request for campaign ${id}`); // Attempt to mark as failed if possible and wasn't already sending/sent try { await prisma.campaign.update({ where: { id: parseInt(id, 10), status: 'DRAFT' }, // Only update if still DRAFT data: { status: 'FAILED' } }); } catch (updateError) { logger.error(updateError, `Additionally failed to mark campaign ${id} as FAILED after initial error.`); } next(error); // Pass original error to global handler } }; module.exports = { createCampaign, getCampaigns, getCampaignById, sendCampaign, // Add update/delete later if needed };- Critical Note: The
sendCampaignfunction above is deliberately simplified for initial setup and testing. It uses a hardcoded recipient and does not fetch contacts from a list or implement batching. Replace this logic with the more robust implementation outlined in Section 9 before using it for actual campaigns. TheTODOcomments highlight the missing pieces.
- Critical Note: The
-
Define Routes with Validation: Use
express.Routerandexpress-validatorfor routing and input validation.javascript// src/routes/contactRoutes.js const express = require('express'); const { body, param, validationResult } = require('express-validator'); const contactController = require('../controllers/contactController'); const logger = require('../utils/logger'); const router = express.Router(); // Middleware to handle validation results const validate = (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { logger.warn({ path: req.path, errors: errors.array(), ip: req.ip }, 'Validation failed'); return res.status(400).json({ errors: errors.array() }); } next(); }; // POST /api/contacts - Create Contact router.post('/', [ // Use a regex for stricter E.164 format validation. // Example: /^\+[1-9]\d{1,14}$/ body('phoneNumber').isString().trim().matches(/^\+?[1-9]\d{1,14}$/).withMessage('Must be a valid phone number, ideally in E.164 format (e.g., +15551234567)'), body('firstName').optional({ checkFalsy: true }).isString().trim().isLength({ min: 1, max: 100 }).escape(), body('lastName').optional({ checkFalsy: true }).isString().trim().isLength({ min: 1, max: 100 }).escape(), // Add validation for 'lists' if implementing lists ], validate, contactController.createContact ); // GET /api/contacts - Get All Contacts router.get('/', contactController.getContacts); // GET /api/contacts/:id - Get Single Contact router.get('/:id', [param('id').isInt({ gt: 0 }).withMessage('ID must be a positive integer')], validate, contactController.getContactById ); // PUT /api/contacts/:id - Update Contact router.put('/:id', [ param('id').isInt({ gt: 0 }).withMessage('ID must be a positive integer'), body('firstName').optional({ checkFalsy: true }).isString().trim().isLength({ min: 1, max: 100 }).escape(), body('lastName').optional({ checkFalsy: true }).isString().trim().isLength({ min: 1, max: 100 }).escape(), body('optedOut').optional().isBoolean().withMessage('optedOut must be true or false'), // Do not allow changing phoneNumber via standard PUT body('phoneNumber').not().exists().withMessage('Cannot change phone number via this endpoint'), ], validate, contactController.updateContact ); // DELETE /api/contacts/:id - Delete Contact router.delete('/:id', [param('id').isInt({ gt: 0 }).withMessage('ID must be a positive integer')], validate, contactController.deleteContact ); module.exports = router;javascript// src/routes/campaignRoutes.js const express = require('express'); const { body, param, validationResult } = require('express-validator'); const campaignController = require('../controllers/campaignController'); const logger = require('../utils/logger'); const router = express.Router(); // Middleware to handle validation results (reuse or define locally) const validate = (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { logger.warn({ path: req.path, errors: errors.array(), ip: req.ip }, 'Validation failed'); return res.status(400).json({ errors: errors.array() }); } next(); }; // POST /api/campaigns - Create Campaign router.post('/', [ body('name').isString().trim().isLength({ min: 1, max: 150 }).withMessage('Campaign name is required (max 150 chars)').escape(), body('messageBody').isString().trim().isLength({ min: 1, max: 1600 }).withMessage('Message body is required (max 1600 chars)'), // Max length depends on concatenation // body('targetListId').optional().isInt({ gt: 0 }), // body('scheduleAt').optional().isISO8601().toDate(), ], validate, campaignController.createCampaign ); // GET /api/campaigns - Get All Campaigns router.get('/', campaignController.getCampaigns); // GET /api/campaigns/:id - Get Single Campaign router.get('/:id', [param('id').isInt({ gt: 0 }).withMessage('ID must be a positive integer')], validate, campaignController.getCampaignById ); // POST /api/campaigns/:id/send - Send Campaign router.post('/:id/send', [param('id').isInt({ gt: 0 }).withMessage('ID must be a positive integer')], validate, campaignController.sendCampaign ); // Add PUT/DELETE for campaigns if needed (e.g., update name, message, schedule) module.exports = router; -
Set up Express App (
app.js): Configure middleware and mount routes.javascript// src/app.js require('dotenv').config(); // Load .env variables early const express = require('express'); const pinoHttp = require('pino-http'); const logger = require('./utils/logger'); const contactRoutes = require('./routes/contactRoutes'); const campaignRoutes = require('./routes/campaignRoutes'); // Import webhook routes later // const webhookRoutes = require('./routes/webhookRoutes'); const app = express(); // Middleware app.use(pinoHttp({ logger })); // Use pino-http for request logging app.use(express.json()); // Parse JSON request bodies app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies // API Routes app.get('/health', (req, res) => res.status(200).send('OK')); // Health check endpoint app.use('/api/contacts', contactRoutes); app.use('/api/campaigns', campaignRoutes); // Mount webhook routes later, potentially with specific middleware for raw body parsing // app.use('/webhooks', webhookRoutes); // Global Error Handler // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { logger.error(err, `Unhandled error for request ${req.method} ${req.originalUrl}`); // Avoid sending detailed errors in production const isProduction = process.env.NODE_ENV === 'production'; const statusCode = err.statusCode || 500; const message = isProduction ? 'Internal Server Error' : err.message; const stack = isProduction ? undefined : err.stack; // Only include stack in non-production res.status(statusCode).json({ status: 'error', message, stack, // Will be undefined in production }); }); // 404 Handler for unmatched routes app.use((req, res) => { logger.warn(`404 Not Found: ${req.method} ${req.originalUrl}`); res.status(404).json({ message: 'Not Found' }); }); module.exports = app;
Frequently Asked Questions About Sinch SMS Marketing Campaigns
What Node.js version is required for Sinch SMS integration?
Use Node.js 18 or higher for modern Sinch SMS API implementations. As of January 2025, Node.js 22 is the current LTS version recommended for production. Node.js 18.x reaches end-of-life on April 30, 2025 – upgrade to Node.js 20 or 22 for continued support and security updates. If using Prisma 6.x, minimum versions are 18.18.0, 20.9.0, or 22.11.0.
What are the Sinch SMS API rate limits?
Each Sinch service plan has a maximum messages per second (MPS) rate limit that varies by account. A batch with multiple recipients counts each recipient individually toward the rate limit (e.g., 10 recipients = 10 messages). Status query limit is 1 request per second per IP address (HTTP 429 if exceeded). Maximum API concurrency is 700 requests per second per IP. As of 2025, Sinch increased the max to field from 100 to 1000 recipients per batch. Contact Sinch support for higher volume adjustments.
How do you send SMS to multiple recipients with Sinch?
Sinch supports batch sending through the /batches endpoint. Include an array of phone numbers in the to field – send to up to 1000 recipients per batch (increased from 100 in 2025). Each recipient counts toward your service plan's rate limit. For large campaigns, implement rate limiting and queue systems to respect your MPS limits. Store batch IDs for delivery tracking.
Example batch sending implementation:
const sendSmsBatch = async (recipients, messageBody, from = null) => {
const endpoint = `${SINCH_SERVICE_PLAN_ID}/batches`;
const payload = {
to: recipients, // Array of up to 1000 phone numbers in E.164 format
body: messageBody,
};
if (from) {
payload.from = from;
}
const response = await sinchClient.post(endpoint, payload);
return response.data;
};What database should you use for SMS marketing campaigns?
Use PostgreSQL for SMS marketing systems due to its reliability, ACID compliance, and robust support for relational data. Use Prisma ORM (v5.14.0+ or v6.x) to simplify database interactions. Your schema should include tables for contacts, campaigns, message logs, and delivery reports. Implement proper indexing on phone numbers and timestamps for query performance.
How do you handle Sinch webhook delivery reports?
Configure a webhook URL in your Sinch Dashboard to receive delivery status updates. Your Express endpoint should:
- Validate the webhook signature using HMAC with your webhook secret
- Parse the delivery report payload from Sinch
- Update message status in your database (delivered, failed, undelivered)
- Log the event for auditing and troubleshooting
- Respond with HTTP 200 within a few seconds to prevent retries
Use ngrok for local development testing. Example validation:
const crypto = require('crypto');
const validateWebhook = (req, res, next) => {
const signature = req.headers['x-sinch-signature'];
const hash = crypto.createHmac('sha256', process.env.SINCH_WEBHOOK_SECRET)
.update(JSON.stringify(req.body))
.digest('base64');
if (signature !== hash) {
return res.status(401).json({ message: 'Invalid webhook signature' });
}
next();
};What is the maximum SMS message length for Sinch?
A single SMS segment contains up to 160 GSM-7 characters or 70 Unicode characters (for emojis and special characters). Longer messages are automatically split into concatenated segments. Sinch supports message bodies up to 1600 characters in the API request. Each segment is billed separately, so monitor message length to control costs. Use GSM-7 encoding when possible for maximum efficiency.
How do you implement opt-out functionality for SMS campaigns?
Add an optedOut boolean field to your contact schema. Exclude opted-out contacts from campaign queries using where: { optedOut: false }. Provide an API endpoint for users to update opt-out status. Include opt-out instructions in your messages (e.g., "Reply STOP to unsubscribe"). Process inbound STOP messages via Sinch webhooks and automatically update the contact's opt-out status. Maintain compliance with SMS marketing regulations (TCPA, GDPR).
Example opt-out handling:
// In webhook handler for inbound messages
if (inboundMessage.body.toUpperCase().includes('STOP')) {
await prisma.contact.update({
where: { phoneNumber: inboundMessage.from },
data: { optedOut: true }
});
// Send confirmation message
}What Prisma version works with Node.js 22?
Prisma v5.14.0+ and v6.x are compatible with Node.js 22. Prisma 6.x introduced a Rust-free query engine, ESM support, and improved performance for serverless environments. Minimum Node.js versions for Prisma 6.x are 18.18.0, 20.9.0, or 22.11.0. Prisma 6.x also requires TypeScript 5.1.0 minimum if using TypeScript. The new architecture provides better performance and easier customization.
How do you secure Sinch API credentials in production?
Store Sinch credentials (Service Plan ID and API Token) in environment variables – never in code. Use .env files for local development and add them to .gitignore. In production, use platform-specific secret management (AWS Secrets Manager, Azure Key Vault, Heroku Config Vars). Rotate API tokens regularly. Implement webhook signature validation to verify requests come from Sinch. Use HTTPS for all API communication.
Production security checklist:
- ✓ Store credentials in environment variables
- ✓ Use secret management services (AWS Secrets Manager, Azure Key Vault)
- ✓ Rotate API tokens every 90 days
- ✓ Validate webhook signatures with HMAC
- ✓ Use HTTPS for all endpoints
- ✓ Implement rate limiting on your API endpoints
- ✓ Log and monitor for suspicious activity
What is the cost of sending SMS campaigns with Sinch?
Sinch SMS pricing varies by destination country and message type. Costs typically range from $0.0035 to $0.10 per message segment depending on the destination. MMS messages cost more than SMS. International messages have higher rates than domestic. Messages split into multiple segments are billed per segment. Check your Sinch Dashboard for real-time pricing for specific countries and monitor usage to control costs. Volume discounts may be available – contact Sinch sales.
Cost optimization strategies:
- Use GSM-7 encoding to maximize characters per segment (160 vs 70)
- Avoid unnecessary emojis and special characters that trigger Unicode encoding
- Monitor message length to prevent unintended multi-segment messages
- Use batch sending to reduce API overhead
- Implement message scheduling to spread costs over time
- Track delivery reports to identify and remove invalid numbers
Frequently Asked Questions
How to send SMS messages with Sinch and Node.js?
The SinchService.js module handles sending SMS messages. It uses Axios to make POST requests to the Sinch API with recipient numbers, message content, and optionally a sender number. The sendSms function manages single messages, while sendSmsBatch (to be implemented) will handle sending to multiple recipients.
What is the role of Prisma in this SMS campaign app?
Prisma is used as an Object-Relational Mapper (ORM) to simplify database interactions. It allows the application to interact with the PostgreSQL database using JavaScript objects and functions instead of raw SQL queries, making the code cleaner and easier to manage.
Why use Express.js for building this application?
Express.js is a minimalist web framework for Node.js that makes it easy to build APIs and handle HTTP requests. It provides a structure for routing, middleware, and request handling, making the backend code more organized and manageable.
When to use ngrok with Sinch webhooks?
Ngrok is essential during development to test webhooks locally. It creates a public URL that forwards requests to your local server, allowing Sinch to deliver webhook events even when your app isn't publicly deployed.
Can I schedule SMS campaigns for future delivery?
The current example code does not directly support scheduling but includes placeholders for this functionality. You would need to implement database fields (like scheduleAt) and background processes to check and trigger sending at the right time.
What are the prerequisites for running this Node.js application?
You'll need Node.js v18 or later, npm, a Sinch account (free or paid), PostgreSQL database access, ngrok for webhook testing, and basic knowledge of Node.js, Express, REST APIs, and SQL.
How to handle Sinch SMS delivery reports?
While the code doesn't show it yet, delivery reports are handled through Sinch webhooks. The Express app must expose a webhook endpoint to receive these updates and update message status in the database accordingly.
What is the purpose of express-validator?
Express-validator is middleware for validating user inputs before they reach controllers. This helps ensure data integrity and prevents unexpected errors. It allows for flexible validation using rules and sanitization methods.
How to manage contact lists in this SMS campaign app?
The provided examples don't implement contact lists, but they can be added. You would define database models to represent lists, link contacts to lists, and modify controllers to handle list creation, assignment, and fetching contacts from specified lists for campaigns.
How to create a new SMS campaign via the API?
A POST request to the /api/campaigns endpoint with the campaign name, message body, and optional target list ID creates a new campaign marked with the initial status 'DRAFT'. Validation is performed using express-validator.
Why does the sendCampaign function use a hardcoded recipient?
The hardcoded recipient is a temporary placeholder for initial testing. It is critically important to replace this part with proper contact list fetching logic before using the app for real campaigns.
What database is used to store data for this SMS campaign system?
The application uses PostgreSQL, a robust open-source relational database, to store contact information, campaign details, and message logs. The DATABASE_URL environment variable configures the connection.
How to test the Sinch SMS integration locally?
Ngrok allows you to expose your local development server to the internet. This enables Sinch webhooks to reach your application during testing, even before deploying it to a public server.
What is the maximum length of an SMS message body?
The code enforces a maximum of 1600 characters for the message body, as longer messages may be split into multiple parts. However, this limit can be adjusted as needed or based on limitations of Sinch or mobile carriers.