code examples

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

How to Send MMS with Sinch, Node.js, and Fastify: Complete Tutorial

Learn how to send MMS messages using Sinch SMS API with Node.js and Fastify. Step-by-step tutorial covering project setup, API integration, error handling, rate limiting, and A2P 10DLC compliance for production-ready multimedia messaging.

Sinch Node.js Fastify MMS Multimedia Sending Guide

Learn how to build a production-ready MMS messaging service using the Sinch SMS API, Node.js, and Fastify. This comprehensive tutorial walks you through sending multimedia messages (images, GIFs, videos) with complete code examples covering project setup, Sinch API integration, error handling, rate limiting, and A2P 10DLC compliance requirements for US messaging.

What You'll Build: MMS Messaging API with Sinch and Fastify

<!-- DEPTH: Section lacks concrete use case examples and ROI justification (Priority: Medium) -->

What We're Building:

We will create a simple but robust Fastify API endpoint that accepts requests to send an MMS message (containing an image or other media hosted at a public URL) to a specified recipient via Sinch.

Problem Solved:

This service provides a straightforward way to integrate MMS sending capabilities into larger applications, abstracting the direct interaction with the Sinch API into a dedicated microservice or API layer. This is useful for applications needing to send media-rich notifications, alerts, or marketing messages.

<!-- GAP: Missing comparison of MMS vs SMS effectiveness and when to use each (Type: Substantive) -->

Technologies Used:

  • Node.js: A JavaScript runtime built on Chrome's V8 engine, ideal for building fast, scalable network applications.
  • Fastify: A high-performance, low-overhead web framework for Node.js, chosen for its speed, extensive plugin ecosystem, and developer-friendly features like built-in validation.
  • Sinch SMS API (REST): We'll use Sinch's reliable REST API to handle the actual sending of MMS messages. While Sinch offers SDKs, using the REST API directly with axios provides clarity and control for this specific task.
  • axios: A popular promise-based HTTP client for making requests to the Sinch API.
  • dotenv: To manage environment variables securely for API credentials and configuration.
  • pino-pretty: To enhance development logging readability.
  • @fastify/rate-limit: To protect the API endpoint from abuse.
  • tap: A simple, effective testing framework often used with Fastify.
<!-- EXPAND: Could benefit from performance comparison table with other frameworks (Type: Enhancement) -->

System Architecture:

+-------------+ +-----------------+ +-------------+ +-----------+ | Client | -----> | Fastify API | -----> | Sinch API | -----> | Recipient | | (e.g., App, | | (/send-mms) | | (SMS /batches)| | (Mobile) | | Frontend) | +-----------------+ +-------------+ +-----------+ | | | | ^ ^ | | | v | | | | | Node.js | (MMS) | | | (axios) | | | +-----------------+ | | | | | | <------------------------------------------------------------+ | | (Optional: Delivery Status via Webhook -> DB -> API) +-------------+ <!-- DEPTH: Architecture diagram needs explanation of data flow and failure scenarios (Priority: High) -->

Final Outcome & Prerequisites:

By the end of this guide, you will have a running Fastify application with a single API endpoint (POST /send-mms) capable of sending MMS messages via Sinch.

Prerequisites:

  1. Node.js and npm: Node.js v20 or later required (v22 LTS "Jod" recommended for new projects, Active LTS until October 2025). Note: Node.js v18 reaches end-of-life April 30, 2025. Fastify v5 requires Node.js v20+.
  2. Sinch Account: A registered account at Sinch.com.
  3. Sinch Service Plan ID and API Token: Found in your Sinch Customer Dashboard under SMS -> APIs. Select your API and look for the REST configuration.
  4. Sinch Phone Number: An MMS-capable number purchased or configured within your Sinch account, associated with the Service Plan ID above. Note: Contact your Sinch account manager to enable MMS support.
  5. Basic Command Line/Terminal Familiarity.
  6. (Optional) curl or Postman: For testing the API endpoint.
  7. (Mandatory for US Traffic) A2P 10DLC Registration: Effective February 1, 2025, all businesses sending SMS/MMS messages to US numbers must register with A2P 10DLC. Unregistered messages are blocked by US carriers. Registration requires Business EIN, campaign description, opt-in documentation, and typically takes 1–3 weeks. Work with Sinch to complete registration via The Campaign Registry (TCR).
<!-- GAP: Missing detailed A2P 10DLC registration walkthrough with screenshots (Type: Critical) -->

Step 1: Setting Up Your Node.js MMS Project

Let's initialize our Node.js project and install the necessary dependencies using npm.

Step 1: Create Project Directory

Open your terminal and create a new directory for the project, then navigate into it.

bash
mkdir sinch-mms-fastify
cd sinch-mms-fastify

Step 2: Initialize npm Project

This creates a package.json file to manage project dependencies and scripts. The -y flag accepts default settings.

bash
npm init -y

Step 3: Install Dependencies

We need Fastify, axios for HTTP requests, dotenv for environment variables, and @fastify/rate-limit for security.

bash
npm install fastify axios dotenv @fastify/rate-limit
<!-- GAP: Missing specific version numbers for dependency installation (Type: Substantive) -->

Step 4: Install Development Dependencies

We'll use pino-pretty for readable logs during development and tap for testing.

bash
npm install --save-dev pino-pretty tap

Step 5: Create Project Structure

Set up a basic structure for clarity:

bash
mkdir src
mkdir src/routes
mkdir src/services
touch src/app.js
touch src/server.js
touch src/routes/mms.js
touch src/services/sinchService.js
touch .env
touch .env.example
touch .gitignore

Your structure should look like this:

sinch-mms-fastify/ ├── node_modules/ ├── src/ │ ├── app.js # Fastify app configuration │ ├── server.js # Server startup logic │ ├── routes/ │ │ └── mms.js # MMS sending route handler │ └── services/ │ └── sinchService.js # Logic for interacting with Sinch API ├── .env # Local environment variables (DO NOT COMMIT) ├── .env.example # Example environment variables (Commit this) ├── .gitignore # Files/folders ignored by Git ├── package.json └── package-lock.json

Step 6: Configure .gitignore

Prevent sensitive files and unnecessary folders from being committed to version control. Add the following to .gitignore:

text
# Node dependencies
node_modules/

# Environment variables
.env*
!.env.example

# 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

Step 7: Configure Environment Variables

Populate .env.example with the required variable names. This serves as a template.

.env.example:

dotenv
# Sinch API Credentials (Find in Sinch Dashboard -> SMS -> APIs -> Your API -> REST Configuration)
SINCH_SERVICE_PLAN_ID=YOUR_SERVICE_PLAN_ID
SINCH_API_TOKEN=YOUR_API_TOKEN

# Sinch Number associated with the Service Plan ID (E.164 format, e.g., +12025550181)
SINCH_FROM_NUMBER=YOUR_SINCH_NUMBER

# Sinch API Region Base URL (e.g., us, eu, ca, au, br, jp) - determines the base URL
# Examples: https://us.sms.api.sinch.com, https://eu.sms.api.sinch.com
# Defaulting to US here. Change if needed.
SINCH_REGION=us

# Server Configuration
PORT=3000
HOST=0.0.0.0 # Listen on all available network interfaces
LOG_LEVEL=info # Pino log levels: 'fatal', 'error', 'warn', 'info', 'debug', 'trace' or 'silent'

Now, create your local .env file by copying .env.example and fill in your actual credentials obtained from the Sinch dashboard. Never commit your .env file.

.env:

dotenv
# --- COPY from .env.example and fill with REAL values ---
SINCH_SERVICE_PLAN_ID=your_actual_service_plan_id_here
SINCH_API_TOKEN=your_actual_api_token_here
SINCH_FROM_NUMBER=+1xxxxxxxxxx # Your actual Sinch number
SINCH_REGION=us # Or your region
PORT=3000
HOST=0.0.0.0
LOG_LEVEL=info
# --- END COPY ---
<!-- DEPTH: Environment configuration lacks security best practices and production considerations (Priority: High) -->

Step 8: Add Run Scripts to package.json

Add scripts for starting the server in development (with pretty logging) and production modes.

package.json:

json
{
  // ... other package.json content ...
  ""main"": ""src/server.js"", // Specify entry point
  ""scripts"": {
    ""start"": ""node src/server.js"",
    ""dev"": ""node src/server.js | pino-pretty"", // Pipe output to pino-pretty for dev
    ""test"": ""tap src/**/*.test.js"" // Configure test script later
  },
  // ... other package.json content ...
}

Why this setup?

  • Separation of Concerns: Dividing code into routes, services, app.js, and server.js makes the application easier to understand, maintain, and test.
  • Environment Variables: Using .env keeps sensitive credentials out of the codebase and allows for different configurations per environment (dev, staging, prod).
  • .gitignore: Essential for preventing accidental commits of secrets or large directories.
  • pino-pretty: Improves developer experience by making logs human-readable during development.
  • npm scripts: Standardizes common tasks like starting the server.
<!-- EXPAND: Could add Docker configuration example for containerized deployment (Type: Enhancement) -->

Step 2: Implementing the Sinch MMS Service

Now, let's write the code that interacts directly with the Sinch SMS API to send MMS messages. This service layer handles authentication, request construction, and error handling when communicating with Sinch's REST API endpoints.

File: src/services/sinchService.js

javascript
import axios from 'axios';
import dotenv from 'dotenv';

// Load environment variables early
dotenv.config();

const {
  SINCH_SERVICE_PLAN_ID,
  SINCH_API_TOKEN,
  SINCH_FROM_NUMBER,
  SINCH_REGION = 'us', // Default to 'us' if not set
} = process.env;

// Validate essential configuration
if (!SINCH_SERVICE_PLAN_ID || !SINCH_API_TOKEN || !SINCH_FROM_NUMBER) {
  // Using console.error here as the main app logger might not be initialized yet
  console.error('Error: Missing required Sinch environment variables (SERVICE_PLAN_ID, API_TOKEN, FROM_NUMBER).');
  process.exit(1); // Exit if critical config is missing
}

// Construct base URL based on region
const SINCH_BASE_URL = `https://${SINCH_REGION}.sms.api.sinch.com/xms/v1/${SINCH_SERVICE_PLAN_ID}`;

// Create an axios instance for Sinch API calls
const sinchApiClient = axios.create({
  baseURL: SINCH_BASE_URL,
  headers: {
    'Authorization': `Bearer ${SINCH_API_TOKEN}`,
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
  timeout: 15000, // 15 second timeout
});

/**
 * Sends an MMS message via the Sinch API.
 * Note: Uses console.log/error for simplicity in this standalone module.
 * A more sophisticated setup might involve injecting a logger instance.
 *
 * @param {string} to The recipient phone number in E.164 format (e.g., +1xxxxxxxxxx).
 * @param {string} mediaUrl The publicly accessible URL of the media file.
 *                          Supported formats: JPEG/JPG, PNG, GIF (static and animated).
 *                          Recommended size: Keep under 500 KB for optimal deliverability (max 2 MB).
 *                          Recommended dimensions: 640x1138 pixels (9:16 ratio) or 600x600 (square).
 * @param {string} [text] Optional text message to accompany the media. Max 1600 chars.
 * @returns {Promise<object>} The response data from the Sinch API on success.
 * @throws {Error} If the API call fails or returns an error status.
 */
async function sendMms(to, mediaUrl, text = '') {
  const endpoint = '/batches'; // Using the batches endpoint

  // Construct the payload for sending a single MMS message within a batch
  const payload = {
    to: [to], // Recipient needs to be in an array
    from: SINCH_FROM_NUMBER,
    body: {
      url: mediaUrl,
      // You can optionally add a message/caption to the media body itself
      // message: ""Media Caption Example"",
    },
    // Add the text part if provided
    ...(text && { parameters: { // Standard text goes in parameters for media messages
        // Note: The exact parameter key and carrier handling for text alongside MMS can vary.
        // 'text_message' is a plausible custom parameter.
        // Consult the latest Sinch API documentation for the /batches endpoint and test with target carriers
        // if precise text display behavior is critical.
        text_message: { default: text }
    }}),
    // Explicitly defining type might be needed for some scenarios, but often inferred
    // type: 'mt_media',
    // delivery_report: 'full' // Optional: Request detailed delivery reports (requires webhook setup)
  };

  try {
    console.info(`[Sinch Service] Attempting to send MMS to ${to}...`); // Basic logging
    const response = await sinchApiClient.post(endpoint, payload);

    // Sinch API usually returns 200 or 201 on success for batch sends
    if (response.status === 200 || response.status === 201) {
      console.info(`[Sinch Service] Sinch API Success Response Status: ${response.status}`);
      console.info('[Sinch Service] Sinch Response Data:', response.data); // Log successful response data
      // The response.data often contains a batch_id which can be used for tracking
      return response.data;
    } else {
      // This case might not be hit often if axios throws on non-2xx status, but good practice
      console.error(`[Sinch Service] Sinch API returned non-success status: ${response.status}`, response.data);
      throw new Error(`Sinch API Error: Status ${response.status}`);
    }
  } catch (error) {
    console.error('[Sinch Service] Error sending MMS via Sinch:', error.message);
    if (error.response) {
      // The request was made and the server responded with a status code
      // that falls out of the range of 2xx
      console.error('[Sinch Service] Sinch Error Response Data:', error.response.data);
      console.error('[Sinch Service] Sinch Error Response Status:', error.response.status);
      console.error('[Sinch Service] Sinch Error Response Headers:', error.response.headers);
      // Rethrow a more specific error including details from Sinch if available
      const sinchError = error.response.data?.request_error?.service_exception?.text
                       || error.response.data?.text
                       || `Sinch API request failed with status ${error.response.status}`;
      throw new Error(`Failed to send MMS: ${sinchError}`);
    } else if (error.request) {
      // The request was made but no response was received
      console.error('[Sinch Service] Sinch Error Request:', error.request);
      throw new Error('Failed to send MMS: No response received from Sinch API.');
    } else {
      // Something happened in setting up the request that triggered an Error
      console.error('[Sinch Service] Sinch Error Config:', error.config);
      throw new Error(`Failed to send MMS: Error setting up request - ${error.message}`);
    }
  }
}

export default { sendMms };
<!-- GAP: Missing input validation for phone number format and media URL accessibility (Type: Critical) --> <!-- DEPTH: Payload construction lacks examples of advanced features like batch sending or scheduling (Priority: Medium) -->

Explanation:

  1. Environment Variables: We load credentials using dotenv and immediately check if the essential ones (SERVICE_PLAN_ID, API_TOKEN, SINCH_FROM_NUMBER) are present. The application exits if they are missing, preventing runtime errors later.
  2. Base URL: The Sinch API base URL is constructed dynamically based on the SINCH_REGION environment variable, defaulting to us.
  3. axios Instance: We create a dedicated axios instance (sinchApiClient) pre-configured with the baseURL, Authorization header (using the Bearer token), and standard JSON headers. Setting a timeout prevents requests from hanging indefinitely.
  4. sendMms Function:
    • Takes the recipient number (to), the mediaUrl, and optional text as arguments.
    • Defines the Sinch /batches endpoint. While sending a single message, the /batches endpoint is commonly used and supports MMS.
    • Constructs the payload according to the Sinch API specification for sending MMS via the batches endpoint:
      • to: An array containing the recipient number(s).
      • from: The Sinch number loaded from environment variables.
      • body: An object containing the url of the media.
      • parameters: If text is provided, it's added within the parameters object. This is a common way to include text alongside media in the /batches endpoint. (Note: Carrier handling of text alongside MMS can vary, consult latest Sinch docs).
    • Uses async/await to call sinchApiClient.post.
    • Includes basic console.info/console.error logging for this service module. The Fastify route handler uses the framework's structured logger (fastify.log).
    • Performs robust error handling:
      • Checks the response status code for success (200 or 201).
      • Catches various axios error types (response error, request error, setup error).
      • Logs detailed error information from the Sinch response (error.response.data) if available.
      • Throws a new, more informative error message for the calling function to handle.

Step 3: Creating the Fastify API Endpoint for MMS

Now, let's create the Fastify route handler that receives HTTP requests and uses our sinchService to send MMS messages. This endpoint includes automatic request validation, structured error handling, and comprehensive logging for production use.

File: src/routes/mms.js

javascript
import sinchService from '../services/sinchService.js';

// Define the JSON schema for the request body for validation
const sendMmsBodySchema = {
  type: 'object',
  required: ['to', 'mediaUrl'],
  properties: {
    to: {
      type: 'string',
      description: 'Recipient phone number in E.164 format (e.g., +12025550181)',
      // Basic pattern for E.164 format (must start with +, followed by digits)
      pattern: '^\\+[1-9]\\d{1,14}$'
    },
    mediaUrl: {
      type: 'string',
      description: 'Publicly accessible URL of the media file (JPEG, PNG, GIF)',
      format: 'url' // Use Fastify's built-in format validation
    },
    text: {
      type: 'string',
      description: 'Optional text message to accompany the media (max 1600 chars)',
      maxLength: 1600,
      nullable: true // Allow null or missing
    }
  },
  additionalProperties: false // Disallow properties not defined in the schema
};

// Define the schema for the success response
const successResponseSchema = {
  type: 'object',
  properties: {
    message: { type: 'string' },
    batch_id: { type: 'string', description: 'Sinch batch ID for tracking' },
    // Include other relevant fields from Sinch response if needed
  }
};

// Define the schema for error responses
const errorResponseSchema = {
    type: 'object',
    properties: {
        statusCode: { type: 'integer' },
        error: { type: 'string' },
        message: { type: 'string' },
    }
};


/**
 * Fastify route handler plugin for sending MMS.
 * @param {import('fastify').FastifyInstance} fastify The Fastify instance.
 * @param {object} options Plugin options.
 */
async function mmsRoutes(fastify, options) {

  fastify.post('/send-mms', {
    schema: {
      description: 'Send an MMS message via Sinch',
      tags: ['MMS'], // Useful for OpenAPI documentation generation
      summary: 'Sends an MMS message',
      body: sendMmsBodySchema,
      response: {
        200: successResponseSchema, // Successful send
        // Sinch might return 201 Created for batches, map to 200 for consistency or keep 201
        // 201: successResponseSchema,
        // Add schemas for 4xx and 5xx errors for OpenAPI documentation
        400: errorResponseSchema, // Validation error
        500: errorResponseSchema  // Server/Sinch API error
      }
    }
  }, async (request, reply) => {
    // Access the logger instance decorated onto the request object
    const log = request.log;
    const { to, mediaUrl, text } = request.body;

    log.info(`Received request to send MMS to ${to}`);

    try {
      // Pass the logger instance to the service if it were refactored to accept it
      // const sinchResponse = await sinchService.sendMms(to, mediaUrl, text, log);
      const sinchResponse = await sinchService.sendMms(to, mediaUrl, text);

      // Sinch batch API usually returns 200 or 201 on success
      // Standardize API response to 200 OK for simplicity client-side.
      const replyPayload = {
          message: 'MMS sent successfully via Sinch.',
          // Pass relevant info back, like the batch_id for tracking
          batch_id: sinchResponse?.id || 'N/A',
      };

      reply.code(200).send(replyPayload);

    } catch (error) {
      log.error({ err: error }, `Error processing /send-mms request: ${error.message}`);

      // Determine appropriate status code based on error type
      // Check if it's likely a client-side issue passed up from the service
      if (error.message.includes('Failed to send MMS:') &&
          (error.message.includes('400') || // Rough check for Sinch 400 errors
           error.message.includes('Invalid') ||
           error.message.includes('format'))) {
         reply.code(400).send({
             statusCode: 400,
             error: 'Bad Request',
             // Provide a clearer message if possible, else the Sinch error
             message: error.message.replace('Failed to send MMS: ', '')
         });
      } else {
         // Assume internal server error or downstream Sinch API error (5xx, network, etc.)
         reply.code(500).send({
             statusCode: 500,
             error: 'Internal Server Error',
             // Avoid leaking potentially sensitive details from Sinch 5xx errors
             message: 'An unexpected error occurred while attempting to send the MMS.'
         });
      }
    }
  });
}

export default mmsRoutes;
<!-- EXPAND: Schema validation could include example request/response payloads (Type: Enhancement) --> <!-- GAP: Missing rate limit configuration specific to this endpoint (Type: Substantive) -->

Explanation:

  1. Schema Validation: We define detailed JSON schemas (sendMmsBodySchema, successResponseSchema, errorResponseSchema) for the request body and possible responses. Fastify uses these schemas to:
    • Automatically validate incoming requests: If a request doesn't match sendMmsBodySchema, Fastify automatically sends back a 400 Bad Request error before our handler code even runs. This is efficient and secure. We validate to (E.164 pattern), mediaUrl (must be a valid URL format), and text (optional, max length). additionalProperties: false prevents unexpected fields.
    • Serialize responses: Ensures the outgoing response matches the defined success or error schema.
    • (Optional) Generate OpenAPI documentation: These schemas are essential if you later use plugins like @fastify/swagger to generate API docs.
  2. Route Definition:
    • fastify.post('/send-mms', { schema }, async (request, reply) => { ... }) defines the POST endpoint.
    • The schema option attaches our validation and response schemas to the route.
  3. Handler Logic:
    • Extracts validated data (to, mediaUrl, text) from request.body.
    • Uses request.log.info (Fastify's request-bound logger) for logging within the handler context.
    • Calls sinchService.sendMms within a try...catch block.
    • On success, sends a 200 OK response with a success message and the batch_id received from Sinch.
    • On error, logs the error using request.log.error (including the error object for more context) and sends an appropriate error response (attempting to distinguish between 400 Bad Request for likely client/Sinch input errors and 500 Internal Server Error for other issues) with a standardized error payload.

Step 4: Configuring the Fastify Application

The core Sinch integration was handled in src/services/sinchService.js. Now let's configure our main Fastify application (app.js) to load environment variables, set up logging, register plugins (like rate limiting), and register our MMS route.

File: src/app.js

javascript
import Fastify from 'fastify';
import dotenv from 'dotenv';
import rateLimit from '@fastify/rate-limit';
import mmsRoutes from './routes/mms.js';

// Load environment variables first
dotenv.config();

const {
  LOG_LEVEL = 'info', // Default to 'info' if not set
} = process.env;


/**
 * Builds and configures the Fastify application instance.
 * @param {object} opts - Options for Fastify constructor (e.g., for testing)
 * @returns {Promise<import('fastify').FastifyInstance>} The configured Fastify app instance.
 */
async function buildApp(opts = {}) {

  // Initialize Fastify
  // Pass logger options based on environment (pretty print in dev/test)
  const app = Fastify({
    logger: {
      level: LOG_LEVEL,
      ...(process.env.NODE_ENV !== 'production' && { // Enable pretty printing only outside production
        transport: {
          target: 'pino-pretty',
          options: {
            translateTime: 'HH:MM:ss Z', // Human-readable time
            ignore: 'pid,hostname',     // Hide less relevant fields like process ID and hostname
          },
        }
      }),
    },
    ...opts, // Pass any additional options (e.g., from tests)
  });

  // --- Register Plugins ---

  // 1. Rate Limiting
  // Protects against brute-force attacks and abuse
  await app.register(rateLimit, {
    max: 100, // Max requests per window (adjust as needed)
    timeWindow: '1 minute', // Time window
    // Optional: Add Redis for distributed rate limiting across multiple instances
    // redis: new Redis({ host: '127.0.0.1', port: 6379 }),
    // Optional: Customize error response
    // errorResponseBuilder: function (req, context) {
    //   return {
    //     statusCode: 429,
    //     error: 'Too Many Requests',
    //     message: `Rate limit exceeded. You are allowed ${context.max} requests per ${context.after}. Please try again later.`,
    //   }
    // }
  });
  app.log.info('Rate limit plugin registered.');


  // Add other plugins here if needed (e.g., @fastify/cors, @fastify/swagger, @fastify/helmet)


  // --- Register Routes ---
  await app.register(mmsRoutes, { prefix: '/api/v1' }); // Prefix routes with /api/v1
  app.log.info('MMS routes registered under /api/v1.');


  // --- Add Hooks (optional) ---
  // Example: A global hook to log every request start/end
  // app.addHook('onRequest', async (request, reply) => {
  //   request.log.info({ req: { method: request.method, url: request.url, id: request.id } }, 'Incoming request');
  // });
  // app.addHook('onResponse', async (request, reply) => {
  //   request.log.info({ res: { statusCode: reply.statusCode }, reqId: request.id }, 'Request completed');
  // });


  // --- Graceful Shutdown ---
  // Handle SIGINT (Ctrl+C) and SIGTERM (used by Docker/Kubernetes)
  const shutdown = async (signal) => {
      app.log.warn(`Received ${signal}. Shutting down gracefully...`);
      try {
          await app.close();
          app.log.info('Server closed successfully.');
          process.exit(0);
      } catch (err) {
          app.log.error({ err }, 'Error during server shutdown.');
          process.exit(1);
      }
  };
  process.on('SIGINT', shutdown);
  process.on('SIGTERM', shutdown);

  app.log.info('Application setup complete.');
  return app;
}

export default buildApp;
<!-- DEPTH: Rate limit configuration needs justification and guidance on tuning for production (Priority: Medium) --> <!-- GAP: Missing CORS, Helmet, and other security middleware configuration (Type: Critical) -->

File: src/server.js

javascript
import buildApp from './app.js';

const {
  PORT = 3000,  // Default to 3000 if not set
  HOST = '0.0.0.0' // Default to listen on all interfaces
} = process.env;

/**
 * Starts the Fastify server.
 */
async function startServer() {
  let app;
  try {
    app = await buildApp();

    // Start listening and log the address
    await app.listen({ port: PORT, host: HOST });
    // Note: Accessing app.server.address() might return null if listening on a pipe/socket
    // or if called before the 'listening' event. Fastify's internal log usually handles this.
    // For explicit logging, ensure it's done after await app.listen() resolves.
    const address = app.server.address();
    const addressString = typeof address === 'string' ? address : `${address?.address}:${address?.port}`;
    app.log.info(`Server listening on ${addressString}`);

  } catch (err) {
    // Log error using console.error before app.log might be available or if app init failed
    console.error('Error starting server:', err);
    // If app exists and has log, use it, otherwise stick to console
    if (app && app.log) {
        app.log.error({ err }, 'Server startup failed.');
    }
    process.exit(1);
  }
}

startServer();

Explanation:

  1. src/app.js (Configuration):
    • Imports: Brings in Fastify, dotenv, the rateLimit plugin, and our mmsRoutes.
    • buildApp Function: Encapsulates app creation and configuration. This pattern is useful for testing, allowing tests to import and build the app without automatically starting the server.
    • Fastify Instance: Creates the app instance.
      • Logger: Configured with LOG_LEVEL from environment variables. It smartly enables pino-pretty only when NODE_ENV is not production, ensuring structured JSON logs in production (better for log aggregation tools) and readable logs in development/testing.
    • Plugin Registration:
      • @fastify/rate-limit: Registered using app.register. We set a basic limit (e.g., 100 requests per minute). Comments indicate how to use Redis for distributed limiting or customize the error response.
    • Route Registration:
      • app.register(mmsRoutes, { prefix: '/api/v1' }): Registers all routes defined in mms.js under the /api/v1 path prefix. This versions the API.
    • Graceful Shutdown: Implements handlers for SIGINT and SIGTERM signals. This ensures that when the server is asked to stop (e.g., by Ctrl+C, Docker, or Kubernetes), it attempts to finish processing existing requests and close connections cleanly using app.close() before exiting.
  2. src/server.js (Startup):
    • Imports: Imports the buildApp function.
    • startServer Function:
      • Calls buildApp() to get the configured Fastify instance.
      • Calls app.listen() to start the HTTP server, listening on the PORT and HOST defined in environment variables (or defaults).
      • Logs the listening address using app.log.info after the server has successfully started listening. It correctly handles getting the address information.
      • Includes robust error handling for startup failures, attempting to use app.log if available, otherwise falling back to console.error.

Step 5: Adding Error Handling, Logging, and Retry Logic

<!-- GAP: Missing concrete examples of error scenarios and how to debug them (Type: Substantive) -->
  • Error Handling: We've already implemented significant error handling:
    • Fastify's automatic request validation via schemas (mms.js).
    • try...catch blocks in the route handler (mms.js) and the service layer (sinchService.js).
    • Detailed error logging (using request.log in the route, console.error in the service), including Sinch API responses when available in the service layer logs.
    • Returning appropriate HTTP status codes (400, 500) with consistent JSON error payloads from the API route.
  • Logging:
    • Fastify's built-in Pino logger is configured in app.js.
    • Uses pino-pretty for readable development logs.
    • Produces structured JSON logs in production (controlled by NODE_ENV).
    • Logs are written at different levels (info, error, warn) providing context. Request-bound logging (request.log) in the route handler automatically includes request IDs.
    • Troubleshooting: Analyze logs (e.g., using grep or log management tools like Datadog, Splunk in production) to diagnose issues. Look for error messages, stack traces, and context like request IDs.
<!-- DEPTH: Logging strategy lacks correlation IDs and distributed tracing guidance (Priority: Medium) -->
  • Retry Mechanisms:
    • Sending SMS/MMS can sometimes experience transient network issues or brief downstream service unavailability (e.g., Sinch returning 5xx errors). Implementing retries can improve reliability for such cases.
    • Strategy: Use a library like axios-retry integrated with the sinchApiClient in sinchService.js.
      • Install: npm install axios-retry
      • Example Integration (in src/services/sinchService.js):
        javascript
        import axios from 'axios';
        import dotenv from 'dotenv';
        import axiosRetry from 'axios-retry'; // Import
        
        // ... Load env vars ...
        
        // Construct base URL ...
        
        // Create axios instance ...
        const sinchApiClient = axios.create({ /* ... */ });
        
        // Configure retries on the instance
        axiosRetry(sinchApiClient, {
          retries: 3, // Number of retry attempts
          retryDelay: (retryCount, error) => {
            console.warn(`[Sinch Service] Retry attempt ${retryCount} for ${error.config.url}: ${error.message}`);
            return axiosRetry.exponentialDelay(retryCount, error, 1000); // Exponential backoff starting at 1s
          },
          retryCondition: (error) => {
            // Retry on network errors or specific HTTP status codes (e.g., 5xx from Sinch)
            return axiosRetry.isNetworkOrIdempotentRequestError(error) ||
                   (error.response && error.response.status >= 500 && error.response.status <= 599);
          },
          shouldResetTimeout: true, // Reset timeout on retries
        });
        
        // ... rest of sinchService.js (sendMms function) ...
    • Considerations: Retries are generally safe for the Sinch /batches endpoint, especially if using a unique client_reference (not shown here) for idempotency. Avoid retrying on non-recoverable errors like 4xx client errors (e.g., invalid number format).
<!-- EXPAND: Could add circuit breaker pattern for handling sustained API failures (Type: Enhancement) -->

MMS Technical Specifications and Sinch API Limits

<!-- DEPTH: Technical specs lack carrier-specific limitations and regional variations (Priority: High) -->

MMS Media Requirements

Supported Formats:

  • JPEG/JPG
  • PNG
  • GIF (static and animated)

File Size Limits:

  • Recommended: 200–500 KB for optimal speed and deliverability
  • Maximum: 2 MB (varies by carrier)
  • Note: Larger files may experience delivery delays or failures on certain carrier networks

Image Dimensions:

  • Default preview: 640×1138 pixels (9:16 aspect ratio)
  • Square: 600×600 pixels (recommended for profile images)
  • Landscape: 1280×720 pixels (recommended for wide content)
<!-- GAP: Missing guidance on image optimization tools and techniques (Type: Substantive) -->

Sinch API Specifications

API Type: mt_media (required for MMS messages via batches endpoint)

Endpoint Structure:

https://{region}.sms.api.sinch.com/xms/v1/{service_plan_id}/batches

Regional Endpoints:

  • us - United States
  • eu - Europe
  • ca - Canada
  • au - Australia
  • br - Brazil
  • jp - Japan

Rate Limits: Contact Sinch support for account-specific rate limits and throughput requirements.

<!-- GAP: Missing webhook configuration for delivery reports and status callbacks (Type: Critical) -->

A2P 10DLC Compliance (US Only)

Mandatory Since: February 1, 2025

Registration Requirements:

  • Business EIN (Employer Identification Number)
  • Professional email address (non-free domains)
  • Campaign description and use case
  • Opt-in method documentation
  • Privacy policy and terms of service URLs

Timeline: 1–3 weeks for standard campaigns (3–7 business days for campaign approval after brand registration)

Restricted Industries: Cannabis/hemp, firearms, payday loans, third-party collections are not eligible for 10DLC registration.

Consequence of Non-Registration: Messages blocked by US carriers, higher filtering rates, potential account suspension.

<!-- EXPAND: Could add FAQ section addressing common A2P 10DLC registration issues (Type: Enhancement) -->

Looking to expand your messaging capabilities? Check out these related guides:


Source Citations

Node.js Version Information:

Fastify Framework:

Sinch MMS API:

MMS Technical Standards:

A2P 10DLC Compliance: