code examples

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

SMS Marketing Campaigns with Sinch API: Fastify & Node.js Guide

Build production-ready bulk SMS marketing campaigns using Sinch REST API, Fastify, and Node.js. Complete guide covering rate limiting, TCPA compliance, Prisma integration, opt-out handling, and deployment for scalable messaging systems.

Building a Scalable SMS Campaign Sender with Fastify, Node.js, and Sinch

Learn how to build production-ready SMS marketing campaigns using Sinch REST API, Fastify, and Node.js. This comprehensive tutorial covers bulk SMS sending, TCPA compliance, rate limiting, database integration with Prisma, and deployment strategies for scalable messaging systems.

You'll create a complete API service that manages bulk SMS campaigns, handles opt-outs, implements suppression lists, and processes delivery reports. This guide addresses real-world requirements for SMS marketing including regulatory compliance, performance optimization, and error handling.

Project Overview and Goals

  • Goal: Create a Node.js backend service that exposes an API endpoint to send SMS messages to a list of recipients using the Sinch SMS API.
  • Technology:
    • Node.js: JavaScript runtime environment.
    • Fastify: High-performance web framework chosen for speed, extensibility, and built-in logging with schema validation.
    • Sinch SMS REST API: Third-party service for sending SMS via the /batches endpoint.
    • Axios: Promise-based HTTP client for Sinch API requests.
    • Dotenv: Secure environment variable management.
    • Prisma: (Optional) ORM for database interaction to log campaign details.
    • Docker: Application containerization for deployment.
  • Outcome: REST API endpoint (POST /campaigns) that accepts JSON payload with recipients (phone number array) and message (string), sends SMS via Sinch, logs attempts, and returns confirmation.
  • Prerequisites:
    • Node.js and npm (or yarn) installed.
    • Sinch account with SMS API credentials (Service Plan ID, API Token).
    • Provisioned phone number within your Sinch account.
    • Basic familiarity with Node.js, REST APIs, and terminal commands.
    • (Optional) Docker installed for containerization.
    • (Optional) Database (PostgreSQL, MySQL) for campaign logging with Prisma.

System Architecture

mermaid
graph LR
    A[Client / API Caller] -- HTTP POST /campaigns --> B(Fastify App);
    B -- Send SMS Request --> C(Sinch SMS REST API);
    C -- SMS Delivery --> D(Recipient Phones);
    B -- Log Campaign Data --> E[(Database)];
    C -- Response --> B;
    B -- API Response --> A;

    style B fill:#f9f,stroke:#333,stroke-width:2px
    style C fill:#ccf,stroke:#333,stroke-width:2px
    style E fill:#eee,stroke:#333,stroke-width:1px,stroke-dasharray: 5 5

(Note: Verify Mermaid diagram rendering on your publishing platform)

1. Setting up the Project

Initialize your Node.js project and install necessary dependencies.

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

    bash
    mkdir sinch-fastify-campaigns
    cd sinch-fastify-campaigns
  2. Initialize Node.js Project: Create a package.json file with default settings:

    bash
    npm init -y
  3. Install Dependencies: Install Fastify, Axios, and Dotenv:

    bash
    npm install fastify axios dotenv
  4. Install Development Dependencies (Optional – Prisma): For database logging, install Prisma:

    bash
    npm install prisma @prisma/client --save-dev
  5. Initialize Prisma (Optional): Create prisma directory with schema.prisma file:

    bash
    npx prisma init
    • Configuration: Update DATABASE_URL in .env with your database connection string.
  6. Create Project Structure: Set up directory structure:

    bash
    mkdir src
    touch src/server.js src/sinchService.js .env .gitignore
    • src/server.js: Main application file with Fastify setup and routes.
    • src/sinchService.js: Module for Sinch API interaction.
    • .env: Stores sensitive credentials. Never commit this file.
    • .gitignore: Specifies untracked files for Git.
  7. Configure .gitignore: Add Node.js ignores and .env:

    plaintext
    # .gitignore
    
    node_modules
    .env
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    dist
    coverage
    .DS_Store
  8. Configure .env: Add placeholders for Sinch credentials:

    plaintext
    # .env
    
    # Sinch Credentials
    SINCH_SERVICE_PLAN_ID=YOUR_SERVICE_PLAN_ID
    SINCH_API_TOKEN=YOUR_API_TOKEN
    SINCH_NUMBER=YOUR_SINCH_PHONE_NUMBER # e.g., +15551234567
    SINCH_REGION_URL=https://us.sms.api.sinch.com # Or eu., ca., au., br., etc.
    
    # Server Configuration
    PORT=3000
    
    # Database (Optional – Adjust based on your provider)
    # Example for PostgreSQL
    DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
    • Purpose: .env keeps sensitive data secure and out of your codebase. dotenv loads these into process.env.

2. Implementing Core Functionality (Sinch Service)

Encapsulate SMS sending logic in a dedicated service module.

  1. Edit src/sinchService.js: Create a function for Sinch's /batches endpoint:

    javascript
    // src/sinchService.js
    import axios from 'axios';
    
    // Load Sinch credentials from environment variables
    const SERVICE_PLAN_ID = process.env.SINCH_SERVICE_PLAN_ID;
    const API_TOKEN = process.env.SINCH_API_TOKEN;
    const SINCH_NUMBER = process.env.SINCH_NUMBER;
    const SINCH_API_BASE_URL = process.env.SINCH_REGION_URL || 'https://us.sms.api.sinch.com';
    
    /**
     * Sends an SMS campaign batch using the Sinch REST API.
     * @param {string[]} recipients - Array of phone numbers in E.164 format (e.g., +15551234567).
     * @param {string} message - Text message body.
     * @returns {Promise<object>} - Response data from Sinch API.
     * @throws {Error} - Throws error if API call fails.
     */
    async function sendSmsBatch(recipients, message) {
      if (!SERVICE_PLAN_ID || !API_TOKEN || !SINCH_NUMBER) {
        throw new Error('Sinch API credentials are not configured in .env file.');
      }
      if (!recipients || recipients.length === 0) {
        throw new Error('Recipient list cannot be empty.');
      }
      if (!message) {
        throw new Error('Message body cannot be empty.');
      }
    
      const endpoint = `${SINCH_API_BASE_URL}/xms/v1/${SERVICE_PLAN_ID}/batches`;
    
      const payload = {
        from: SINCH_NUMBER,
        to: recipients,
        body: message,
      };
    
      const config = {
        headers: {
          'Authorization': `Bearer ${API_TOKEN}`,
          'Content-Type': 'application/json',
        },
      };
    
      try {
        console.log(`Sending SMS batch to ${recipients.length} recipients via Sinch…`);
        const response = await axios.post(endpoint, payload, config);
        console.log('Sinch API response:', response.data);
        return response.data;
      } catch (error) {
        console.error('Error sending SMS via Sinch:', error.response?.data || error.message);
        throw new Error(`Sinch API request failed: ${error.response?.data?.text || error.message}`);
      }
    }
    
    export { sendSmsBatch };
    • Why this approach?
      • Modularity: Separates Sinch logic from server code.
      • Security: Loads credentials from environment variables.
      • Error Handling: Includes validation and catches Axios errors.
      • Clarity: Uses documented Sinch /batches endpoint structure.

3. Building the API Layer with Fastify

Set up Fastify server and define the API endpoint.

  1. Edit src/server.js: Configure Fastify, load environment variables, and define routes:

    javascript
    // src/server.js
    import Fastify from 'fastify';
    import dotenv from 'dotenv';
    import { sendSmsBatch } from './sinchService.js';
    
    // Load environment variables
    dotenv.config();
    
    // Initialize Fastify with logging
    const fastify = Fastify({
      logger: true
    });
    
    // API Route Definition
    const campaignSchema = {
      body: {
        type: 'object',
        required: ['recipients', 'message'],
        properties: {
          recipients: {
            type: 'array',
            items: {
              type: 'string',
              pattern: '^\\+[1-9]\\d{1,14}$' // E.164 format validation
            },
            minItems: 1,
          },
          message: {
            type: 'string',
            minLength: 1,
            maxLength: 1600
          }
        }
      },
      response: {
        200: {
          type: 'object',
          properties: {
            message: { type: 'string' },
            batchId: { type: 'string' },
            recipientCount: { type: 'number' }
          }
        },
      }
    };
    
    fastify.post('/campaigns', { schema: campaignSchema }, async (request, reply) => {
      const { recipients, message } = request.body;
    
      // **Important:** Implement suppression list check here.
      // Filter out recipients who opted out.
    
      try {
        const sinchResponse = await sendSmsBatch(recipients, message);
    
        reply.code(200).send({
          message: 'SMS campaign batch submitted successfully.',
          batchId: sinchResponse.id,
          recipientCount: recipients.length
        });
    
      } catch (error) {
        fastify.log.error(`Campaign sending failed: ${error.message}`);
        reply.code(500).send({ error: 'Failed to send SMS campaign.', details: error.message });
      }
    });
    
    // Health Check Route
    fastify.get('/health', async (request, reply) => {
      return { status: 'ok', timestamp: new Date().toISOString() };
    });
    
    // Start Server
    const start = async () => {
      try {
        const port = process.env.PORT || 3000;
        await fastify.listen({ port: parseInt(port, 10), host: '0.0.0.0' });
      } catch (err) {
        fastify.log.error(err);
        process.exit(1);
      }
    };
    
    start();
    • Why Fastify? Schema validation automatically handles request validation, improving security. Built-in Pino logger provides high-performance logging.
    • Route Logic: /campaigns validates requests, calls sinchService, handles errors, and sends appropriate responses.
    • Health Check: /health endpoint enables monitoring and container orchestration.
    • Server Start: Listens on configured port and host 0.0.0.0 (required for Docker).

4. Integrating with Sinch (Credentials Setup)

Obtain API credentials from Sinch.

  1. Navigate to Sinch Dashboard: Log in to your Sinch Customer Dashboard.

  2. Find SMS API Credentials:

    • Go to SMSAPIs.
    • Locate your Service plan ID.
    • Click your Service Plan ID name.
    • Under API Credentials, find your API token. Click "Show" or generate one.
    • In Numbers section, find your provisioned phone number (E.164 format: +15551234567).
    • Note your Region (US, EU, CA, AU, BR). Common URLs:
      • US: https://us.sms.api.sinch.com
      • EU: https://eu.sms.api.sinch.com
      • Canada: https://ca.sms.api.sinch.com
      • Australia: https://au.sms.api.sinch.com
      • Brazil: https://br.sms.api.sinch.com
  3. Update .env File: Add your credentials:

    plaintext
    # .env
    SINCH_SERVICE_PLAN_ID=YOUR_ACTUAL_SERVICE_PLAN_ID
    SINCH_API_TOKEN=YOUR_ACTUAL_API_TOKEN
    SINCH_NUMBER=+1XXXXXXXXXX
    SINCH_REGION_URL=https://<region>.sms.api.sinch.com
    • Security: Keep .env secure and ensure it's in .gitignore.

5. Error Handling, Logging, and Retry Mechanisms

Your setup includes basic error handling and logging.

  • Error Handling:
    • try...catch blocks catch exceptions in server.js and sinchService.js.
    • Fastify schema validation catches malformed requests.
    • sinchService throws errors for missing credentials or failed API calls.
    • API route returns 500 on errors with generic messages, logging details internally.
  • Logging:
    • Fastify's logger: true uses Pino for efficient JSON-based logging.
    • Logs include request details, errors, and informational messages.
  • Retry Mechanisms (Advanced):
    • For production, implement retries with exponential backoff for transient errors (5xx responses).

    • Use axios-retry or manual implementation with setTimeout.

    • Example Concept:

      javascript
      // Inside sinchService.js – Conceptual Retry Logic
      async function sendWithRetry(url, payload, config, logger = console, retries = 3, delay = 1000) {
          try {
              return await axios.post(url, payload, config);
          } catch (error) {
              if (retries > 0 && (!error.response || error.response.status >= 500)) {
                  logger.warn(`Retrying Sinch request (${retries} left) after ${delay} ms delay…`);
                  await new Promise(resolve => setTimeout(resolve, delay));
                  return sendWithRetry(url, payload, config, logger, retries - 1, delay * 2);
              } else {
                  throw error;
              }
          }
      }
    • Testing Errors: Stop your network, provide invalid credentials, or use toxiproxy to simulate network failures.

6. Creating a Database Schema and Data Layer (Optional – Prisma)

If you initialized Prisma, define a schema for campaign logging.

  1. Define Schema (prisma/schema.prisma): Add campaign model:

    prisma
    // prisma/schema.prisma
    generator client {
      provider = "prisma-client-js"
    }
    
    datasource db {
      provider = "postgresql"
      url      = env("DATABASE_URL")
    }
    
    model Campaign {
      id             Int      @id @default(autoincrement())
      createdAt      DateTime @default(now())
      message        String
      recipientCount Int
      status         String   // PENDING, SENT, FAILED
      batchId        String?
      errorDetails   String?
    }
    
    // Optional: Model for Suppression List
    /*
    model SuppressionList {
        phoneNumber String   @id @unique
        reason      String?
        createdAt   DateTime @default(now())
        updatedAt   DateTime @updatedAt
    }
    */
  2. Create Database Migration: Generate and apply SQL migration:

    bash
    npx prisma migrate dev --name init_campaign_model
  3. Generate Prisma Client: Update Prisma client:

    bash
    npx prisma generate
  4. Integrate with Server Code:

    • Uncomment Prisma lines in src/server.js (import, initialization, database operations).
    • Adds database logging for campaign attempts and final status.

7. Adding Security Features

Security is critical for any API.

  1. Input Validation:

    • Fastify schema validation checks request structure, data types, and constraints (E.164 format, message length).
    • Prevents injection attacks and malformed data.
  2. Rate Limiting:

    • Protect against abuse and brute-force attacks.

    • Install @fastify/rate-limit:

      bash
      npm install @fastify/rate-limit
    • Register in src/server.js:

      javascript
      // src/server.js
      import rateLimit from '@fastify/rate-limit';
      
      await fastify.register(rateLimit, {
        max: 100,
        timeWindow: '1 minute'
      });
    • Adjust max and timeWindow based on usage patterns.

  3. Secrets Management:

    • Use .env and dotenv for local development.
    • Production: Use platform secret management (Docker Secrets, Kubernetes Secrets, PaaS environment variables). Never commit .env.
  4. HTTPS:

    • Always use HTTPS in production via reverse proxy (Nginx, Caddy) or hosting platform.
  5. Helmet (Optional but Recommended):

    • Use @fastify/helmet for security headers:
    bash
    npm install @fastify/helmet
    javascript
    // src/server.js
    import helmet from '@fastify/helmet';
    await fastify.register(helmet);

8. Handling Special Cases for SMS Marketing

Real-world SMS marketing campaigns require specific considerations for compliance, deliverability, and cost optimization.

  • Large Recipient Lists:
    • Sinch's /batches endpoint supports up to 1,000 recipients per batch (Sinch SMS API Release Notes).
    • For campaigns exceeding 1,000 recipients, split into multiple batches, respecting API rate limits.
    • Asynchronous Processing: Use background job queue (BullMQ with Redis) for large batches. Return immediate 202 Accepted response.
  • Character Limits & Encoding:
    • GSM-7 encoding: 160 characters per segment.
    • Unicode/UCS-2 (emojis, special characters): 70 characters per segment.
    • Sinch handles concatenation, but you're billed per segment.
    • Set maxLength validation (e.g., 1600 characters) to prevent excessive costs.
  • Opt-Outs & Compliance:
    • Crucial: Follow TCPA (US), GDPR (EU) regulations. Only message consenting users.
    • Provide clear opt-out mechanism (reply STOP).
    • Handle incoming SMS webhooks from Sinch to process STOP keywords.
    • Maintain suppression list (database table).
    • Before sending, check recipients against suppression list and remove opted-out numbers.
  • International Formatting:
    • Use E.164 format (+ followed by country code and number) for international deliverability.
    • Schema validation enforces this with regex pattern validation.
  • Delivery Reports (DLRs):
    • Configure webhook URL in Sinch dashboard for delivery status updates.
    • Create Fastify route (e.g., POST /sinch/dlr) to handle webhook payloads.
    • Process DLRs to update campaign logs with delivery status.
    • Requires public endpoint (use ngrok for local testing).

9. Implementing Performance Optimizations

Optimize for high-load scenarios.

  • Asynchronous Processing (Queues): Offload Sinch API calls to background job queue (BullMQ) to prevent blocking and improve response times.

  • Database Connection Pooling: Prisma manages this automatically.

  • Caching: Cache suppression lists (Redis/Memcached) for large-scale lookups with proper invalidation.

  • Node.js Clustering: Use cluster module or PM2 (pm2 start src/server.js -i max) to leverage multi-core processors.

  • Load Testing: Use k6, autocannon, or wrk to simulate traffic:

    bash
    # Example using autocannon (npm i -g autocannon)
    autocannon -m POST -H "Content-Type: application/json" -b '{"recipients":["+15551234567"],"message":"Test Load"}' http://localhost:3000/campaigns
  • Profiling: Use Node.js profiler (node --prof) or Clinic.js (npm i -g clinic; clinic doctor -- node src/server.js) to analyze performance.

10. Adding Monitoring, Observability, and Analytics

Monitor production service behavior.

  • Health Checks:

    • GET /health provides basic liveness check.
    • Enhance with database connectivity checks.
  • Structured Logging:

    • Fastify's Pino logger outputs JSON for log aggregation (ELK Stack, Splunk, Datadog, Grafana Loki).
    • Logs capture context (request IDs, batch IDs, user IDs).
  • Performance Metrics (Prometheus):

    • Integrate prom-client for application metrics:
    bash
    npm install prom-client
    • Expose /metrics endpoint and track custom metrics (campaign rate, Sinch API latency).
    • Set up Prometheus scraping and Grafana visualization.
  • Error Tracking:

    • Use Sentry, Bugsnag, or Datadog APM for real-time error capture with context.
  • Dashboards:

    • Create Grafana dashboards showing:
      • API request rate and latency
      • API error rates (4xx, 5xx)
      • Sinch API latency and errors
      • Campaign throughput
      • Database query performance
      • System resource usage
  • Alerting:

    • Configure alerts for:
      • High API error rate (> 1%)
      • High API latency (> 500 ms p95)
      • High Sinch API error rate
      • /health endpoint failures
      • High resource utilization
      • Job queue failures

11. Troubleshooting and Caveats

Common issues and solutions:

  • Sinch Errors:
    • 401 Unauthorized: Incorrect SERVICE_PLAN_ID or API_TOKEN. Verify .env and dashboard. Check Authorization: Bearer <token> format.
    • 400 Bad Request: Invalid JSON structure, incorrect E.164 format, or parameter issues. Check error.response.data.
    • 403 Forbidden/Insufficient Funds: Account lacks funds or permissions. Check balance and settings.
    • 5xx Server Error: Temporary Sinch issue. Implement retries.
  • Configuration Issues:
    • .env not loaded: Ensure dotenv.config() is called early. Verify file location.
    • Incorrect DATABASE_URL: Check format, credentials, and host.
    • Firewall Issues: Ensure outbound requests to Sinch API and database. For webhooks, ensure Sinch can reach your endpoint.
  • Code Errors:
    • Variable name typos
    • Incorrect async/await usage
    • Schema validation errors
  • Deployment Problems:
    • Environment variables not set correctly (use platform secrets management)
    • Incorrect host binding (use 0.0.0.0 for Docker)
    • Port conflicts
  • Compliance/Opt-Out Failures:
    • Critical: Forgetting suppression list check can cause legal issues. Test thoroughly. Process incoming STOP messages correctly.

Check detailed error messages in Fastify logs (fastify.log.error) and Axios response data (error.response?.data) when troubleshooting.

FAQ

How do I get Sinch SMS API credentials?

Log in to your Sinch Customer Dashboard, navigate to SMS → APIs, and locate your Service Plan ID. Click on the Service Plan ID to view your API token (generate one if needed). Find your provisioned phone number in the Numbers section – this is your sender number. Note your region (US, EU, CA, AU, BR) to construct the correct API base URL (e.g., https://us.sms.api.sinch.com).

What is the maximum number of recipients per Sinch SMS batch?

Sinch's /batches endpoint supports up to 1,000 recipients per batch request (Sinch SMS API Documentation). The to field accepts an array of 1–1000 phone numbers in E.164 format. This limit was increased from 100 to 1,000 on October 12, 2019 (Sinch Release Notes). For campaigns exceeding 1,000 recipients, split into multiple batch requests and implement rate limiting. Use background job queues (BullMQ with Redis) for asynchronous processing.

How do I implement E.164 phone number validation in Fastify?

Use Fastify's JSON schema validation with regex pattern: pattern: '^\\+[1-9]\\d{1,14}$'. This enforces E.164 format (+ followed by country code and number, 7–15 digits total). Schema validation runs automatically before route handler execution, rejecting malformed numbers with 400 Bad Request responses.

What are TCPA and GDPR requirements for SMS marketing campaigns?

TCPA (US) requires prior express written consent, clear opt-out mechanisms (STOP keyword), mandatory communication hours (8:00 AM – 9:00 PM recipient local time), and suppression lists (FCC TCPA Guidelines). GDPR (EU) requires explicit consent, right to erasure, and data processing records. For US compliance, most businesses need 10DLC registration for SMS marketing campaigns. Implement suppression list database table, check recipients before every campaign, and process STOP keywords via Sinch webhooks. Penalties: $500–$1,500 per violation for TCPA (Tratta TCPA Guide) with no upper limit, and up to 4% of annual global revenue for GDPR violations.

How do I handle SMS character limits and encoding with Sinch?

GSM-7 encoding supports 160 characters per segment. Unicode/UCS-2 (emojis, special characters) reduces this to 70 characters per segment. Sinch automatically concatenates longer messages, but you're billed per segment. Set maxLength validation (e.g., 1600 characters for ~10 segments) in Fastify schema to prevent excessive costs. Monitor segment counts in production.

What retry strategy should I use for Sinch API failures?

Implement exponential backoff with 3–5 retry attempts for transient errors (5xx responses, network timeouts). Use axios-retry or manual implementation with setTimeout. Only retry retriable errors – don't retry 4xx client errors. Example: retry after 1 s, 2 s, 4 s delays. Log all retry attempts and final failures. Consider circuit breaker patterns for sustained Sinch API outages.

How do I process opt-outs and STOP keywords with Sinch?

Configure webhook URL in Sinch dashboard (Service Plan settings) to receive inbound SMS. Create Fastify route (POST /sinch/inbound) to handle webhook payloads. Parse message body for STOP, UNSUBSCRIBE, or QUIT keywords. Add sender's phone number to suppression list database immediately. Send confirmation SMS. Check suppression list before every campaign send.

Use Prisma with Campaign model containing: id (autoincrement), createdAt (timestamp), message (text), recipientCount (integer), status (PENDING/SENT/FAILED), batchId (Sinch batch ID), and errorDetails (nullable string). Add SuppressionList model with: phoneNumber (E.164 string, unique primary key), reason (STOP/Complaint), createdAt, and updatedAt. Index phoneNumber for fast lookups.

How do I handle rate limiting for Sinch SMS API?

Install @fastify/rate-limit to protect your API endpoint (e.g., max: 100 requests per minute per IP). For Sinch API rate limits, each service plan has specific messages-per-second limits (Sinch Rate Limits Documentation). A batch with 10 recipients counts as 10 messages. Batches queue in first-in-first-out order. Check account tier limits, implement batch splitting for large campaigns, and use job queues to control velocity. Monitor for 429 Too Many Requests responses and implement backoff.

What monitoring and alerting should I implement for SMS campaigns?

Expose /metrics endpoint using prom-client for Prometheus. Track: API request rate and latency, Sinch API success/failure rates, campaign throughput, error rates by type, and queue depth. Set alerts for: error rate > 1%, API latency > 500 ms p95, Sinch API failures, health check failures, and low Sinch account balance. Use Grafana dashboards and Sentry for error tracking.