This guide provides a step-by-step walkthrough for building a production-ready Node.js application using the Express framework to send Multimedia Messaging Service (MMS) messages via the Sinch MMS JSON API. We will cover project setup, core implementation, API creation, error handling, security, deployment, and verification.
By the end of this guide, you will have a functional Express API endpoint capable of accepting requests to send MMS messages, including text and media content hosted at a public URL, using your Sinch account credentials.
Project Overview and Goals
Goal: To create a reliable backend service that can send MMS messages programmatically using Sinch.
Problem Solved: Automates the process of sending rich media messages (images, audio, video) to users, essential for notifications, marketing, or user engagement requiring more than plain text SMS.
Technologies:
- Node.js: A JavaScript runtime for building scalable server-side applications.
- Express: A minimal and flexible Node.js web application framework for building APIs.
- Axios: A promise-based HTTP client for making requests to the Sinch API.
- dotenv: A module to load environment variables from a
.env
file. - Winston: A versatile logging library.
- express-validator: Middleware for request input validation.
- express-rate-limit: Middleware for basic API rate limiting.
- helmet: Middleware for setting security-related HTTP headers.
- axios-retry: Plugin to automatically retry failed Axios requests.
- Sinch MMS JSON API: Specifically, the XMS API endpoint for sending MMS messages.
System Architecture:
graph LR
Client[Client Application / cURL] -- HTTP POST Request --> ExpressApp[Node.js/Express API]
ExpressApp -- Validate Request --> ExpressApp
ExpressApp -- Call Sinch Service --> SinchService[Sinch MMS Service Logic]
SinchService -- Build JSON Payload --> SinchService
SinchService -- POST Request (Axios w/ Retry) --> SinchAPI[Sinch XMS API (/batches)]
SinchAPI -- Send MMS --> MobileNetwork[Mobile Network]
MobileNetwork -- Deliver MMS --> UserDevice[User's Mobile Device]
SinchAPI -- API Response (Success/Failure) --> SinchService
SinchService -- Process Response --> ExpressApp
ExpressApp -- HTTP Response (Success/Error) --> Client
Prerequisites:
- Node.js and npm (or yarn) installed.
- A Sinch account. Confirm that your account plan includes access to the MMS API. This might be enabled by default on paid plans but may require specific activation for trials or certain account types. Check your dashboard or contact Sinch support if unsure.
- A provisioned phone number (Short Code, Toll-Free, or 10DLC) within your Sinch account. Verify in the Sinch dashboard (usually under ""Numbers"" -> ""Your Numbers"") that the chosen number is explicitly enabled for MMS sending capabilities.
- Your Sinch Service Plan ID and API Token. These are typically found in your Sinch Customer Dashboard under SMS -> APIs -> REST API Configuration. Note that dashboard layouts can change; look for API credentials or REST API settings if this exact path differs.
- A publicly accessible URL hosting the media file (image, audio, video, etc.) you want to send. Sinch needs to fetch the content from this URL via HTTP GET.
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for the project.
mkdir sinch-mms-sender cd sinch-mms-sender
-
Initialize Node.js Project: This creates a
package.json
file.npm init -y
-
Enable ES Modules: To use
import
/export
syntax, add""type"": ""module""
to yourpackage.json
. Alternatively, rename all.js
files to.mjs
. We will use thepackage.json
method. Openpackage.json
and add the line:{ ""name"": ""sinch-mms-sender"", ""version"": ""1.0.0"", ""description"": """", ""main"": ""src/server.js"", ""type"": ""module"", ""scripts"": { }, }
-
Install Dependencies: Install Express, Axios, dotenv, validation, rate limiting, security headers, logging, and retry libraries.
npm install express axios dotenv express-validator express-rate-limit helmet winston axios-retry
express
: Web framework.axios
: HTTP client.dotenv
: Loads environment variables from.env
.express-validator
: For request input validation.express-rate-limit
: Basic rate limiting.helmet
: Security HTTP headers.winston
: Logging library.axios-retry
: Automatic retries for Axios requests.
-
Install Development Dependencies: Install
nodemon
for automatic server restarts during development.npm install --save-dev nodemon
-
Create Project Structure: Organize the code for better maintainability.
mkdir src mkdir src/routes mkdir src/controllers mkdir src/services mkdir src/middleware mkdir src/config touch src/server.js touch src/routes/mmsRoutes.js touch src/controllers/mmsController.js touch src/services/sinchService.js touch src/middleware/authMiddleware.js touch src/config/logger.js touch .env touch .gitignore
-
Configure
.gitignore
: Prevent sensitive files and unnecessary modules from being committed to version control.# .gitignore node_modules .env npm-debug.log logs/ *.log
-
Set Up Environment Variables (
.env
): Store your sensitive credentials and configuration here. Never commit this file.# .env # Server Configuration PORT=3000 NODE_ENV=development # Use 'production' in deployment # Sinch API Credentials & Config SINCH_SERVICE_PLAN_ID=YOUR_SERVICE_PLAN_ID_HERE SINCH_API_TOKEN=YOUR_API_TOKEN_HERE SINCH_MMS_NUMBER=+1YOURSINCHNUMBER # Use E.164 format (e.g., +12223334444) SINCH_API_BASE_URL=https://us.sms.api.sinch.com # Or your region's URL (e.g., eu.sms.api.sinch.com) # Basic API Key for Authentication # IMPORTANT: Generate a strong, random key for production! # Example generation: openssl rand -base64 32 INTERNAL_API_KEY=REPLACE_WITH_A_SECURE_RANDOM_KEY # Logging Configuration LOG_LEVEL=debug # Use 'info' or 'warn' in production # SENTRY_DSN=YOUR_SENTRY_DSN_HERE # Optional: For Sentry integration
PORT
: Port the Express server will listen on.NODE_ENV
: Environment type (development
,production
).SINCH_SERVICE_PLAN_ID
,SINCH_API_TOKEN
: Your credentials from the Sinch Dashboard (see Prerequisites).SINCH_MMS_NUMBER
: The Sinch phone number you'll send MMS from (E.164 format).SINCH_API_BASE_URL
: The base URL for the Sinch API region. Check Sinch documentation or your dashboard.INTERNAL_API_KEY
: Crucial: Replace the placeholder with a cryptographically secure random string. This key protects your API endpoint.LOG_LEVEL
: Controls logging verbosity.
-
Add npm Scripts: Add scripts to
package.json
for running the server.// package.json ""scripts"": { ""start"": ""node src/server.js"", ""dev"": ""nodemon src/server.js"", ""test"": ""echo \""Error: no test specified\"" && exit 1"" },
2. Implementing Core Functionality (Sinch Service)
We'll create a dedicated service to handle interactions with the Sinch MMS API, incorporating logging and retry logic.
src/config/logger.js
// src/config/logger.js
import winston from 'winston';
import dotenv from 'dotenv';
dotenv.config();
const { combine, timestamp, json, errors, colorize, printf } = winston.format;
// Custom format for console logging with colors
const consoleFormat = combine(
colorize(),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
printf(({ timestamp, level, message, stack }) => {
return `${timestamp} ${level}: ${stack || message}`;
})
);
// Format for file logging (JSON)
const fileFormat = combine(
timestamp(),
errors({ stack: true }), // Log stack traces
json()
);
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info', // Default to 'info'
format: fileFormat, // Default format (used by file transports)
transports: [
// Console transport - only in non-production or if explicitly enabled
...(process.env.NODE_ENV !== 'production' ? [
new winston.transports.Console({
format: consoleFormat, // Use colorful format for console
handleExceptions: true,
})
] : []),
// File transports (optional, adjust paths and levels)
// new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
// new winston.transports.File({ filename: 'logs/combined.log' }),
],
exitOnError: false, // Do not exit on handled exceptions
});
// If we're in production and didn't add a console transport above,
// add a basic JSON console transport for platforms that aggregate stdout/stderr.
if (process.env.NODE_ENV === 'production' && logger.transports.length === 0) {
logger.add(new winston.transports.Console({
format: fileFormat, // Use JSON format for production console logs
handleExceptions: true,
}));
logger.info('Production environment detected, configuring JSON console logging.');
} else if (process.env.NODE_ENV !== 'production') {
logger.info('Development environment detected, configuring colorful console logging.');
}
export default logger;
src/services/sinchService.js
// src/services/sinchService.js
import axios from 'axios';
import axiosRetry from 'axios-retry';
import dotenv from 'dotenv';
import logger from '../config/logger.js';
dotenv.config(); // Load environment variables
const SINCH_API_BASE_URL = process.env.SINCH_API_BASE_URL;
const SINCH_SERVICE_PLAN_ID = process.env.SINCH_SERVICE_PLAN_ID;
const SINCH_API_TOKEN = process.env.SINCH_API_TOKEN;
const SINCH_MMS_NUMBER = process.env.SINCH_MMS_NUMBER;
if (!SINCH_API_BASE_URL || !SINCH_SERVICE_PLAN_ID || !SINCH_API_TOKEN || !SINCH_MMS_NUMBER) {
logger.error(""FATAL ERROR: Missing required Sinch environment variables. Check .env file."");
process.exit(1); // Exit if critical configuration is missing
}
// Construct the full API endpoint URL using the Sinch XMS (Unified Messaging) API
const SINCH_XMS_ENDPOINT = `${SINCH_API_BASE_URL}/xms/v1/${SINCH_SERVICE_PLAN_ID}/batches`;
// Create an Axios instance for Sinch requests
const sinchAxios = axios.create();
// Configure axios-retry for the instance
axiosRetry(sinchAxios, {
retries: 3, // Number of retry attempts
retryDelay: (retryCount, error) => {
logger.warn(`Retry attempt ${retryCount} for request to ${error.config.url} due to ${error.message}`);
return retryCount * 1000; // Exponential backoff (1s, 2s, 3s)
},
retryCondition: (error) => {
// Retry on network errors or 5xx server errors from Sinch
const shouldRetry = axiosRetry.isNetworkOrIdempotentRequestError(error) ||
(error.response && error.response.status >= 500);
if (shouldRetry) {
logger.info(`Condition met for retry: ${error.message}`);
}
return shouldRetry;
},
shouldResetTimeout: true, // Reset timeout on retries
});
/**
* Sends an MMS message using the Sinch XMS JSON API (/batches endpoint).
* Assumes validation is handled by the controller/route layer.
*
* @param {string} recipientPhoneNumber - The recipient's phone number in E.164 format.
* @param {string} mediaUrl - The publicly accessible URL of the media file.
* @param {string} mediaType - The type of media (e.g., 'image', 'video'). Consult Sinch docs for supported types.
* @param {string} [messageText=''] - Optional text message. Max 5000 chars.
* @param {string} [subject=''] - Optional MMS subject. Max 80 chars (40 recommended).
* @param {string} [fallbackText=''] - Optional text for SMS fallback. Max 160 chars (~110 recommended).
* @returns {Promise<object>} - The response data from the Sinch API (typically includes batch ID).
* @throws {Error} - Throws an error if the API request ultimately fails after retries.
*/
export const sendMms = async (
recipientPhoneNumber,
mediaUrl,
mediaType, // Note: mediaType isn't directly used in this payload, Sinch infers from URL/headers
messageText = '',
subject = '',
fallbackText = ''
) => {
// Construct the Sinch XMS API payload for sending MMS via the /batches endpoint
const batchPayload = {
to: [recipientPhoneNumber], // Array of recipients
from: SINCH_MMS_NUMBER,
body: {
type: ""mt_media"", // Indicates a mobile-terminated media message (MMS)
url: mediaUrl,
message: messageText || undefined, // Optional text associated with the media
parameters: {
// Only include parameters if they have a non-empty value
...(subject && { ""mms_subject"": { default: subject } }),
...(fallbackText && { ""mms_fallback_text"": { default: fallbackText } }),
// Add other parameters as needed, check Sinch XMS API docs
}
},
// Optional: Configure delivery report callback for status updates
// delivery_report: ""FULL"", // Or ""SUMMARY""
// callback_url: ""YOUR_WEBHOOK_URL_HERE""
};
// Remove empty parameters object if no optional params were provided
if (Object.keys(batchPayload.body.parameters).length === 0) {
delete batchPayload.body.parameters;
}
// Remove empty message field if not provided
if (!batchPayload.body.message) {
delete batchPayload.body.message;
}
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${SINCH_API_TOKEN}` // Use Bearer token authentication
};
logger.info(`Attempting to send MMS via Sinch XMS to ${recipientPhoneNumber} from ${SINCH_MMS_NUMBER}`);
logger.debug(`Using Endpoint: ${SINCH_XMS_ENDPOINT}`);
logger.debug(""Request Payload:"", { payload: batchPayload }); // Log payload structure
try {
// Use the Axios instance with retry configured
const response = await sinchAxios.post(SINCH_XMS_ENDPOINT, batchPayload, { headers });
logger.info('Sinch API Response Received:', { status: response.status, data: response.data });
// Check for potential errors within a successful (2xx) HTTP response structure
const batch = response.data?.batches?.[0];
if (batch && (batch.error_code || batch.status === 'FAILED')) {
logger.error('Sinch API reported an error or failure status within the batch response:', { batchData: batch });
const errorMessage = batch.status_details || `Sinch API Error Code: ${batch.error_code || 'N/A'}, Status: ${batch.status}`;
throw new Error(errorMessage);
}
// Also check for top-level errors if the structure varies
if (response.data?.error) {
logger.error('Sinch API top-level error:', { errorData: response.data.error });
throw new Error(`Sinch API Error: ${response.data.error.message || JSON.stringify(response.data.error)}`);
}
// Assuming success if status is 2xx and no specific error structure is found.
return response.data;
} catch (error) {
logger.error('Error sending MMS via Sinch service:', {
message: error.message,
status: error.response?.status,
responseData: error.response?.data,
requestConfig: error.config
});
// Construct a more informative error message
let errorMessage = 'Failed to send MMS via Sinch.';
if (error.response) {
const errorDetails = error.response.data?.error?.message
|| error.response.data?.batches?.[0]?.status_details
|| JSON.stringify(error.response.data);
errorMessage = `Sinch API request failed with status ${error.response.status}: ${errorDetails}`;
} else if (error.request) {
errorMessage = 'Sinch API request failed: No response received from server after retries.';
} else {
errorMessage = `Failed to send MMS: ${error.message}`;
}
throw new Error(errorMessage); // Re-throw the processed error
}
};
Explanation:
- Logger: Imports the configured
winston
logger. - Configuration: Loads Sinch credentials and base URL, logging a fatal error and exiting if any are missing.
- Endpoint: Defines the URL for the Sinch XMS
/batches
endpoint. - Axios Instance & Retry: Creates a dedicated
axios
instance and configuresaxios-retry
to automatically retry requests on network errors or 5xx responses from Sinch, using exponential backoff. Logs retry attempts. sendMms
Function:- Takes recipient, media URL, media type, text, subject, and fallback text.
- Payload Construction: Builds the JSON object for the
/batches
endpoint. Key fields includeto
,from
,body.type
,body.url
,body.message
, andbody.parameters
(conditionally added). - Empty
parameters
ormessage
fields are removed if not used. - Headers: Sets
Content-Type
andAuthorization: Bearer
. - Logging: Uses the logger (
logger.info
,logger.debug
,logger.warn
,logger.error
). Payload is logged at debug level. - API Call: Uses the
sinchAxios
instance (with retry) to make thePOST
request. - Response Handling: Logs the response. Checks the response body for specific error codes or failure statuses even if the HTTP status is 2xx. Throws an error if an issue is found.
- Error Handling: The
catch
block logs detailed error information and constructs a specific error message before re-throwing it.
3. Building the API Layer (Express Route and Controller)
Now, let's create the Express endpoint that will use our sinchService
.
src/middleware/authMiddleware.js
// src/middleware/authMiddleware.js
import dotenv from 'dotenv';
import logger from '../config/logger.js';
dotenv.config();
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY;
if (!INTERNAL_API_KEY || INTERNAL_API_KEY === 'REPLACE_WITH_A_SECURE_RANDOM_KEY') {
logger.warn(""SECURITY WARNING: INTERNAL_API_KEY is not set or is using the default placeholder. The API endpoint is effectively unprotected. Please set a strong key in .env"");
}
/**
* Simple API Key Authentication Middleware.
* Checks for 'X-API-Key' header.
*/
export const apiKeyAuth = (req, res, next) => {
// Allow requests if no key is configured (useful ONLY for local dev, insecure)
if (!INTERNAL_API_KEY || INTERNAL_API_KEY === 'REPLACE_WITH_A_SECURE_RANDOM_KEY') {
logger.warn('Proceeding without API key check because INTERNAL_API_KEY is not properly configured.');
return next();
}
const providedKey = req.headers['x-api-key'];
if (!providedKey) {
logger.warn('Unauthorized access attempt: Missing X-API-Key header.');
return res.status(401).json({ message: 'Unauthorized: Missing API Key' });
}
if (providedKey !== INTERNAL_API_KEY) {
logger.warn(`Forbidden access attempt: Invalid API Key provided. Key: ${providedKey.substring(0, 5)}...`);
return res.status(403).json({ message: 'Forbidden: Invalid API Key' });
}
logger.debug('API Key authentication successful.');
next(); // Key is valid, proceed
};
src/controllers/mmsController.js
// src/controllers/mmsController.js
import { validationResult } from 'express-validator';
import { sendMms } from '../services/sinchService.js';
import logger from '../config/logger.js';
/**
* Handles the request to send an MMS message.
* Validates input, calls the Sinch service, and sends the response.
*/
export const handleSendMms = async (req, res) => {
// 1. Validate Request Input using express-validator results
const errors = validationResult(req);
if (!errors.isEmpty()) {
logger.warn('Validation failed for /send MMS request:', { errors: errors.array() });
return res.status(400).json({ errors: errors.array() });
}
// 2. Extract validated data from request body
const { to, mediaUrl, mediaType, messageText, subject, fallbackText } = req.body;
try {
// 3. Call the Sinch service function
logger.info(`Controller received valid request to send MMS to: ${to}`);
const sinchResponse = await sendMms(
to,
mediaUrl,
mediaType,
messageText,
subject,
fallbackText
);
// 4. Send successful response back to client (202 Accepted)
logger.info('MMS request successfully processed by Sinch service.', { sinchResponse });
res.status(202).json({
message: 'MMS request accepted for processing by Sinch.',
details: sinchResponse // Include Sinch's response (e.g., batch ID)
});
} catch (error) {
// 5. Handle errors thrown by the Sinch service
logger.error(`Error in handleSendMms controller after calling service: ${error.message}`, { stack: error.stack });
// Default to 500 for server/service errors.
const statusCode = 500;
res.status(statusCode).json({
message: 'Failed to process MMS request.',
error: error.message // Provide the specific error message from the service
});
}
};
src/routes/mmsRoutes.js
// src/routes/mmsRoutes.js
import express from 'express';
import { body, validationResult } from 'express-validator';
import { handleSendMms } from '../controllers/mmsController.js';
import { apiKeyAuth } from '../middleware/authMiddleware.js';
import logger from '../config/logger.js';
const router = express.Router();
// Define supported media types (align with Sinch capabilities, consult docs)
const SUPPORTED_MEDIA_TYPES = ['image', 'audio', 'video', 'pdf', 'contact', 'calendar'];
// Input validation rules using express-validator
const sendMmsValidationRules = [
body('to')
.trim()
.notEmpty().withMessage('Recipient phone number (to) is required.')
.isString()
.matches(/^\+[1-9]\d{1,14}$/).withMessage('Recipient phone number must be in E.164 format (e.g., +12223334444).'),
body('mediaUrl')
.trim()
.notEmpty().withMessage('Media URL (mediaUrl) is required.')
.isURL({ protocols: ['http', 'https'], require_protocol: true })
.withMessage('Media URL must be a valid HTTP or HTTPS URL.'),
body('mediaType')
.trim()
.notEmpty().withMessage('Media type (mediaType) is required.')
.toLowerCase()
.isIn(SUPPORTED_MEDIA_TYPES)
.withMessage(`Invalid media type. Must be one of: ${SUPPORTED_MEDIA_TYPES.join(', ')}`),
body('messageText')
.optional({ checkFalsy: true }) // Allow empty string or null/undefined
.isString()
.isLength({ max: 5000 }).withMessage('Message text cannot exceed 5000 characters.'),
body('subject')
.optional({ checkFalsy: true })
.isString()
.isLength({ max: 80 }).withMessage('Subject cannot exceed 80 characters (40 recommended).'),
body('fallbackText')
.optional({ checkFalsy: true })
.isString()
.isLength({ max: 160 }).withMessage('Fallback text cannot exceed 160 characters (approx. SMS limit).')
];
// Middleware to log validation results (optional, for debugging)
const logValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
logger.debug('Express-validator validation errors:', { errors: errors.array() });
}
next();
};
// Define the POST route for sending MMS
// Chain: Authentication -> Validation -> Controller
router.post(
'/send',
apiKeyAuth, // 1. Check API Key
sendMmsValidationRules, // 2. Validate request body
// logValidationErrors, // Uncomment for debugging validation errors
handleSendMms // 3. Process the request if auth/validation pass
);
export default router;
Explanation:
- Auth Middleware (
authMiddleware.js
): Checks forX-API-Key
header against.env
. Logs warnings if the key is missing or invalid. - Controller (
mmsController.js
):- Uses
validationResult
to check for errors fromexpress-validator
. Returns 400 if invalid. - Extracts validated data.
- Calls
sendMms
service function within atry...catch
block. - Logs actions and outcomes.
- Returns
202 Accepted
on success, including the service response. - Catches errors from the service, logs them, and returns
500 Internal Server Error
.
- Uses
- Router (
mmsRoutes.js
):- Defines strict validation rules for all input fields using
express-validator
. - Defines the
POST /api/mms/send
route. - Applies middleware sequentially:
apiKeyAuth
, validation rules, then the controller.
- Defines strict validation rules for all input fields using
4. Integrating with Third-Party Services (Sinch)
This section focuses on the specific Sinch integration points.
-
Credentials:
SINCH_SERVICE_PLAN_ID
,SINCH_API_TOKEN
,SINCH_MMS_NUMBER
,SINCH_API_BASE_URL
: Ensure these are correctly set in your.env
file (locally) and as secure environment variables in your deployment environment. Never commit.env
or hardcode credentials.- Refer to the Prerequisites section for details on finding these values.
-
API Endpoint:
- We use the Sinch XMS API endpoint:
${SINCH_API_BASE_URL}/xms/v1/${SINCH_SERVICE_PLAN_ID}/batches
. - Refer to the official Sinch XMS API documentation for the latest specifications.
- We use the Sinch XMS API endpoint:
-
Authentication:
- Sinch API uses Bearer Token authentication.
- The
Authorization: Bearer ${SINCH_API_TOKEN}
header is set insinchService.js
.
-
Payload:
- The JSON payload in
sinchService.js
uses themt_media
type within the/batches
structure. - Verify parameter names against the official Sinch XMS API documentation if adding more options.
- The JSON payload in
-
Media URL Requirements:
- The
mediaUrl
must be publicly accessible via HTTP GET. - The hosting server must return a
Content-Length
header. Check usingcurl -I <your_media_url>
. - Ensure the URL returns the correct
Content-Type
header (e.g.,image/jpeg
).
- The
-
Fallback Mechanism:
- The
parameters.mms_fallback_text
field controls the SMS text if MMS fails. - Check Sinch documentation for parameters like
disable_fallback_sms_link
ordisable_fallback_sms
if needed.
- The
5. Error Handling, Logging, and Retry Mechanisms
- Error Handling: Implemented at multiple levels:
- Validation (400):
express-validator
in routes. - Authentication (401/403):
apiKeyAuth
middleware. - Service Errors (Sinch API): Handled in
sinchService.js
catch block with retries viaaxios-retry
. - Controller Errors (500): Controller's
catch
block handles service errors.
- Validation (400):
- Logging:
- Uses
winston
for structured, leveled logging (src/config/logger.js
). - Different formats for console (dev) and potential files/services (prod).
- Includes timestamps and stack traces.
- Replaced
console.*
withlogger.*
.
- Uses
- Retry Mechanisms:
axios-retry
configured insinchService.js
.- Retries network issues or 5xx errors from Sinch with exponential backoff.