code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

How to Build SMS Marketing Campaigns with Sinch, Node.js & NextAuth (2025)

A step-by-step guide to creating an SMS marketing campaign application using Node.js, Express, and the Sinch SMS API, covering setup, sending messages, and handling delivery reports.

Building SMS Marketing Campaigns with Sinch, Node.js & Express (2025 Guide)

Learn how to build a production-ready SMS marketing campaign system using the Sinch SMS API, Node.js, Express 5, and NextAuth authentication. This comprehensive tutorial covers everything from project setup to sending bulk SMS messages, handling delivery webhooks, securing your API endpoints, and deploying to production.

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.

Framework Compatibility Note (2025): This guide uses Express 5.x, which became the default on npm as of March 31, 2025. Express 5 requires Node.js 18 or higher and includes breaking changes such as improved async error handling and removal of legacy middleware. This guide is compatible with Node.js 22.x (current LTS, Active until October 2025) and Node.js 24.x (released May 2025).

What You'll Build: SMS Marketing System with Sinch and Node.js

Technologies Used:

  • Node.js: A JavaScript runtime environment for building server-side applications. (Requires Node.js 18+)
  • Express.js: A minimal and flexible Node.js web application framework for building APIs and web servers. (Express 5.1.0+, default since March 2025)
  • Sinch SMS API: A powerful REST API for sending and receiving SMS messages globally. (Max 1000 recipients per request as of 2024)
  • NextAuth: Authentication library for securing API endpoints and managing user sessions
  • dotenv: A module to load environment variables from a .env file into process.env. (Current version: 17.2.3)
  • node-fetch: A module that brings the browser fetch API to Node.js, used for making HTTP requests to the Sinch API. (Note: Node.js 18+ includes native fetch support; node-fetch is optional)
  • (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. Node.js 18 or higher required for Express 5.x compatibility. Recommended: Node.js 22.x (current LTS) or Node.js 24.x.
  • 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.

Note on Native Fetch: Node.js 18 includes experimental native fetch support (stable in Node.js 21+). If using Node.js 18 or higher, you can use native fetch() instead of installing node-fetch. This guide uses node-fetch for broader compatibility, but native fetch is recommended for production use with modern Node.js versions.


Step 1: Setting Up Your Node.js SMS Marketing Project

Let's initialize our Node.js project and install the necessary dependencies for building SMS marketing campaigns with Sinch.

  1. Create Project Directory: Open your terminal and create a new directory for the project.

    bash
    mkdir sinch-marketing-app
    cd sinch-marketing-app
  2. Initialize npm: Initialize the project using npm. You can accept the defaults.

    bash
    npm init -y

    This creates a package.json file.

  3. Install Dependencies: Install Express for the web server, dotenv for environment variables, and node-fetch for making API requests.

    bash
    npm install express dotenv node-fetch

    Alternative for Node.js 18+: If using Node.js 18 or higher, you can omit node-fetch and use native fetch() instead:

    bash
    npm install express dotenv
  4. Project Structure: Create a basic directory structure for organization.

    bash
    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.
  5. Configure .gitignore: Add node_modules and .env to your .gitignore file to prevent committing them.

    text
    # .gitignore
    
    node_modules/
    .env
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*

Step 2: Configuring Sinch API Credentials

Securely store your Sinch credentials and configuration using environment variables.

  1. Edit .env file: Open the .env file and add your Sinch details. Replace the placeholder values with your actual credentials.

    dotenv
    # .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 webhook callback_url. Use your ngrok HTTPS URL during development/testing, and your actual domain name in production.
  2. Load Environment Variables: Configure dotenv at the very beginning of your application entry point (src/server.js) to load these variables.


Step 3: Building the Sinch SMS Service Module

Create a dedicated service to handle communication with the Sinch API. This encapsulates the logic and makes it reusable.

  1. Edit src/services/sinchService.js: Implement the function to send SMS messages.

    javascript
    // 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 (recipients.length > 1000) {
            throw new Error("Recipient list exceeds maximum of 1000 recipients per request (Sinch API limit as of 2024).");
        }
        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 the Authorization: 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.
  2. Enable ES Modules: Since we are using import/export syntax, add "type": "module" to your package.json:

    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": "^17.2.3",
        "express": "^5.1.0",
        "node-fetch": "^3.3.2"
      }
    }

    (Ensure your Node.js version supports ES Modules and native fetch. Node.js 18+ recommended, Node.js 22+ for full LTS support).


Step 4: Creating the Express API for Campaign Management

Set up the Express server and define the API endpoints for sending campaigns and receiving webhooks.

  1. Configure Express App (src/app.js): Set up middleware and routes.

    javascript
    // 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() and express.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).
  2. Create Server Entry Point (src/server.js): Load environment variables and start the server.

    javascript
    // 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 from app.js.
    • Starts the server listening on the configured PORT.
    • Adds startup logs to verify environment variables are loaded, including a check for BASE_URL.
  3. Define Campaign Routes (src/routes/campaignRoutes.js): Create the endpoint for sending campaigns.

    javascript
    // 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;
  4. Implement Campaign Controller (src/controllers/campaignController.js): Handle the logic for the /send endpoint.

    javascript
    // 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 and message 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 the BASE_URL environment variable. Warns if BASE_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. Your BASE_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 using crypto.randomUUID(). Crucially, this should be stored in your database before calling sendSms so you can look it up when the webhook arrives.
    • Calls the sinchService.sendSms function, passing the callbackUrl and clientReference.
    • Returns a 202 Accepted status, indicating the request is processing, along with the batch_id and client_reference.
    • Includes // TODO: comments indicating where database interactions would typically occur.
    • Uses next(error) to delegate error handling.
  5. Test Sending:

    • Start the server: npm start (or npm run dev if using Node >= 18).
    • Use curl or Postman to send a POST request:
    bash
    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:

    json
    {
        "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"
        }
    }

Step 5: Implementing Sinch Webhook Handlers for Delivery Reports

Configure and handle incoming delivery reports from Sinch to track SMS delivery status.

  1. Define Webhook Routes (src/routes/webhookRoutes.js):

    javascript
    // 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;
  2. Implement Webhook Controller (src/controllers/webhookController.js):

    javascript
    // 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 or batch_id, status updates, error handling within the handler).
    • Highlights key fields like batch_id, status, code, recipient, and client_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.
  3. 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).
    bash
    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.
  4. Set BASE_URL Environment Variable:

    • Stop your Node.js server (Ctrl+C).
    • Edit your .env file and set the BASE_URL to the ngrok HTTPS URL you copied.
    dotenv
    # .env (example update)
    BASE_URL=https://abcd-1234-5678.ngrok.io
    • Restart your Node.js server (npm run dev or npm start). It should now log the correct BASE_URL.
  5. 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.
  6. Test Webhook:

    • Ensure your Node.js server (with the correct BASE_URL set) and ngrok are running.
    • Send another test SMS using the curl command from Step 4.5. Make sure the server logs show it's using the correct ngrok based callbackUrl.
    • 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 the client_reference you sent.

Securing Your SMS API with NextAuth (Optional)

For production SMS marketing campaigns, you should secure your API endpoints with authentication. Here's how to integrate NextAuth for protecting campaign routes:

  1. Install NextAuth dependencies:

    bash
    npm install next-auth
  2. Add authentication middleware to campaign routes to ensure only authorized users can send SMS campaigns.

For a complete NextAuth integration guide with SMS functionality, see our detailed tutorial on implementing SMS authentication with NextAuth.


Database Schema for SMS Campaign Tracking (Conceptual)

While this guide uses in-memory processing, a production system requires persistent storage.

  • Conceptual Schema (e.g., PostgreSQL):

    sql
    -- 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)
    );
  • Implementation Considerations:

    • Use an ORM (Sequelize, Prisma, TypeORM) or query builder (Knex.js) for database interactions.
    • Store the client_reference before calling the Sinch API to ensure you can correlate webhook delivery reports.
    • Index the client_reference column for fast lookups when processing webhooks.
    • Implement proper foreign key relationships and cascading deletes as appropriate.
    • Consider adding indexes on status, campaign_id, and sent_at for query performance.

Frequently Asked Questions About SMS Marketing with Sinch and Node.js

What Node.js version is required for building SMS campaigns with Sinch?

You need Node.js 18 or higher to use Express 5.x, which became the default on npm as of March 31, 2025. We recommend using Node.js 22.x (current LTS, Active until October 2025) or Node.js 24.x (released May 2025) for production applications. Express 5 includes breaking changes such as improved async error handling and requires the removal of legacy middleware patterns.

How many recipients can I send SMS to in a single Sinch API request?

As of 2024, Sinch supports a maximum of 1000 recipients per request. This limit was increased from the previous 100-recipient cap. If you need to send to more than 1000 recipients, implement batching logic to split your recipient list into groups of 1000 or fewer and process each batch sequentially with appropriate delays to respect rate limits.

Should I use node-fetch or native fetch() for Sinch API calls?

If you're using Node.js 18 or higher, use the native fetch() function, which is built into Node.js (experimental in v18, stable in v21+). Native fetch provides better performance and eliminates an external dependency. Only use node-fetch if you need to support older Node.js versions or require specific features not available in the native implementation.

How do Sinch SMS delivery reports work with webhooks?

Sinch sends HTTP POST requests to your configured callback_url when message status changes occur (delivered, failed, etc.). Your webhook endpoint must return a 200 OK response quickly to acknowledge receipt. Use the client_reference field (a UUID you generate before sending) to correlate delivery reports with your database records. Sinch may retry webhook delivery if your endpoint returns an error or times out.

What are the Sinch SMS API rate limits for marketing campaigns?

Rate limits are plan-specific and set the maximum messages per second. Status queries have a limit of 1 request per second per IP address, with a maximum of 700 requests per second per IP for all endpoints. Each recipient in a batch counts as one message for rate limiting purposes. Contact your Sinch account manager to adjust rate limits for your specific use case.

How do I handle STOP/unsubscribe requests in SMS marketing?

Implement an inbound SMS webhook handler to process messages containing keywords like STOP, UNSUBSCRIBE, or END. When you receive these messages, immediately add the sender's number to your suppression list in your recipients table (set is_opted_out = TRUE) and send a confirmation message. Always filter opted-out numbers from your recipient lists before sending campaigns to maintain compliance with telecommunications regulations and avoid penalties.

Can I integrate NextAuth authentication with this Sinch SMS setup?

Yes! This tutorial uses Express.js for the API server and can be extended with NextAuth for authentication. To integrate with Next.js and NextAuth, create API routes in the pages/api or app/api directory and adapt the controller logic to Next.js API route handlers. You can add authentication middleware to protect your campaign endpoints and associate campaigns with authenticated users. The Sinch service layer code remains largely the same regardless of the framework.

What's the difference between batch_id and client_reference in Sinch?

The batch_id is generated by Sinch and returned when you send a message batch – it identifies the batch in Sinch's system. The client_reference is a unique identifier you generate (typically a UUID) before sending, allowing you to correlate Sinch's delivery reports with your internal database records. Always use client_reference for reliable lookups in webhook handlers, as it's under your control and can be stored before the API call.

How do I test Sinch webhooks locally during development?

Use ngrok to expose your local development server to the internet. Run ngrok http 3000 to get a public HTTPS URL, set this as your BASE_URL environment variable, and configure the full webhook endpoint URL in your Sinch dashboard (e.g., https://your-ngrok-id.ngrok.io/api/webhooks/delivery-reports). Remember that ngrok URLs change each time you restart, so update your .env file and Sinch dashboard configuration accordingly. Never use ngrok in production – deploy to a server with a stable public URL.

What security measures should I implement for production SMS campaigns?

Essential security measures include: API key authentication or JWT tokens for campaign endpoints, input validation using libraries like express-validator for phone numbers and message content, rate limiting with express-rate-limit to prevent abuse, HTTPS-only communication (TLS), webhook signature verification if supported by Sinch, secure storage of API credentials using environment variables or secrets managers (AWS Secrets Manager, HashiCorp Vault), IP allowlisting for webhook endpoints, and proper error handling that doesn't expose sensitive information in responses.

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

Sinch SMS pricing varies by destination country and message volume. Costs typically range from $0.0075 to $0.05 per SMS depending on the country. Marketing campaigns should factor in delivery report costs if using premium features. Visit the Sinch pricing page for detailed rates. For cost optimization, implement message batching, use delivery reports only when necessary, and consider scheduling campaigns during off-peak hours.

Frequently Asked Questions

How to send SMS messages with Node.js and Express?

Use the Sinch SMS API with Node.js and Express to send SMS messages. This involves setting up an Express server, configuring the Sinch API, and implementing routes and controllers to handle message sending and delivery reports. A dedicated service like 'sinchService.js' helps encapsulate the API interaction logic.

What is Sinch SMS API used for in Node.js?

The Sinch SMS API enables sending and receiving SMS messages globally within your Node.js applications. It provides a RESTful interface accessible using libraries like 'node-fetch', allowing developers to integrate SMS functionality into their projects.

How to set up a Sinch marketing campaign in Node.js?

Create a Node.js Express app with API endpoints for accepting campaign details (recipients, message content). Use environment variables to securely store your Sinch API credentials. Use a service to handle interaction with the Sinch API, and include error handling and logging.

Why does Sinch need a callback URL for SMS?

Sinch uses callback URLs (webhooks) to deliver real-time updates on message delivery status. When you send a message, specify a callback URL in the API request. Sinch will POST delivery reports to this URL, including the status (Delivered, Failed) and other details. This is how you know whether your message was successfully sent and delivered.

When should I use ngrok with Sinch SMS API?

Use ngrok during development to expose your local server and test Sinch webhooks. Ngrok provides a public HTTPS URL that Sinch can reach, essential for receiving delivery reports while your app is running locally.

What is the purpose of client_reference in Sinch?

The `client_reference` is a unique identifier you generate to track individual SMS messages within your system. Include it in the API request when sending messages, and Sinch will return it in delivery reports, allowing you to correlate reports back to your internal message records.

How to handle Sinch delivery reports in Express?

Create a specific route in your Express app to receive POST requests from Sinch (e.g., '/api/webhooks/delivery-reports'). In the route handler, process the JSON payload, validate it, and update your database based on the delivery status and unique `client_reference`.

How to install necessary packages for using Sinch in Node.js?

Use npm or yarn to install the required packages. `npm install express dotenv node-fetch` will install Express for the webserver, dotenv for handling environment variables, and node-fetch for making API requests.

What are the prerequisites for building a Sinch SMS system?

You'll need Node.js and npm installed, a Sinch account with a Service Plan ID and API Token, a Sinch phone number, and optionally ngrok for webhook testing. Basic knowledge of Node.js, Express, and REST APIs is also helpful.

How to structure a Node.js project for Sinch SMS marketing?

Create directories for routes, controllers, and services. Create files for app configuration, server setup, and route definitions. Use `.env` to store sensitive information, and `.gitignore` to exclude files from version control.

What is the role of dotenv in a Sinch SMS project?

Dotenv loads environment variables from a `.env` file into `process.env`, making it easy to manage configuration values like API keys and other sensitive information. This helps secure your credentials and keeps them separate from your code.

Why does the Sinch API require environment variables?

Storing sensitive information like API keys directly in your code is a security risk. Environment variables, loaded with dotenv, provide a more secure way to configure your Sinch integration without exposing credentials.

How can I secure my Sinch API integration further?

Use a strong validation library for API requests, implement robust input sanitization, and avoid logging sensitive data in production. If available, implement webhook signature validation to ensure requests come from Sinch.

Can I test Sinch webhooks locally during development?

Yes, ngrok creates a secure tunnel that allows you to receive webhooks locally, enabling testing of delivery reports during development without deploying your application.