code examples

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

How to Implement Sinch SMS Delivery Status Callbacks with Node.js and Vite

Step-by-step guide to implementing Sinch SMS delivery callbacks in Node.js with Express and Vite. Learn webhook security, HMAC validation, and real-time status tracking for React apps.

Sinch SMS Delivery Status Callbacks in Node.js: Complete Guide for Vite Apps

Learn how to implement Sinch SMS delivery status callbacks using Node.js, Express, and Vite for real-time message tracking. This comprehensive guide covers building a secure webhook endpoint that receives delivery reports from the Sinch API, validating callbacks with HMAC signatures, and integrating status tracking with your React frontend application.

Important: SDK Package Verification

This guide uses @sinch/sdk-core for the Sinch Node.js SDK. As of 2024-2025, Sinch offers multiple SDK packages. Verify the current recommended package at Sinch Developer Documentation before implementation. Alternative packages may include @sinch/sdk-sms or platform-specific SDKs. The authentication and API methods shown may vary by package version.

Building an SMS Delivery Tracking System with Sinch Webhooks

System Components:

You'll create a system comprising:

  1. A Node.js/Express backend API responsible for:
    • Exposing an endpoint to send SMS messages via the Sinch API.
    • Exposing a secure webhook endpoint to receive delivery status callbacks from Sinch.
    • Validating incoming callbacks using HMAC signatures.
    • (Optional but recommended) Storing or processing message status updates.
  2. A Vite (React) frontend that:
    • Provides a simple interface to send SMS messages through your backend API.
    • Displays the messageId (batch ID) received after sending.

Problem Solved:

Your applications need real-time visibility into SMS message delivery status to know if messages successfully reach recipients' handsets. Relying solely on the initial API response isn't enough – delivery is asynchronous. Sinch provides delivery status callbacks via webhooks, enabling you to track message status changes from queued to delivered or failed. Implementing a secure and reliable webhook endpoint requires careful setup with proper HMAC signature validation. This guide shows you how to:

  • Securely handle Sinch API credentials.
  • Send SMS messages using the Sinch Node.js SDK.
  • Configure webhooks in the Sinch dashboard correctly.
  • Implement a Node.js endpoint to receive callbacks.
  • Validate callback authenticity using HMAC signatures.
  • Process delivery status information.

Technologies Used:

  • Node.js: Backend JavaScript runtime environment.
  • Express.js: Minimalist web framework for Node.js, used to build the API and webhook endpoint.
  • Sinch Node.js SDK (@sinch/sdk-core): Official SDK for interacting with Sinch APIs. (Note: Verify against current Sinch documentation if this core package is sufficient or if a more specific SMS package is recommended for your use case).
  • Vite: Frontend build tool (we'll use the React template).
  • dotenv: Module to load environment variables from a .env file.
  • cors: Express middleware to enable Cross-Origin Resource Sharing (necessary for local development).
  • ngrok (for testing): A tool to expose local servers to the internet, allowing Sinch to send callbacks to your development machine.

System Architecture:

+-----------------+ +---------------------+ +----------------+ | Vite Frontend |------>| Node.js/Express |------>| Sinch API | | (React App) | | Backend |<------| (Send SMS) | +-----------------+ | (API, Webhook Hdlr) | +----------------+ +---------------------+ | ^ | (Callback) | | +----------------------------+
  1. User Interaction: The user enters a phone number and message in the Vite frontend.
  2. API Call: The frontend sends a POST request to the /send-sms endpoint on the Node.js backend.
  3. Sinch Request: The backend uses the Sinch SDK to send the SMS message via the Sinch API. Sinch returns a batch_id (or messageId).
  4. API Response: The backend returns the batch_id to the frontend.
  5. Sinch Callback: When the delivery status changes (e.g., delivered, failed), Sinch sends a POST request (callback) to the pre-configured webhook URL on the Node.js backend.
  6. Webhook Processing: The backend validates the callback's HMAC signature, parses the payload, and processes the delivery status (e.g., logs it, updates a database).

Prerequisites:

  • Node.js and npm (or yarn): Install Node.js 18.17.0 minimum (required by Vite). For production, use Node.js 20 (Active LTS until April 2026) or Node.js 22 (LTS until April 2027). Node.js 18 reaches end-of-life in April 2025. Download Node.js. For more details on proper phone number formatting, see our guide on E.164 phone number format.
  • Sinch Account: A free or paid Sinch account. Sign up at Sinch Dashboard
  • Sinch API Credentials:
    • Project ID, Key ID, Key Secret: Found on your Sinch account dashboard. Needed for most APIs including SMS.
    • A provisioned Sinch Phone Number (or Alphanumeric Sender ID) capable of sending SMS.
    • Webhook Secret: You will create this when setting up the webhook in the Sinch dashboard.
  • Basic knowledge of JavaScript, Node.js, Express, and React.
  • ngrok (Optional but Recommended for Local Testing): Download ngrok

How to Set Up Your Node.js Project Structure for Sinch SMS Webhooks

Let's structure our project with a client directory for the Vite frontend and a server directory for the Node.js backend.

Step 1: Create Project Directory and Frontend

Open your terminal and run the following commands:

bash
# Create the main project directory
mkdir sinch-callback-app
cd sinch-callback-app

# Create the Vite React frontend
# Accept defaults when prompted
npm create vite@latest client --template react

# Navigate into the client directory and install dependencies
cd client
npm install
cd .. # Go back to the root directory (sinch-callback-app)

Step 2: Create and Initialize Backend

bash
# Create the server directory
mkdir server
cd server

# Initialize the Node.js project
npm init -y

# Install necessary backend dependencies
npm install express @sinch/sdk-core dotenv cors

Step 3: Project Structure

Your project structure should now look like this:

plaintext
sinch-callback-app/
├── client/             # Vite Frontend
│   ├── node_modules/
│   ├── public/
│   ├── src/
│   ├── index.html
│   ├── package.json
│   └── vite.config.js
│   └── ...
└── server/             # Node.js Backend
    ├── node_modules/
    ├── package.json
    ├── .env            # Will create this next
    ├── server.js       # Will create this next
    └── sinchClient.js  # Will create this next

Step 4: Configure Environment Variables

Create a .env file inside the server directory to store your Sinch credentials securely. Never commit this file to version control. Committing this file exposes your secret API keys and credentials, creating a significant security risk.

plaintext
# server/.env

# Sinch API Credentials (Get from Sinch Dashboard -> Project -> API Keys)
SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID
SINCH_KEY_ID=YOUR_SINCH_KEY_ID
SINCH_KEY_SECRET=YOUR_SINCH_KEY_SECRET

# Your provisioned Sinch phone number or Sender ID
SINCH_NUMBER=YOUR_SINCH_NUMBER # e.g., +12025550199

# Webhook Secret (You will define this in the Sinch Dashboard later)
# Choose a strong, random string
SINCH_WEBHOOK_SECRET=YOUR_STRONG_RANDOM_WEBHOOK_SECRET

# Port for the backend server
PORT=3001
  • Purpose: Using environment variables keeps sensitive credentials out of your codebase. dotenv loads these into process.env for your Node.js application.
  • Obtaining Credentials:
    • PROJECT_ID, KEY_ID, KEY_SECRET: Log in to the Sinch Dashboard. Navigate to your Project, then find the API Keys or similar section. Create a new key if needed.
    • SINCH_NUMBER: This is the number you've rented or configured within your Sinch account for sending SMS.
    • SINCH_WEBHOOK_SECRET: You create this secret. Make it a strong, unpredictable string. You will enter this exact same string when configuring the webhook in the Sinch portal later.

Step 5: Create Gitignore

In the root directory (sinch-callback-app), create a .gitignore file to prevent committing sensitive files and unnecessary directories:

plaintext
# .gitignore

# Node modules
**/node_modules

# Environment variables
**/server/.env

# Build artifacts
**/client/dist
**/client/.vite

# OS generated files
.DS_Store
Thumbs.db

Initialize git and make your first commit:

bash
git init
git add .
git commit -m "Initial project setup with frontend and backend structure"

Integrating the Sinch Node.js SDK for SMS Delivery Callbacks

Let's set up the Sinch SDK client in our backend.

Step 1: Create Sinch Client Configuration

Create a file server/sinchClient.js to initialize and export the Sinch client instance.

javascript
// server/sinchClient.js
import { SinchClient } from '@sinch/sdk-core';
import dotenv from 'dotenv';

dotenv.config(); // Load environment variables from .env file

const sinchClient = new SinchClient({
  projectId: process.env.SINCH_PROJECT_ID,
  keyId: process.env.SINCH_KEY_ID,
  keySecret: process.env.SINCH_KEY_SECRET,
  // 'region' is optional, defaults to 'US'. Use 'EU', 'BR', 'CA', 'AU' if needed.
  // region: 'US',
});

export default sinchClient;
  • Why: This centralizes the Sinch client initialization. We load credentials securely from environment variables using dotenv. We export the initialized client for use in other parts of our application.

How to Create a Secure Webhook Endpoint for Sinch Delivery Status Callbacks

This is the core of handling delivery statuses. We need an Express endpoint that Sinch can POST data to. This endpoint must handle raw request bodies for HMAC validation before attempting to parse JSON.

Step 1: Set up Basic Express Server

Modify server/server.js with the basic server structure.

javascript
// server/server.js
import express from 'express';
import dotenv from 'dotenv';
import cors from 'cors';
import crypto from 'crypto'; // Node.js crypto module for HMAC
import sinchClient from './sinchClient.js';

dotenv.config();

const app = express();
const PORT = process.env.PORT || 3001;

// --- Middleware ---
// Enable CORS for requests from your frontend (adjust origin in production)
// WARNING: Using '*' is insecure for production. Restrict it to your frontend's origin.
app.use(cors({ origin: '*' })); // For development ONLY. Allow all origins.

// IMPORTANT: Use express.raw() for the webhook endpoint BEFORE express.json()
// This ensures we get the raw body buffer for HMAC verification.
app.post('/webhooks/sinch/delivery', express.raw({ type: 'application/json' }), (req, res) => {
  console.log('Received raw callback request on /webhooks/sinch/delivery');

  const webhookSecret = process.env.SINCH_WEBHOOK_SECRET;
  if (!webhookSecret) {
    console.error('SINCH_WEBHOOK_SECRET is not set in environment variables.');
    return res.status(500).send('Webhook secret not configured');
  }

  // --- 4. Handling Callback Security (HMAC) ---
  const timestamp = req.headers['x-sinch-webhook-signature-timestamp'];
  const nonce = req.headers['x-sinch-webhook-signature-nonce'];
  const algorithm = req.headers['x-sinch-webhook-signature-algorithm']; // Should be HmacSHA256
  const signatureHeader = req.headers['x-sinch-webhook-signature'];

  if (!timestamp || !nonce || !algorithm || !signatureHeader) {
    console.warn('Missing required HMAC headers');
    return res.status(400).send('Missing required HMAC headers');
  }

  // Ensure the body is a Buffer (express.raw should handle this)
  if (!Buffer.isBuffer(req.body)) {
     console.error('Request body is not a Buffer. Check middleware order.');
     return res.status(500).send('Internal Server Error: Invalid body type for HMAC.');
  }
  const rawBody = req.body.toString('utf8'); // Get raw body as string for signing

  try {
    const signedData = rawBody + '.' + nonce + '.' + timestamp;
    const calculatedSignature = crypto
      .createHmac('sha256', webhookSecret)
      .update(signedData)
      .digest('base64');

    console.log(`Received Signature: ${signatureHeader}`);
    console.log(`Calculated Signature: ${calculatedSignature}`);

    // Securely compare signatures (constant time comparison)
    const signaturesMatch = crypto.timingSafeEqual(
        Buffer.from(signatureHeader, 'base64'),
        Buffer.from(calculatedSignature, 'base64')
    );

    if (!signaturesMatch) {
      console.warn('HMAC validation failed: Signatures do not match.');
      return res.status(401).send('Unauthorized: Invalid signature');
    }

    console.log('HMAC validation successful!');

    // --- 5. Processing Callback Data ---
    // Now that HMAC is validated, parse the JSON payload
    const payload = JSON.parse(rawBody); // Use the validated rawBody

    // Example: Log the delivery report details
    if (payload.message_delivery_report) {
      const report = payload.message_delivery_report;
      console.log(`--- Delivery Report ---`);
      console.log(`Message ID: ${report.message_id}`); // This often corresponds to the batch_id
      console.log(`Conversation ID: ${report.conversation_id}`);
      console.log(`Status: ${report.status}`); // e.g., QUEUED_ON_CHANNEL, DELIVERED, FAILED
      console.log(`Channel: ${report.channel_identity?.channel}`);
      console.log(`Recipient: ${report.channel_identity?.identity}`);
      console.log(`Timestamp: ${payload.event_time}`);
      if (report.reason) {
        console.log(`Reason: ${report.reason}`); // Present on failure
      }
       console.log(`--- End Delivery Report ---`);

      // --- 6. Storing Message Status (Example) ---
      // In a real app, update your database here based on message_id and status
      // messageStore.updateStatus(report.message_id, report.status, report.reason);

    } else {
       console.log('Received callback, but not a message_delivery_report:', payload);
    }


    // IMPORTANT: Respond quickly with a 2xx status code to acknowledge receipt.
    // Failure to respond promptly can lead to Sinch retrying the callback.
    res.status(200).send('Callback received successfully.');

  } catch (error) {
    console.error('Error processing Sinch callback:', error);
    // Avoid sending detailed error messages back in the response for security
    res.status(500).send('Internal Server Error');
  }
});

// --- Other Routes and Middleware ---
// Use express.json() for other routes that expect JSON payloads
app.use(express.json());

// Placeholder for the SMS sending endpoint (we'll build this next)
app.post('/send-sms', (req, res) => {
  res.status(501).send('Not Implemented Yet');
});

// Basic root route
app.get('/', (req, res) => {
  res.send('Sinch Callback Backend is running!');
});

// Start the server
app.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
  console.log(`Webhook endpoint: /webhooks/sinch/delivery`);
});
  • Why express.raw? Sinch's HMAC signature is calculated based on the raw, unparsed request body. If we let express.json() parse it first, the body gets modified, and the signature won't match. We apply express.raw({ type: 'application/json' }) only to the webhook route.
  • Why express.json() later? Other routes, like our upcoming /send-sms endpoint, will expect standard JSON payloads, so we apply the express.json() middleware after the specific webhook route.
  • Why cors? The Vite development server runs on a different port than our backend server (e.g., 5173 vs. 3001). Browsers enforce the Same-Origin Policy, blocking requests between different origins unless the server explicitly allows it via CORS headers. cors() adds these headers. Security Note: The example cors({ origin: '*' }) allows requests from any origin. This is convenient for local development but highly insecure for production. In production, you must restrict the origin to your specific frontend domain(s), e.g., cors({ origin: 'https://your-frontend-domain.com' }).

Implementing HMAC Signature Validation for Sinch Webhook Security

This crucial step ensures that the callbacks received are genuinely from Sinch and haven't been tampered with. The code for this is included within the /webhooks/sinch/delivery route handler in server.js (Step 1 above).

Critical Security Notes:

  • HMAC Algorithm: Sinch uses HMAC-SHA256 for webhook signature validation to ensure delivery callbacks are authentic and haven't been tampered with. The algorithm name appears in the x-sinch-webhook-signature-algorithm header (expected value: HmacSHA256).
  • Header Names: Verify these exact header names against Sinch Webhook Signature Validation docs. Header names are case-sensitive in HTTP/2 and may vary by Sinch API version:
    • x-sinch-webhook-signature-timestamp
    • x-sinch-webhook-signature-nonce
    • x-sinch-webhook-signature-algorithm
    • x-sinch-webhook-signature
  • Timing Attack Prevention: Always use crypto.timingSafeEqual() for signature comparison. Regular string comparison (===) is vulnerable to timing attacks where attackers can infer signature validity by measuring response times.
  • Replay Attack Protection: Implement timestamp validation to reject callbacks older than 5-10 minutes. Add nonce tracking to prevent replay attacks (store used nonces temporarily).
  • Raw Body Requirement: HMAC signature is computed on the raw request body bytes. Any parsing or transformation invalidates the signature. Use express.raw() middleware exclusively for webhook routes.

Explanation of the HMAC Logic:

  1. Retrieve Headers: Get the x-sinch-webhook-signature-timestamp, x-sinch-webhook-signature-nonce, x-sinch-webhook-signature-algorithm, and x-sinch-webhook-signature from the request headers.
  2. Get Raw Body: Access the raw request body (ensured by express.raw).
  3. Get Secret: Retrieve the SINCH_WEBHOOK_SECRET you defined in your .env file.
  4. Construct Signed Data: Concatenate the rawBody, nonce, and timestamp strings, separated by a period (.).
  5. Calculate Signature: Use Node.js's built-in crypto module:
    • Create an HMAC object using the sha256 algorithm and your webhookSecret.
    • Update the HMAC object with the signedData.
    • Generate the digest in base64 format.
  6. Compare Signatures:
    • Use crypto.timingSafeEqual to compare the calculated signature with the signature received in the header. This function prevents timing attacks. Important: Both buffers being compared must have the same byte length.
  7. Handle Validation Result:
    • If signatures match, proceed to process the callback data.
    • If they don't match, log a warning and return a 401 Unauthorized status.

Processing and Handling Sinch SMS Delivery Status Updates

Once HMAC validation passes, you can safely parse and use the callback payload. This logic is also within the /webhooks/sinch/delivery route handler (Step 1 in Section 3).

Key Information in message_delivery_report:

  • message_id: The unique identifier for the message batch (usually matches the batch_id returned when sending). Use this to correlate the callback with the original message sent.
  • status: The delivery status (e.g., DELIVERED, FAILED, QUEUED_ON_CHANNEL, REJECTED, EXPIRED).
  • channel_identity.identity: The recipient's phone number.
  • reason: If the status is FAILED, this field often contains an error code or description.
  • event_time: Timestamp of when the status event occurred.

Sinch SMS Delivery Status Codes:

StatusDescriptionAction Required
QUEUEDMessage queued in Sinch systemWait for further updates
DISPATCHEDMessage sent to carrierNormal progression
DELIVEREDMessage delivered to handsetSuccess - no action needed
FAILEDDelivery failed permanentlyCheck reason field, notify user, investigate carrier/number issues
EXPIREDMessage expired before deliveryRetry with shorter validity period if needed
REJECTEDCarrier rejected messageCheck number format, carrier restrictions, content filtering
UNKNOWNStatus unknown (rare)Monitor for updates or contact Sinch support
QUEUED_ON_CHANNELMessage queued at carrierIntermediate state, wait for final status

Note: Status codes may vary by Sinch API version and SMS channel. Consult Sinch Message Delivery States documentation for the complete list applicable to your implementation.

Action: In a real application, you would typically use the message_id and status to update a record in your database associated with the sent message.

Storing SMS Delivery Status in Your Database

For demonstration, we won't set up a full database. However, in a production scenario, you need persistent storage.

Conceptual Database Update:

Imagine you have a messages table with columns like message_id, recipient, body, sent_at, status, status_updated_at, failure_reason.

Inside the callback handler (after validation and parsing):

javascript
// --- 6. Storing Message Status (Conceptual DB Example) ---
const report = payload.message_delivery_report;
const messageId = report.message_id;
const newStatus = report.status;
const failureReason = report.reason || null; // Store null if no reason
const statusTimestamp = payload.event_time;

try {
  // Replace with your actual database update logic (e.g., using Prisma, Sequelize, knex)
  await db.messages.update({
    where: { message_id: messageId },
    data: {
      status: newStatus,
      status_updated_at: new Date(statusTimestamp),
      failure_reason: failureReason,
    },
  });
  console.log(`Updated status for message ${messageId} to ${newStatus}`);
} catch (dbError) {
  console.error(`Failed to update database for message ${messageId}:`, dbError);
  // Consider retry logic or logging to an error tracking service
}

Building a React Frontend with Vite for SMS Delivery Tracking

Let's create a simple React component to send an SMS via our backend.

Step 1: Modify client/src/App.jsx

Replace the contents of client/src/App.jsx with the following:

jsx
// client/src/App.jsx
import { useState } from 'react';
import './App.css'; // You can add basic styling here

function App() {
  const [recipient, setRecipient] = useState('');
  const [messageBody, setMessageBody] = useState('');
  const [sending, setSending] = useState(false);
  const [statusMessage, setStatusMessage] = useState('');
  const [messageId, setMessageId] = useState('');

  // Define the backend API URL (adjust if your backend runs elsewhere)
  const BACKEND_URL = 'http://localhost:3001'; // Default backend port

  const handleSubmit = async (event) => {
    event.preventDefault();
    setSending(true);
    setStatusMessage('Sending...');
    setMessageId('');

    try {
      const response = await fetch(`${BACKEND_URL}/send-sms`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          to: recipient,
          body: messageBody,
        }),
      });

      const data = await response.json();

      if (!response.ok) {
        // Handle errors from the backend API
        throw new Error(data.error || `HTTP error! status: ${response.status}`);
      }

      setStatusMessage(`SMS submitted successfully!`);
      setMessageId(data.messageId); // Display the batch ID
      setRecipient(''); // Clear fields on success
      setMessageBody('');

    } catch (error) {
      console.error('Error sending SMS:', error);
      setStatusMessage(`Error: ${error.message}`);
      setMessageId('');
    } finally {
      setSending(false);
    }
  };

  return (
    <div className="App">
      <h1>Send SMS via Sinch</h1>
      <form onSubmit={handleSubmit}>
        <div>
          <label htmlFor="recipient">Recipient Number:</label>
          <input
            type="tel"
            id="recipient"
            value={recipient}
            onChange={(e) => setRecipient(e.target.value)}
            placeholder="+12223334444"
            required
            disabled={sending}
          />
        </div>
        <div>
          <label htmlFor="messageBody">Message:</label>
          <textarea
            id="messageBody"
            value={messageBody}
            onChange={(e) => setMessageBody(e.target.value)}
            required
            disabled={sending}
          />
        </div>
        <button type="submit" disabled={sending}>
          {sending ? 'Sending...' : 'Send SMS'}
        </button>
      </form>
      {statusMessage && <p className="status">{statusMessage}</p>}
      {messageId && <p className="message-id">Message Batch ID: <code>{messageId}</code></p>}
      <p className="info">Delivery status callbacks will be logged on the backend server console.</p>
    </div>
  );
}

export default App;

Step 2: Basic Styling (Optional)

Add some basic styles to client/src/App.css:

css
/* client/src/App.css */
.App {
  max-width: 500px;
  margin: 2rem auto;
  padding: 2rem;
  border: 1px solid #ccc;
  border-radius: 8px;
  font-family: sans-serif;
}

label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: bold;
}

input[type="tel"],
textarea {
  width: 100%;
  padding: 0.5rem;
  margin-bottom: 1rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  box-sizing: border-box; /* Include padding in width */
}

textarea {
  min-height: 80px;
  resize: vertical;
}

button {
  padding: 0.75rem 1.5rem;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
}

button:disabled {
  background-color: #aaa;
  cursor: not-allowed;
}

.status {
  margin-top: 1rem;
  padding: 0.75rem;
  border-radius: 4px;
  background-color: #e0e0e0;
  border: 1px solid #ccc;
}

.message-id {
  margin-top: 0.5rem;
  font-size: 0.9rem;
  word-break: break-all;
}

.message-id code {
  background-color: #f0f0f0;
  padding: 2px 4px;
  border-radius: 3px;
}

.info {
    margin-top: 1.5rem;
    font-size: 0.85rem;
    color: #555;
    border-top: 1px dashed #ccc;
    padding-top: 1rem;
}

Creating the SMS Sending API Endpoint with Sinch Node.js SDK

Now, let's implement the /send-sms endpoint in our backend.

Step 1: Update server/server.js

Replace the placeholder /send-sms route with the actual implementation:

javascript
// server/server.js
// ... (keep imports and other middleware as before, including the webhook route) ...

// --- Other Routes and Middleware ---
app.use(express.json()); // Make sure this is after the webhook route but before /send-sms

// --- SMS Sending Endpoint ---
app.post('/send-sms', async (req, res) => {
  const { to, body } = req.body;

  // Basic input validation
  if (!to || !body) {
    return res.status(400).json({ error: 'Missing "to" or "body" in request' });
  }
  if (!process.env.SINCH_NUMBER) {
     console.error('SINCH_NUMBER is not set in environment variables.');
     return res.status(500).json({ error: 'Server configuration error: Sender number missing.' });
  }

  console.log(`Attempting to send SMS to: ${to} from: ${process.env.SINCH_NUMBER}`);

  try {
    // Use the imported sinchClient
    const response = await sinchClient.sms.batches.send({
      sendSMSRequestBody: {
        to: [to.trim()], // Expecting a single recipient for this simple example
        from: process.env.SINCH_NUMBER,
        body: body,
        // Optional parameters:
        // delivery_report: 'full', // Request delivery report (often default)
        // type: 'mt_text', // Mobile Terminated text message
      },
    });

    console.log('Sinch API Send Response:', response);

    // The 'id' in the response is the batch ID (often referred to as messageId)
    res.status(200).json({ success: true, messageId: response.id });

  } catch (error) {
    console.error('Error sending SMS via Sinch:', error.response?.data || error.message);
    // Provide a more generic error to the client
    res.status(error.response?.status || 500).json({
       error: 'Failed to send SMS',
       details: error.response?.data?.text || 'An internal server error occurred.',
    });
  }
});

// ... (keep basic root route and app.listen as before) ...
  • Why: This endpoint receives the recipient number and message body from the frontend, uses the configured sinchClient to call the Sinch SMS API, and returns the resulting batch_id (message ID) or an error to the frontend.

Best Practices for Error Handling in Sinch SMS Webhooks

  • Backend:
    • We use try...catch blocks around Sinch API calls and callback processing.
    • Errors are logged to the console using console.error.
    • Basic input validation is included in the /send-sms endpoint.
    • HMAC validation handles unauthorized callback attempts.
    • Sensitive error details from the Sinch API are logged on the server but not necessarily exposed directly to the frontend.
  • Frontend:
    • The fetch call includes a .catch() block to handle network errors or exceptions.
    • We check response.ok to handle HTTP errors (like 4xx, 5xx) returned by the backend API.
    • Status messages are displayed to the user.

Production Considerations:

  • Use a dedicated logging library (like Winston or Pino) for structured logging (levels, formats, transports to files or services).
  • Implement an error tracking service (like Sentry or Datadog) to capture and analyze errors centrally.
  • Add more robust input validation and sanitization (using libraries like joi or express-validator).
  • Implement rate limiting on API endpoints to prevent abuse.
  • Configure CORS securely for your production frontend domain.
  • For SMS compliance requirements, review our guide on 10DLC registration for US SMS.

Testing Sinch SMS Delivery Callbacks Locally with ngrok

Testing webhooks locally requires exposing your backend server to the public internet so Sinch can reach it. ngrok is perfect for this.

Step 1: Configure Sinch Webhook

  1. Go to your Sinch Dashboard.
  2. Navigate to the API settings relevant to your SMS service. This might be under SMS, Conversation API, or a specific Service Plan you are using. Look for a section named Webhooks, Callbacks, or API Settings. (Note: Sinch Dashboard UI can change. Look for settings related to the specific API/Service ID you are using for sending SMS).
  3. Create a new Webhook (or configure an existing one):
    • Target URL: This is where ngrok comes in. Leave this blank for now.
    • Target Type: HTTP (or HTTPS if ngrok provides it).
    • Secret: Enter the exact same strong, random secret you put in your server/.env file for SINCH_WEBHOOK_SECRET.
    • Triggers: Select MESSAGE_DELIVERY. This tells Sinch to send callbacks specifically for delivery status updates. You might find related triggers like MESSAGE_INBOUND or EVENT_DELIVERY - ensure MESSAGE_DELIVERY (or its equivalent for SMS status) is selected.
    • Associate with Service/App: Ensure the webhook is linked to the correct Service Plan ID or App ID used for sending the SMS messages, if applicable.
  4. Save the webhook configuration (you'll add the URL next).

Step 2: Run Backend and Frontend

  1. Run Backend Server:

    bash
    cd server
    node server.js
    # Or use nodemon for auto-restarts during development: npm install -g nodemon; nodemon server.js

    You should see Server listening on port 3001.

  2. Run Frontend Dev Server: Open another terminal window.

    bash
    cd client
    npm run dev

    Your React app should open in your browser (usually at http://localhost:5173).

Step 3: Use ngrok

  1. Open a third terminal window.
  2. Start ngrok, pointing it to your backend server's port (3001 in this case):
    bash
    ngrok http 3001
  3. ngrok will display output including Forwarding URLs. Copy the https URL (e.g., https://random-string.ngrok-free.app).

Step 4: Update Sinch Webhook URL

  1. Go back to the Sinch Dashboard where you configured the webhook.
  2. Edit the webhook.
  3. Paste the https ngrok URL into the Target URL field, appending your specific webhook path: https://random-string.ngrok-free.app/webhooks/sinch/delivery
  4. Save the webhook configuration again.

Step 5: Send an SMS and Watch for Callbacks

  1. Go to your running React app in the browser (http://localhost:5173).
  2. Enter a valid recipient phone number (your own mobile is good for testing) and a message.
  3. Click Send SMS.
  4. Observe:
    • Frontend: You should see the "SMS submitted successfully!" message and the Message Batch ID.
    • Backend Console (Terminal 1): You'll see logs for the /send-sms request and the response from the Sinch API.
    • Backend Console (Terminal 1 - after a delay): After a few seconds or minutes (depending on the carrier), you should see logs from the /webhooks/sinch/delivery endpoint:
      • "Received raw callback request..."
      • HMAC signature comparison logs.
      • "HMAC validation successful!"
      • "--- Delivery Report ---" logs showing the message_id, status (e.g., DELIVERED), etc.
    • ngrok Console (Terminal 3): You can see incoming POST requests hitting the /webhooks/sinch/delivery path on the ngrok tunnel.

Testing HMAC Failure (Optional):

  • Temporarily change the SINCH_WEBHOOK_SECRET in your .env file to something incorrect.
  • Restart your backend server (node server.js).
  • Send another SMS.
  • When the callback arrives, you should see "HMAC validation failed" in the backend logs, and Sinch might retry sending the callback. Remember to change the secret back and restart the server.

Using curl to Simulate Callbacks:

You can manually test the endpoint, but you need a valid payload and correctly calculated HMAC headers.

  1. Get a Real Payload: Send a message and capture the raw body logged by your server when a valid callback arrives.
  2. Calculate HMAC: You'd need to manually calculate the x-sinch-webhook-signature based on the captured payload, a nonce, a timestamp, and your secret.
  3. Send curl Request:
    bash
    # Replace placeholders with actual values
    TIMESTAMP=$(date +%s)
    NONCE=$(openssl rand -hex 16)
    # Ensure RAW_BODY is the exact string payload Sinch sends, without extra escaping if possible
    RAW_BODY='{"app_id":"...","accepted_time":"...","event_time":"...",...}' # Your captured payload as a single line JSON string
    SIGNED_DATA="${RAW_BODY}.${NONCE}.${TIMESTAMP}"
    SECRET="YOUR_STRONG_RANDOM_WEBHOOK_SECRET"
    SIGNATURE=$(echo -n "$SIGNED_DATA" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64)
    
    curl -X POST \
      'http://localhost:3001/webhooks/sinch/delivery' \
      -H 'Content-Type: application/json' \
      -H "x-sinch-webhook-signature-timestamp: $TIMESTAMP" \
      -H "x-sinch-webhook-signature-nonce: $NONCE" \
      -H 'x-sinch-webhook-signature-algorithm: HmacSHA256' \
      -H "x-sinch-webhook-signature: $SIGNATURE" \
      -d "$RAW_BODY"
    This is complex and mainly useful for isolated endpoint testing without relying on Sinch sending the callback.

Troubleshooting Sinch Webhook and Delivery Status Issues

HMAC Validation Failures

Symptom: Backend logs show "HMAC validation failed: Signatures do not match"

Common Causes:

  • Mismatched Secret: The SINCH_WEBHOOK_SECRET in your .env file doesn't match the secret configured in Sinch Dashboard. These must be identical (case-sensitive).
  • Body Parsing: Middleware order is incorrect. express.json() parses the body before HMAC validation. Ensure express.raw() is applied to the webhook route before any JSON parsing middleware.
  • Charset Issues: The raw body string encoding doesn't match. Always use req.body.toString('utf8') for consistency.
  • Signature Encoding: Verify you're comparing base64-encoded signatures. Both buffers in crypto.timingSafeEqual() must have identical length.

Solution: Check webhook secret, verify middleware order, log both signatures for comparison, ensure base64 encoding.

Webhook Not Receiving Callbacks

Symptom: SMS sends successfully but no callback arrives at webhook endpoint

Common Causes:

  • Incorrect Webhook URL: The URL configured in Sinch Dashboard doesn't match your server. Verify the ngrok URL includes the full path: https://xxx.ngrok-free.app/webhooks/sinch/delivery
  • ngrok Not Running: The ngrok tunnel closed or restarted with a different URL. Free ngrok URLs change on restart.
  • Firewall/Network: Production servers may block Sinch's webhook source IPs. Check firewall rules.
  • Webhook Not Enabled: In Sinch Dashboard, ensure MESSAGE_DELIVERY trigger is enabled and associated with your Service Plan ID.
  • Server Not Running: Backend server crashed or stopped. Check server logs.

Solution: Verify webhook URL in Sinch Dashboard, restart ngrok and update URL, check server is running on correct port, enable webhook triggers.

ngrok Connection Issues

Symptom: ngrok shows errors or "Failed to start tunnel"

Common Causes:

  • Port Already in Use: Another process is using port 3001. Change PORT in .env or kill the conflicting process.
  • ngrok Not Authenticated: Free ngrok accounts require authentication. Run ngrok authtoken YOUR_TOKEN with token from ngrok dashboard.
  • Rate Limits: Free ngrok accounts have connection limits. Upgrade or wait for rate limit reset.

Solution: Kill processes on port 3001 (lsof -ti:3001 | xargs kill), authenticate ngrok, check ngrok dashboard for account limits.

CORS Errors in Frontend

Symptom: Browser console shows "CORS policy: No 'Access-Control-Allow-Origin' header"

Common Causes:

  • CORS Middleware Missing: cors() middleware not applied to Express app.
  • CORS Before Routes: CORS middleware must be applied before route definitions.
  • Wrong Origin: Production CORS restricts origin to specific domain, but frontend uses different URL.

Solution: Apply cors() middleware early in server.js, use cors({ origin: '*' }) for development only, specify exact frontend origin in production: cors({ origin: 'https://your-frontend.com' }).

SMS Not Sending

Symptom: /send-sms endpoint returns errors

Common Causes:

  • Invalid Credentials: SINCH_PROJECT_ID, SINCH_KEY_ID, or SINCH_KEY_SECRET incorrect or expired.
  • Number Format: Recipient number not in E.164 format (+12025551234). Missing + or country code causes rejection.
  • Sender ID Issues: SINCH_NUMBER not provisioned, or alphanumeric sender ID not allowed in recipient country.
  • Account Limits: Trial accounts have sending restrictions. Check account limits in Sinch Dashboard.
  • SDK Method Changed: Sinch SDK API changed. Verify current method signature at Sinch SDK Documentation.

Solution: Verify credentials in Sinch Dashboard, format numbers as E.164, provision sender ID, upgrade trial account, check SDK documentation for current API.

Sinch SMS Delivery Callback FAQs

How do I test webhooks without ngrok?

Deploy your backend to a hosting service with a public URL (Vercel, Heroku, Railway, AWS). Use the deployed URL in Sinch Dashboard. For development, ngrok is simplest, but alternatives include localtunnel, serveo, or Cloudflare Tunnel.

What happens if my webhook endpoint is down?

Sinch retries failed webhook deliveries multiple times over several hours. Implement retry logic and ensure your endpoint responds with HTTP 200-299 within 10 seconds. Prolonged failures may cause Sinch to disable the webhook.

How do I handle multiple concurrent callbacks?

Use async/await properly and avoid blocking operations. For high volume, implement a message queue (RabbitMQ, AWS SQS, Redis Bull) to process callbacks asynchronously. Store callbacks in a database immediately, then process in background workers.

Can I use Sinch webhooks with serverless functions?

Yes, but ensure your function:

  • Uses express.raw() or equivalent to preserve raw body
  • Responds within the function timeout limit (typically 10-30 seconds)
  • Handles cold starts gracefully
  • Implements idempotency for retry handling

Serverless platforms: AWS Lambda (with API Gateway), Vercel Functions, Netlify Functions, Google Cloud Functions.

How do I verify webhook signature without Express?

Extract raw body as Buffer, get headers, calculate HMAC-SHA256:

javascript
const crypto = require('crypto');

function validateSignature(rawBody, timestamp, nonce, signatureHeader, secret) {
  const signedData = rawBody + '.' + nonce + '.' + timestamp;
  const calculatedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedData)
    .digest('base64');

  return crypto.timingSafeEqual(
    Buffer.from(signatureHeader, 'base64'),
    Buffer.from(calculatedSignature, 'base64')
  );
}

What should I store in my database?

Minimum recommended fields:

  • message_id (from Sinch batch ID)
  • recipient (phone number)
  • message_body (SMS content)
  • status (current delivery status)
  • status_updated_at (timestamp)
  • failure_reason (if failed)
  • sent_at (original send time)
  • callback_count (number of callbacks received for idempotency)

How do I implement replay attack prevention?

  1. Timestamp Validation: Reject callbacks with timestamps older than 5-10 minutes:
javascript
const timestamp = parseInt(req.headers['x-sinch-webhook-signature-timestamp']);
const currentTime = Math.floor(Date.now() / 1000);
if (currentTime - timestamp > 600) { // 10 minutes
  return res.status(401).send('Callback too old');
}
  1. Nonce Tracking: Store used nonces in Redis with TTL:
javascript
const nonce = req.headers['x-sinch-webhook-signature-nonce'];
const exists = await redis.exists(`nonce:${nonce}`);
if (exists) {
  return res.status(401).send('Duplicate callback');
}
await redis.setex(`nonce:${nonce}`, 600, '1'); // 10-minute expiry

What are Sinch's webhook retry policies?

Sinch retries failed webhooks (non-2xx responses or timeouts) with exponential backoff. Typical retry schedule:

  • Immediate retry
  • After 1 minute
  • After 5 minutes
  • After 15 minutes
  • After 1 hour
  • After 6 hours

Exact timing depends on failure type. Implement idempotency to handle duplicate callbacks safely. Check callback_count in your database before processing.

Frequently Asked Questions

How to implement Sinch SMS callbacks in Node.js?

Implement Sinch SMS callbacks by creating a Node.js/Express backend with a secure webhook endpoint. This endpoint receives delivery status updates from Sinch. Use the Sinch Node.js SDK to send SMS messages and the `express.raw` middleware to handle incoming raw callback data for proper HMAC verification before parsing the JSON payload.

What is the purpose of HMAC validation with Sinch webhooks?

HMAC validation ensures the authenticity and integrity of Sinch webhook callbacks. It confirms that the received data originates from Sinch and hasn't been tampered with, adding a crucial security layer to your application.

Why use express.raw middleware for Sinch callbacks?

The `express.raw` middleware is essential for receiving the raw, unparsed request body containing callback data from Sinch. This is required for accurate HMAC signature validation, which must be done before any JSON parsing.

How to configure Sinch webhooks in the dashboard?

In the Sinch Dashboard, navigate to the API settings for your SMS service. Create a new webhook, specifying the target URL (your backend endpoint exposed via ngrok during development), HTTP target type, a strong secret (matching your server's `SINCH_WEBHOOK_SECRET`), and the `MESSAGE_DELIVERY` trigger. Remember to link the webhook to the relevant service or app ID if necessary.

What does the Sinch SMS callback payload contain?

The Sinch SMS callback payload, particularly the `message_delivery_report`, includes the `message_id` (matching the sent batch ID), delivery `status` (e.g., DELIVERED, FAILED), recipient number, optional failure `reason`, and the `event_time` timestamp.

How to store Sinch SMS delivery status updates?

Store delivery status updates in a database after validating the callback's HMAC signature. Log the `message_id`, `status`, `reason`, and `event_time` from the callback payload. In a production environment, establish a connection to your database and update records based on the `message_id`.

What is ngrok used for with Sinch callback testing?

ngrok creates a public tunnel to your locally running backend server. This allows Sinch to send webhooks to your development environment during testing, as Sinch needs a publicly accessible URL to send callbacks to.

When should I use ngrok with Sinch callbacks?

Use ngrok during local development and testing when your backend server isn't publicly accessible. This enables Sinch to deliver callbacks to your localhost for testing purposes. In production, your backend should be deployed publicly, and ngrok isn't needed.

How to send an SMS message using Sinch Node.js SDK?

Use the Sinch Node.js SDK's `sinchClient.sms.batches.send` method. Provide the recipient number(s), sender number (`SINCH_NUMBER`), message body, and any optional parameters (like `delivery_report`). This sends the SMS via the Sinch API.

What is the Sinch batch ID or messageId?

The Sinch batch ID or messageId is a unique identifier for each batch of SMS messages sent. This ID is crucial for correlating delivery reports received via webhooks back to the originally sent message(s). It's typically included in the callback payload and initial send response.

How to handle errors when sending SMS messages with Sinch?

Implement `try...catch` blocks around Sinch API calls to handle potential errors. Log detailed error information on the server-side using `console.error`. Return user-friendly error messages to the frontend without revealing sensitive details.

What is the project structure for a Sinch SMS application?

A recommended project structure includes a 'client' directory for the frontend (e.g., Vite/React) and a 'server' directory for the Node.js/Express backend. Key files in the backend include `server.js` (main server logic), `sinchClient.js` (Sinch SDK setup), and `.env` (environment variables).