code examples

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

How to Send SMS with Sinch API and Fastify in Node.js (2025 Guide)

Learn how to send SMS messages using Sinch SMS API with Fastify in Node.js. Step-by-step tutorial with code examples, error handling, rate limiting, and production deployment tips.

Send SMS with Sinch and Fastify: Complete Node.js Integration Guide

Learn how to send SMS messages programmatically using the Sinch SMS API with Fastify and Node.js. You'll build a production-ready Fastify Application Programming Interface (API) endpoint capable of sending Short Message Service (SMS) messages using the Sinch SMS API and their official Node.js Software Development Kit (SDK) in this complete walkthrough.

This guide covers project setup, configuration, core implementation, error handling, security considerations, testing, and deployment basics.

Your final application will expose a simple API endpoint that accepts a recipient phone number and a message body, then uses Sinch to dispatch the SMS.

Technologies Used:

  • Node.js: The runtime environment. (Long Term Support (LTS) version recommended)
  • Fastify: A high-performance, low-overhead web framework for Node.js. You'll benefit from its speed, extensibility, and developer experience.
  • Sinch Node.js SDK (@sinch/sdk-client): The official library for interacting with Sinch APIs, simplifying authentication and requests.
  • dotenv: A module to load environment variables from a .env file into process.env.
  • @fastify/env: For validating and managing environment variables within Fastify.
  • @fastify/helmet: For basic security headers.
  • @fastify/rate-limit: For API rate limiting.

System Architecture:

The architecture is straightforward:

  1. Client (e.g., curl, Postman, Frontend App): Sends an HTTP POST request to the Fastify API endpoint (/sms/send).
  2. Fastify API:
    • Receives the request.
    • Validates the request payload (recipient number, message).
    • Calls the Sinch Service module.
    • Returns a response to the client (success or error).
  3. Sinch Service Module:
    • Uses the Sinch Node.js SDK.
    • Authenticates with Sinch using credentials from environment variables.
    • Constructs and sends the SMS batch request to the Sinch API.
    • Handles potential errors from the Sinch API.
  4. Sinch Platform: Receives the API request, processes it, and delivers the SMS message to the recipient's phone.
mermaid
graph LR
    Client -- POST /sms/send --> FastifyAPI[Fastify API];
    FastifyAPI -- sendSms(to, message) --> SinchService[Sinch Service Module];
    SinchService -- Send SMS Request --> SinchPlatform[Sinch Platform API];
    SinchPlatform -- SMS --> RecipientPhone[Recipient Phone];
    SinchPlatform -- API Response --> SinchService;
    SinchService -- Success/Error --> FastifyAPI;
    FastifyAPI -- HTTP Response --> Client;

Prerequisites:

  • Node.js and npm: Install Node.js 18+ or Node.js 20 LTS (recommended) on your system. Download from nodejs.org.
  • Sinch Account: Register a free account at Sinch.com to access the SMS API.
  • Sinch API Credentials: You'll need a Project ID, SDK Key ID, and SDK Key Secret from your Sinch Dashboard.
  • Sinch Phone Number: Purchase or assign an SMS-enabled number within your Sinch account.
  • Basic Terminal/Command Line Knowledge: Navigate directories and run commands.
  • (Optional) fastify-cli: For generating project boilerplate. Install globally: npm install -g fastify-cli.
  • (Optional) Postman or curl: For testing the API endpoint.

Final Outcome:

By the end of this guide, you'll have a running Fastify application with a /sms/send endpoint that securely sends SMS messages via Sinch, including basic validation, error handling, and configuration management.


1. Setting Up Your Node.js Project with Fastify

Initialize your Node.js project using Fastify CLI for a standard structure. If you prefer not to use the CLI, create the files and folders manually.

Using Fastify CLI (Recommended):

bash
# 1. Create a project directory
mkdir fastify-sinch-sms
cd fastify-sinch-sms

# 2. Generate a basic Fastify project
fastify generate .

# Respond to the prompts (defaults are usually fine for this guide)
# Choose standard JavaScript

# 3. Install necessary production dependencies
npm install @sinch/sdk-client dotenv @fastify/env @fastify/helmet @fastify/rate-limit

# 4. Install development dependencies
npm install --save-dev tap sinon # Fastify's default test runner and mocking library

# 5. (Optional) If not using fastify-cli, initialize npm and install core fastify
# npm init -y
# npm install fastify @sinch/sdk-client dotenv @fastify/env @fastify/helmet @fastify/rate-limit
# npm install --save-dev tap sinon

Manual Setup (Alternative):

If you didn't use fastify generate, create the following basic structure:

fastify-sinch-sms/ ├── node_modules/ ├── routes/ │ └── sms/ │ └── index.js # Your SMS sending route ├── services/ │ └── sinchService.js # Logic for interacting with Sinch SDK ├── plugins/ # For Fastify plugins if needed later ├── test/ # Test files (e.g., routes/sms.test.js) │ ├── routes/ │ │ └── sms.test.js │ └── helper.js # Test helper for building the app ├── app.js # Main application entry point ├── package.json ├── package-lock.json ├── .env # Environment variables (DO NOT COMMIT) └── .gitignore # Git ignore file

Create app.js, routes/sms/index.js, services/sinchService.js, and test files manually if needed. Ensure your package.json includes the dependencies listed above.

Create .gitignore:

Prevent committing sensitive information and unnecessary files. Create a .gitignore file in the project root:

text
# .gitignore

# Node dependencies
node_modules/

# Environment variables
.env
*.env
.env.*

# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# Build output
dist
build

# OS generated files
.DS_Store
Thumbs.db

2. Configuring Sinch API Credentials and Environment Variables

Configure your application with the Sinch API credentials and settings. You'll use dotenv and @fastify/env to manage these securely and robustly.

Create .env file:

Create a file named .env in the root of your project. Never commit this file to version control.

dotenv
# .env

# Sinch Credentials – Get from Sinch Dashboard > API & SDK > SDK Keys
SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID
SINCH_KEY_ID=YOUR_SINCH_KEY_ID
SINCH_KEY_SECRET=YOUR_SINCH_KEY_SECRET

# Sinch Settings
SINCH_REGION=us # Or eu, au, br, ca. Check Sinch docs for available regions.
SINCH_NUMBER=+1xxxxxxxxxx # Your purchased/assigned Sinch SMS number

# Application Settings
PORT=3000
HOST=0.0.0.0 # Listen on all available network interfaces
LOG_LEVEL=info # Pino log level (trace, debug, info, warn, error, fatal)

How to Obtain Sinch Credentials:

  1. Log in to your Sinch Customer Dashboard.
  2. Navigate to the API & SDK section (or similar – the exact path might change).
  3. Find or create an SDK Key pair associated with your project.
    • SINCH_PROJECT_ID: You'll find this on your main dashboard or project settings.
    • SINCH_KEY_ID: This is the public identifier for your SDK key.
    • SINCH_KEY_SECRET: This is the secret credential. Important: Sinch typically shows this secret only once upon creation. Store it securely immediately (in your .env file locally, and use secrets management for production).
  4. Find your Sinch Phone Number (SINCH_NUMBER) under the Numbers section or associated with your SMS Service Plan. It must be SMS-enabled.
  5. Determine the correct Region (SINCH_REGION) for your Sinch account/service plan (e.g., us, eu, au, br, ca). Sinch often specifies this when you set up your service plan or in the API documentation corresponding to your account setup. Using the wrong region will cause authentication or connection errors.

Regional Availability (as of 2025):

Sinch SMS API supports multiple regional endpoints for optimal performance and data residency:

RegionBase URLLocationNotes
US (Default)https://us.sms.api.sinch.comUSADefault server location; supports MMS
EUhttps://eu.sms.api.sinch.comIreland, SwedenEuropean data residency
Australiahttps://au.sms.api.sinch.comAustraliaAPAC data residency
Brazilhttps://br.sms.api.sinch.comBrazilLATAM data residency
Canadahttps://ca.sms.api.sinch.comCanadaCanadian data residency

Load and Validate Environment Variables:

Load these variables when the application starts. Modify your main application file (app.js or server.js) to use @fastify/env. While dotenv alone can load variables into process.env, @fastify/env adds crucial schema validation, type coercion, default values, and makes configuration centrally available via fastify.config. This prevents runtime errors due to missing or invalid environment variables.

javascript
// app.js (or server.js)
'use strict'

const path = require('node:path')
const AutoLoad = require('@fastify/autoload')
const fastifyEnv = require('@fastify/env')

// Define schema for environment variables using @fastify/env
// This ensures required variables are present and have the correct type.
const envSchema = {
  type: 'object',
  required: [
    'PORT',
    'HOST',
    'SINCH_PROJECT_ID',
    'SINCH_KEY_ID',
    'SINCH_KEY_SECRET',
    'SINCH_REGION',
    'SINCH_NUMBER'
  ],
  properties: {
    PORT: { type: 'string', default: 3000 },
    HOST: { type: 'string', default: '0.0.0.0' },
    LOG_LEVEL: { type: 'string', default: 'info'},
    SINCH_PROJECT_ID: { type: 'string' },
    SINCH_KEY_ID: { type: 'string' },
    SINCH_KEY_SECRET: { type: 'string' },
    SINCH_REGION: { type: 'string' },
    SINCH_NUMBER: { type: 'string' } // Keep as string to preserve leading +
  },
  // Make NODE_ENV available if needed, with a default
  // NODE_ENV: { type: 'string', default: 'development' }
};

const envOptions = {
  confKey: 'config', // Access variables via `fastify.config`
  schema: envSchema,
  dotenv: true // Tells @fastify/env to load .env file(s) using dotenv
};


module.exports = async function (fastify, opts) {
  // Register @fastify/env FIRST to load and validate config
  // Other plugins/routes can then safely access fastify.config
  await fastify.register(fastifyEnv, envOptions);

  // Set logger level based on validated config
  // Do this early so subsequent logs respect the level
  fastify.log.level = fastify.config.LOG_LEVEL;

  // Standard Fastify setup
  // Place here your custom code! (e.g., other plugins like Helmet, Rate Limit)

  // Do not touch the following lines provided by fastify-cli

  // This loads all plugins defined in plugins
  // those should be support plugins that are reused
  // through your application
  fastify.register(AutoLoad, {
    dir: path.join(__dirname, 'plugins'),
    options: Object.assign({}, opts) // Pass opts, which now includes fastify.config
  })

  // This loads all plugins defined in routes
  // define your routes in one of these
  fastify.register(AutoLoad, {
    dir: path.join(__dirname, 'routes'),
    options: Object.assign({}, opts) // Pass opts, including fastify.config
  })

}

// Export the options for fastify-cli
module.exports.options = {
  // Pass logger options: https://getpino.io/#/docs/api?id=options
  // Use Fastify's default Pino logger, level set above from config
  logger: true
}

Now, validated environment variables are accessible via fastify.config throughout your application (e.g., in routes, plugins). If any required variable is missing or invalid on startup, @fastify/env will throw an error immediately.


3. Implementing the Sinch SMS Service with Node.js SDK

Create a dedicated service to handle interactions with the Sinch SDK. This promotes separation of concerns.

Create services/sinchService.js:

javascript
// services/sinchService.js
'use strict'

const { SinchClient } = require('@sinch/sdk-client');

// Note: For better testability and centralized logging/config,
// pass `fastify.log` and `fastify.config` into this service,
// perhaps during instantiation or via request decoration.
// This example accesses process.env directly for simplicity,
// assuming @fastify/env has already populated it via dotenv: true.

// Initialize Sinch Client using environment variables
// These should have been validated by @fastify/env already
const sinchClient = new SinchClient({
  projectId: process.env.SINCH_PROJECT_ID,
  keyId: process.env.SINCH_KEY_ID,
  keySecret: process.env.SINCH_KEY_SECRET,
  // The `region` parameter for the client might affect internal SDK defaults,
  // but explicitly setting the `baseUrl` in the API call (below) is often
  // the most reliable way to target the correct regional API endpoint.
});

/**
 * Sends an SMS message using the Sinch SDK.
 * @param {string} recipientPhoneNumber – The full recipient phone number in E.164 format (e.g., +15551234567).
 * @param {string} messageText – The text content of the SMS.
 * @param {object} [logger=console] – Optional logger instance (defaults to console). Pass request.log for contextual logging.
 * @returns {Promise<object>} – An object containing success status and the result from the Sinch API (e.g., batch ID).
 * @throws {Error} – Throws an error if the SMS sending fails at the API level or due to configuration issues.
 */
async function sendSms(recipientPhoneNumber, messageText, logger = console) {
  const senderNumber = process.env.SINCH_NUMBER;
  const region = process.env.SINCH_REGION; // Used to construct the base URL

  // Basic validation (though route schema should catch most issues)
  if (!recipientPhoneNumber || !messageText) {
    throw new Error('Recipient phone number and message text are required.');
  }
  if (!senderNumber) {
    // This should ideally be caught by @fastify/env on startup
    logger.error('Sinch sender number (SINCH_NUMBER) is not configured.');
    throw new Error('Internal configuration error: Sinch sender number missing.');
  }
  if (!region) {
      // This should ideally be caught by @fastify/env on startup
      logger.error('Sinch region (SINCH_REGION) is not configured.');
      throw new Error('Internal configuration error: Sinch region missing.');
  }

  // Construct the correct base URL for the Sinch SMS API based on the region.
  // This is crucial for routing the request correctly.
  // Refer to Sinch documentation for exact regional endpoints:
  // https://developers.sinch.com/docs/sms/api-reference/
  const baseUrl = `https://${region}.sms.api.sinch.com`;

  logger.info(`Attempting to send SMS via Sinch from ${senderNumber} to ${recipientPhoneNumber} via region ${region} (${baseUrl})`);

  try {
    // Use the 'batches' endpoint for sending SMS via the SDK
    const response = await sinchClient.sms.batches.send(
        // smsSendBatchRequest payload:
        {
            to: [recipientPhoneNumber],
            from: senderNumber,
            body: messageText,
            // Optional parameters can be added here (e.g., delivery_report: 'summary', client_reference: 'your_unique_id')
        },
        // RequestOptions: Explicitly setting the `baseUrl` here overrides any SDK default
        // and ensures the request hits the correct regional API endpoint. This is recommended
        // as SDK behavior regarding the client's `region` parameter might vary.
        {
            baseUrl: baseUrl,
        }
    );

    // The exact structure of the success response might vary slightly based on SDK version.
    // Check the SDK documentation or log the response during development.
    // A `batch_id` is typically present on successful submission.
    logger.info({ sinchResponse: response }, 'Sinch API Response Received');

    if (response?.batch_id) {
      logger.info(`SMS batch submitted successfully. Batch ID: ${response.batch_id}`);
      // Return a consistent success object
      return { success: true, batchId: response.batch_id, details: response };
    } else {
      // This case might indicate an unexpected successful (e.g., 2xx) response format from Sinch
      logger.warn({ sinchResponse: response }, 'Sinch API response successful but missing expected batch_id.');
      // Treat as failure since we can't confirm the batch ID
      throw new Error('Sinch API response did not contain the expected batch_id.');
    }

  } catch (error) {
    // Log the detailed error from the Sinch SDK/API
    // error.response often contains details from the API response on HTTP errors
    logger.error({
        err: error,
        responseData: error?.response?.data,
        responseStatus: error?.response?.status
      },
      'Error sending SMS via Sinch'
    );

    // Re-throw a more context-specific error for the route handler to catch
    // Avoid leaking raw Sinch error details unless necessary/safe
    const errorMessage = error?.response?.data?.error?.text || // Sinch specific error text
                         error?.response?.data?.message ||      // General error message
                         error.message;                        // Fallback error message
    throw new Error(`Failed to send SMS via Sinch: ${errorMessage}`);
  }
}

module.exports = {
  sendSms
};

Explanation:

  1. Import SDK: Imports SinchClient.
  2. Instantiate Client: Creates SinchClient using credentials from process.env (populated and validated by @fastify/env).
  3. sendSms Function:
    • Accepts recipientPhoneNumber, messageText, and an optional logger. Defaults to console but should ideally receive request.log.
    • Retrieves senderNumber and region from process.env. Includes checks, though @fastify/env should prevent these errors at runtime if configured correctly.
    • Constructs the regional baseUrl. This is critical.
    • Uses the passed logger (or console) for logging.
    • Calls sinchClient.sms.batches.send().
    • Passes the to, from, body in the first argument (payload).
    • Crucially, passes { baseUrl: baseUrl } as the second argument (RequestOptions) to explicitly target the correct regional Sinch API endpoint.
    • Error Handling: Uses try…catch to capture errors. Logs detailed error information using the logger. Throws a new, cleaner error for the route handler.
    • Success Response: Logs success, checks for batch_id, and returns a structured object { success: true, batchId: …, details: … }. Throws an error if batch_id is missing even on a seemingly successful call.

SMS Character Limits and Encoding (Important for Message Content):

When sending SMS messages, you must consider character encoding and message segmentation:

  • GSM-7 Encoding: Standard SMS character set. Single message: 160 characters. Concatenated messages: 153 characters per segment (7 characters reserved for User Data Header (UDH) containing reassembly information).
  • UCS-2 Encoding (Unicode): Required for emojis, Chinese characters, or any non-GSM-7 characters. Single message: 70 characters. Concatenated messages: 67 characters per segment.
  • Important: If your message contains even one non-GSM-7 character, the entire message uses UCS-2 encoding, reducing your available character count from 160 to 70 (or 153 to 67 per segment for longer messages). This can significantly increase costs for longer messages.

4. Creating the Fastify API Endpoint for Sending SMS

Now, create the Fastify route that will use your sinchService.

Modify routes/sms/index.js (or create it):

javascript
// routes/sms/index.js
'use strict'

const sinchService = require('../../services/sinchService'); // Adjust path if needed

// Schema for request body validation using Fastify's built-in JSON Schema support
const sendSmsBodySchema = {
  type: 'object',
  required: ['to', 'message'],
  properties: {
    to: {
      type: 'string',
      description: 'Recipient phone number in E.164 format (e.g., +15551234567)',
      // E.164 regex: Starts with +, then 1–9, then 1 to 14 digits.
      pattern: '^\\+[1-9]\\d{1,14}$'
    },
    message: {
      type: 'string',
      description: 'The text message content',
      minLength: 1,
      maxLength: 1600 // Max length for concatenated SMS; adjust as needed. GSM-7: ~10 segments (153×10 + 7 chars). UCS-2: ~24 segments (67×24 - 7 chars).
    }
  },
  additionalProperties: false // Disallow properties not defined in the schema
};

// Schema for the success response (2xx)
const successResponseSchema = {
  type: 'object',
  properties: {
    success: { type: 'boolean', const: true },
    message: { type: 'string' },
    batchId: { type: 'string', description: 'Sinch Batch ID for the sent message(s)' }
  }
};

// Schema for generic error responses (e.g., 4xx, 5xx)
const errorResponseSchema = {
    type: 'object',
    properties: {
        success: { type: 'boolean', const: false },
        message: { type: 'string' },
        // Optionally include error code or details in non-production environments
        // error: { type: 'string' }
    }
};

// Combine response schemas for documentation/validation
const sendSmsResponseSchema = {
  200: successResponseSchema, // Use specific code for success
  // Define schemas for expected error codes for better API contracts
  400: errorResponseSchema, // Validation errors (handled by Fastify)
  500: errorResponseSchema  // Server/Service errors
};


module.exports = async function (fastify, opts) {

  // Route definition with schema validation and response schema documentation
  fastify.post('/send', {
    schema: {
      description: 'Sends an SMS message via Sinch.',
      tags: ['sms'],
      summary: 'Send SMS',
      body: sendSmsBodySchema,
      response: sendSmsResponseSchema
    }
  }, async function (request, reply) {
    // `request.body` is automatically validated against `sendSmsBodySchema` by Fastify.
    // If validation fails, Fastify sends a 400 response automatically.
    const { to, message } = request.body;

    // Use the contextual logger from the request object
    const log = request.log;

    // Log securely – avoid logging full message content in production if sensitive
    log.info({ recipient: to }, `Received request to send SMS`);

    try {
      // Call the service function, passing the contextual logger
      const result = await sinchService.sendSms(to, message, log);

      // If sinchService.sendSms completes without throwing, assume success based on its contract.
      // The service already verified the presence of batchId or threw an error.
      log.info({ batchId: result.batchId }, 'SMS batch submitted successfully via Sinch');

      // Send a 200 OK response with the success payload
      return reply.code(200).send({
        success: true,
        message: 'SMS submitted successfully.',
        batchId: result.batchId
      });

    } catch (error) {
      // Catch errors thrown by sinchService.sendSms or other unexpected issues
      log.error({ err: error, recipient: to }, 'Error processing send SMS request');

      // Send a 500 Internal Server Error for unexpected failures
      // Avoid leaking sensitive internal error details to the client in production
      const errorMessage = (process.env.NODE_ENV !== 'production')
                           ? error.message
                           : 'Internal server error while attempting to send SMS.';

      return reply.code(500).send({
         success: false,
         message: errorMessage
      });
    }
  })
}

Explanation:

  1. Import Service: Imports the sinchService.
  2. Schemas:
    • Defines sendSmsBodySchema for request validation. The E.164 regex is pattern: '^\\+[1-9]\\d{1,14}$'. additionalProperties: false ensures stricter validation.
    • Defines successResponseSchema and errorResponseSchema.
    • Combines response schemas in sendSmsResponseSchema mapping them to HTTP status codes (e.g., 200, 400, 500) for better documentation and potentially response validation.
  3. Route Definition: Defines POST /send under the /sms prefix (via AutoLoad). Attaches the schemas.
  4. Handler Function:
    • Extracts to and message from request.body (already validated).
    • Uses request.log for contextual logging.
    • Calls sinchService.sendSms, passing log.
    • Simplified Success Handling: The try…catch block handles execution flow. If await sinchService.sendSms completes without throwing, it's considered a success according to the service's logic (which should throw on failure or missing batchId).
    • Catch Error: Logs the error using the request logger. Sends a 500 Internal Server Error. It includes the specific error message only if not in production.

5. Implementing Error Handling and SMS Retry Logic

Error Handling:

  • Validation Errors: Fastify schemas handle automatically (400 Bad Request).
  • Service Errors (sinchService.js): Catches Sinch API errors, logs details, throws a standardized error.
  • Route Errors (routes/sms/index.js): Catches errors from the service call, logs them, returns a 500 Internal Server Error to the client (with generic message in production).
  • Configuration Errors: @fastify/env handles on startup.

Logging:

  • Fastify Logger (Pino): Use via fastify.log (global) and request.log (contextual).
  • Log Levels: Configure via LOG_LEVEL env var (validated by @fastify/env). info for production, debug/trace for development.
  • Structured Logging: Pino outputs JSON logs, suitable for log aggregation systems.
  • Contextual Logging: Passing request.log to services allows tracing logs back to specific requests.
  • What to Log: Incoming requests (mask sensitive data), service calls, Sinch responses (batchId), errors (with stack traces in dev).

Retry Mechanisms (Basic Considerations):

Implementing retries for SMS requires care to avoid duplicates.

  • When to Retry: Only for transient issues: network errors, Sinch 429 Too Many Requests, Sinch 5xx server errors. Do not retry 4xx client errors (validation, auth, invalid number).
  • Idempotency: Crucial for retries. Use the client_reference parameter in the Sinch batches.send request payload. Provide a unique ID for each logical message attempt. If you retry with the same client_reference, Sinch can detect and prevent duplicate sends. Generate this ID in your route handler or service before the first attempt.
    javascript
    // Inside sinchService.sendSms or route handler
    const uniqueMessageId = require('node:crypto').randomUUID(); // Generate unique ID (UUID – Universally Unique Identifier)
    
    // ... inside batches.send payload ...
    // Correct placement within the payload object:
    // {
    //   to: [recipientPhoneNumber],
    //   from: senderNumber,
    //   body: messageText,
    //   client_reference: uniqueMessageId, // Add it here
    // }
  • Implementation: Use libraries like async-retry or implement a custom loop with exponential backoff (increasing delays) within sinchService.js, ensuring the client_reference passes consistently for each retry attempt of the same message.

Example (Conceptual Retry in sinchService.js using async-retry):

javascript
// services/sinchService.js – Conceptual Retry Addition
// Ensure you install: npm install async-retry
const retry = require('async-retry');
const crypto = require('node:crypto'); // For unique client_reference

// Modify sendSms or create a wrapper function
async function sendSmsWithRetry(recipientPhoneNumber, messageText, logger = console) {
  const uniqueMessageId = crypto.randomUUID(); // Generate unique ID for this message attempt
  logger.info({ clientReference: uniqueMessageId }, 'Generated client_reference for idempotency');

  return retry(
    async (bail, attemptNumber) => {
      // bail(error) is called to stop retrying on non-retryable errors
      // Throwing an error triggers a retry if attempts remain

      logger.info(`Attempt ${attemptNumber} to send SMS to ${recipientPhoneNumber} (Ref: ${uniqueMessageId})`);

      const senderNumber = process.env.SINCH_NUMBER;
      const region = process.env.SINCH_REGION;
      // Basic validation (should be done before retry ideally)
      if (!recipientPhoneNumber || !messageText || !senderNumber || !region) {
         const missingConfigError = new Error('Internal configuration error detected before sending.');
         logger.error(missingConfigError);
         bail(missingConfigError); // Stop retrying on config issues
         return; // Needed after bail
      }

      const baseUrl = `https://${region}.sms.api.sinch.com`;

      try {
        const response = await sinchClient.sms.batches.send(
          { // Payload
            to: [recipientPhoneNumber],
            from: senderNumber,
            body: messageText,
            client_reference: uniqueMessageId, // Use the same ID for all retries of this message
          },
          { // RequestOptions
            baseUrl: baseUrl,
          }
        );

        logger.info({ sinchResponse: response, attempt: attemptNumber }, 'Sinch API Response Received');
        if (response?.batch_id) {
          logger.info(`SMS batch submitted successfully on attempt ${attemptNumber}. Batch ID: ${response.batch_id}`);
          return { success: true, batchId: response.batch_id, details: response };
        } else {
          logger.warn({ sinchResponse: response, attempt: attemptNumber }, 'Sinch API response successful but missing batch_id.');
          // Treat as potentially retryable internal issue or unexpected format
          throw new Error('Sinch API response did not contain expected batch_id.');
        }

      } catch (error) {
        const statusCode = error?.response?.status;
        logger.warn({ err: error, status: statusCode, attempt: attemptNumber }, `Attempt ${attemptNumber} failed`);

        // Decide if error is retryable
        if (statusCode === 429 || (statusCode >= 500 && statusCode <= 599)) {
          logger.info(`Retrying due to status code ${statusCode}`);
          throw error; // Throw error to trigger retry by async-retry
        } else {
          // Don't retry on other errors (4xx client errors, config errors, etc.)
          logger.error(`Not retrying for status code ${statusCode || 'N/A'}. Bailing.`);
          bail(error); // Stop retrying and pass the error up
          // Note: bail() throws the error passed to it, so no explicit throw needed here.
        }
      }
    },
    { // async-retry options
      retries: 3,          // Number of retries (total attempts = retries + 1)
      factor: 2,           // Exponential backoff factor
      minTimeout: 1000,    // Minimum delay in ms
      maxTimeout: 5000,    // Maximum delay in ms
      onRetry: (error, attemptNumber) => {
        logger.warn(`Retrying SMS send (attempt ${attemptNumber}). Error: ${error.message}`);
      },
    }
  );
}

// Remember to update the route handler to call sendSmsWithRetry instead of sendSms
// And update the module exports if needed
// module.exports = { sendSms: sendSmsWithRetry }; // Or export both
module.exports = { sendSms, sendSmsWithRetry }; // Example: export both

Note: Implement retries carefully. Understand Sinch's idempotency features (client_reference) and potential costs.


6. Database Schema and Data Layer (Not Applicable)

This guide focuses solely on the immediate action of sending an SMS via an API call. Therefore, a database is not required for the core functionality described.

If your application needed features like:

  • Storing historical message logs.
  • Tracking SMS delivery status updates (received via Sinch Webhooks).
  • Managing user accounts or API keys associated with sending.
  • Implementing message queuing for delayed or bulk sending.

You would introduce a database (e.g., PostgreSQL, MongoDB) and a corresponding data access layer (using an Object-Relational Mapping (ORM) like Prisma, Sequelize, or native drivers). This is beyond the scope of this basic guide.


7. Securing Your SMS API with Rate Limiting and Helmet

Secure your API.

  • Input Validation:

    • Fastify Schemas: Already implemented in Section 4. This is the first line of defense against malformed requests and basic injection attempts.
  • Secrets Management:

    • Local: Use .env (and .gitignore).
    • Production: Use your platform's secrets management (AWS Secrets Manager, Google Cloud Platform (GCP) Secret Manager, HashiCorp Vault, Kubernetes Secrets, Continuous Integration/Continuous Deployment (CI/CD) injected variables). Never commit secrets.
  • Rate Limiting: Protect against abuse and control costs.

    • Dependency @fastify/rate-limit was installed in Section 1.
    • Register the plugin in app.js (usually after @fastify/env but before routes):
    javascript
    // app.js
    // ... other requires
    const fastifyRateLimit = require('@fastify/rate-limit');
    const fastifyHelmet = require('@fastify/helmet'); // Import helmet
    
    module.exports = async function (fastify, opts) {
      // ... register @fastify/env first ...
      await fastify.register(fastifyEnv, envOptions);
      fastify.log.level = fastify.config.LOG_LEVEL; // Set log level
    
      // Register Rate Limiting globally
      await fastify.register(fastifyRateLimit, {
        max: 100, // Max requests per timeWindow per key (default is IP)
        timeWindow: '1 minute', // Time window as string or milliseconds
        // Consider using a persistent store like Redis for multi-instance deployments
        // redis: new Redis({ host: '127.0.0.1' }),
        // keyGenerator: function (request) { /* custom key logic */ }
      });
    
      // Register Helmet
      // Note: Register Helmet *after* rate-limit if rate-limit needs to identify clients before headers are potentially modified.
      // Usually, registering Helmet early is fine.
      await fastify.register(fastifyHelmet, {
         contentSecurityPolicy: false // Disable Content Security Policy (CSP) or configure it properly if needed
         // Enable other recommended headers by default
      });
    
      // ... autoload plugins and routes ...
      // fastify.register(AutoLoad, ...) // Load plugins defined in plugins/
      // fastify.register(AutoLoad, ...) // Load routes defined in routes/
    }
  • Security Headers (Helmet): Protect against common web vulnerabilities.

    • Dependency @fastify/helmet was installed in Section 1.
    • Register the plugin in app.js (see rate limiting example above). It adds headers like X-Frame-Options, Strict-Transport-Security, X-Content-Type-Options, etc., by default. Review and configure Helmet options as needed for your specific application, especially contentSecurityPolicy.

Frequently Asked Questions About Sinch and Fastify SMS Integration

What Sinch regions are supported for SMS API?

Sinch SMS API supports five regional endpoints as of 2025: US (default, supports MMS), EU (Ireland/Sweden), Australia, Brazil, and Canada. Choose your region based on data residency requirements and service plan configuration. Set the SINCH_REGION environment variable to us, eu, au, br, or ca. Using the wrong region causes authentication failures. The SDK constructs the base URL as https://{region}.sms.api.sinch.com.

How do I handle SMS character limits in Sinch?

SMS character limits depend on encoding. GSM-7 encoding allows 160 characters per single SMS (153 per segment for concatenated messages). UCS-2 encoding (required for emojis and non-Latin characters) allows 70 characters per single SMS (67 per segment for concatenated messages). Even one emoji forces UCS-2 encoding for the entire message, reducing your limit from 160 to 70 characters. Monitor message length to control costs and avoid unexpected segmentation.

What's the difference between Fastify v4 and v5 for this tutorial?

Fastify v5 (released 2024, targets Node.js 20+) requires full JSON schemas for request validation – you must include type: 'object' and properties explicitly. The jsonShortHand option was removed. The .listen() method requires an options object: fastify.listen({ port: 3000 }) instead of fastify.listen(3000). Custom loggers use loggerInstance option instead of logger. This tutorial's code works with v5 syntax.

How do I implement idempotent retry logic for SMS?

Use the client_reference parameter in your Sinch batch request payload. Generate a unique UUID (crypto.randomUUID()) for each logical message before the first send attempt. Pass the same client_reference value for all retry attempts of that message. Sinch detects duplicates and prevents double-sending. Only retry on transient errors (network timeouts, 429 rate limits, 5xx server errors) – never retry 4xx client errors. Use exponential backoff (1s, 2s, 4s delays) with the async-retry library.

Do I need a database for basic SMS sending with Sinch?

No database is required for basic SMS sending functionality described in this guide. You only need a database if you want to store message logs, track delivery reports via webhooks, manage user accounts, or implement message queuing for bulk/delayed sending. For production applications handling delivery receipts or audit trails, consider PostgreSQL with Prisma or MongoDB for message history storage.


Conclusion: Building a Production-Ready SMS API with Sinch and Fastify

You've successfully built a production-ready SMS API using Sinch, Fastify, and Node.js. This implementation includes robust error handling, environment-based configuration, security features like rate limiting and Helmet headers, and comprehensive logging with Pino.

Key takeaways from this tutorial:

  • Fastify Performance: Fastify provides high-performance API routing with built-in JSON schema validation for SMS payloads.
  • Sinch SDK Integration: The official @sinch/sdk-client SDK simplifies authentication and regional endpoint configuration for the Sinch SMS API.
  • Production Best Practices: Environment variable validation with @fastify/env, structured logging, rate limiting, and security headers prepare your API for real-world usage.
  • Character Encoding Awareness: Understanding GSM-7 vs UCS-2 encoding helps control SMS costs and message segmentation.
  • Idempotent Retries: Using client_reference with retry logic prevents duplicate messages during transient failures.

Next steps to enhance your SMS API:

For more Sinch API tutorials and Node.js messaging examples, explore the Sinch Developer Documentation and our complete guide library.

Frequently Asked Questions

How to send SMS with Fastify and Sinch

Create a Fastify API endpoint that uses the Sinch Node.js SDK (@sinch/sdk-client) to send SMS messages. The endpoint should accept the recipient's phone number and the message content, then use the Sinch SDK to dispatch the SMS through the Sinch platform. This guide provides a step-by-step walkthrough for setting up the project, handling configuration, and implementing the core logic for sending SMS messages using these technologies.

What is the Sinch Node.js SDK used for?

The Sinch Node.js SDK (`@sinch/sdk-client`) simplifies interaction with the Sinch API. It handles authentication and provides convenient methods for sending SMS messages and other Sinch services. This SDK is essential for integrating Sinch functionality into your Node.js application.

Why use Fastify for sending SMS?

Fastify is a high-performance web framework chosen for its speed and extensibility. Its efficient request handling makes it ideal for building a production-ready SMS API endpoint. The guide uses Fastify to create a robust and scalable solution for sending SMS messages.

When should I use @fastify/env in my Fastify project?

Use `@fastify/env` early in your application setup, ideally before registering other plugins or routes. This plugin loads and validates environment variables, making them accessible through `fastify.config` and ensuring your application has the necessary configuration settings available from the start.

Can I send SMS messages from a frontend application directly using this guide?

No, this guide focuses on building a *backend* Fastify API endpoint. Your frontend application (or any client) would send an HTTP POST request to this endpoint to trigger the SMS sending process through the Sinch platform. The API endpoint acts as an intermediary to manage the interaction with Sinch and protect your API keys.

How to manage Sinch API credentials securely

Store Sinch credentials (Project ID, Key ID, Key Secret) in a `.env` file locally. Never commit this file to version control. In production, use a secrets management service like AWS Secrets Manager, GCP Secret Manager, or Vault to securely store and access these sensitive credentials.

What is the correct format for recipient phone numbers?

Use the E.164 format for recipient phone numbers (e.g., +15551234567). This international standard ensures consistent formatting. Input validation in the Fastify route enforces this format using a regular expression, preventing errors and ensuring deliverability.

How to handle errors when sending SMS messages via Sinch

The provided example implements error handling at the service level and the route level. It uses try-catch blocks and request.log for capturing Sinch API errors and other issues. Errors are logged, and appropriate responses (e.g., 500 Internal Server Error) are returned to the client without revealing sensitive internal details.

What is fastify-cli and how to use it

`fastify-cli` is a command-line tool that helps generate Fastify project boilerplate. Install it globally using `npm install -g fastify-cli`. It simplifies project initialization, providing a standard structure and reducing manual setup steps.

How to set up rate limiting for my Fastify SMS API

Use the `@fastify/rate-limit` plugin. Register it in your `app.js` file and configure options like `max` requests and `timeWindow`. For multi-instance deployments, consider a persistent store like Redis for consistent rate limiting across instances.

Why is setting the correct Sinch region important?

Setting the correct Sinch region is crucial for API requests to reach the right endpoint. Use the `SINCH_REGION` environment variable and construct the Sinch base URL based on this region. Using an incorrect region will lead to authentication failures or routing errors.

How to implement SMS retry mechanisms with Sinch

Use the `client_reference` parameter in the Sinch `batches.send` request and a library like `async-retry` to handle retries with exponential backoff. Generate a unique `client_reference` value for each logical SMS attempt to ensure idempotency and prevent unintended duplicate messages on retries.