Developer Guide: Implementing Sinch SMS Delivery Status Callbacks in Node.js for Vite Apps
This guide provides a step-by-step walkthrough for building a Node.js backend application that sends SMS messages using the Sinch API and handles delivery status callbacks. We'll integrate this backend with a simple Vite (React) frontend application.
Project Overview and Goals
What We're Building:
We will create a system comprising:
- 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.
- A basic Vite (React) frontend that:
- Provides a simple interface to send an SMS message through our backend API.
- Displays the
messageId
(batch ID) received after sending.
Problem Solved:
Applications often need to know if an SMS message was successfully delivered to the recipient's handset. Relying solely on the initial API response isn't enough, as delivery can be asynchronous. Sinch provides delivery status callbacks via webhooks, but implementing a secure and reliable endpoint to receive and process these callbacks requires careful setup. This guide addresses how to:
- Securely handle Sinch API credentials.
- Send SMS messages using the Sinch Node.js SDK.
- Correctly configure webhooks in the Sinch dashboard.
- 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)
| |
+----------------------------+
- User Interaction: The user enters a phone number and message in the Vite frontend.
- API Call: The frontend sends a POST request to the
/send-sms
endpoint on the Node.js backend. - Sinch Request: The backend uses the Sinch SDK to send the SMS message via the Sinch API. Sinch returns a
batch_id
(ormessageId
). - API Response: The backend returns the
batch_id
to the frontend. - 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.
- 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): Installed on your system (LTS version recommended). Download Node.js
- 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
1. Setting up the Project
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:
# 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
# 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:
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.
# 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 intoprocess.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:
# .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:
git init
git add .
git commit -m ""Initial project setup with frontend and backend structure""
2. Integrating with Sinch (Backend)
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.
// 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.
3. Implementing the Callback Endpoint
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.
// 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 letexpress.json()
parse it first, the body gets modified, and the signature won't match. We applyexpress.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 theexpress.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 examplecors({ 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' })
.
4. Handling Callback Security (HMAC)
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).
Explanation of the HMAC Logic:
- Retrieve Headers: Get the
x-sinch-webhook-signature-timestamp
,x-sinch-webhook-signature-nonce
,x-sinch-webhook-signature-algorithm
, andx-sinch-webhook-signature
from the request headers. - Get Raw Body: Access the raw request body (ensured by
express.raw
). - Get Secret: Retrieve the
SINCH_WEBHOOK_SECRET
you defined in your.env
file. - Construct Signed Data: Concatenate the
rawBody
,nonce
, andtimestamp
strings, separated by a period (.
). - Calculate Signature: Use Node.js's built-in
crypto
module:- Create an HMAC object using the
sha256
algorithm and yourwebhookSecret
. - Update the HMAC object with the
signedData
. - Generate the digest in
base64
format.
- Create an HMAC object using the
- 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.
- Use
- 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.
5. Processing Callback Data
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 thebatch_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 isFAILED
, this field often contains an error code or description.event_time
: Timestamp of when the status event occurred.
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.
6. Storing Message Status (Optional but Recommended)
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):
// --- 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
}
7. Frontend Integration (React Example)
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:
// 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
:
/* 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;
}
8. Sending an SMS (Backend Endpoint)
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:
// 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 resultingbatch_id
(message ID) or an error to the frontend.
9. Error Handling & Logging
- 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.
- We use
- 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.
- The
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
orexpress-validator
). - Implement rate limiting on API endpoints to prevent abuse.
- Configure CORS securely for your production frontend domain.
10. Testing the Implementation
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
- Go to your Sinch Dashboard.
- 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).
- 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
(orHTTPS
if ngrok provides it). - Secret: Enter the exact same strong, random secret you put in your
server/.env
file forSINCH_WEBHOOK_SECRET
. - Triggers: Select
MESSAGE_DELIVERY
. This tells Sinch to send callbacks specifically for delivery status updates. You might find related triggers likeMESSAGE_INBOUND
orEVENT_DELIVERY
- ensureMESSAGE_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.
- Target URL: This is where
- Save the webhook configuration (you'll add the URL next).
Step 2: Run Backend and Frontend
-
Run Backend Server:
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
. -
Run Frontend Dev Server: Open another terminal window.
cd client npm run dev
Your React app should open in your browser (usually at
http://localhost:5173
).
Step 3: Use ngrok
- Open a third terminal window.
- Start ngrok, pointing it to your backend server's port (3001 in this case):
ngrok http 3001
- ngrok will display output including
Forwarding
URLs. Copy thehttps
URL (e.g.,https://random-string.ngrok-free.app
).
Step 4: Update Sinch Webhook URL
- Go back to the Sinch Dashboard where you configured the webhook.
- Edit the webhook.
- Paste the
https
ngrok URL into the Target URL field, appending your specific webhook path:https://random-string.ngrok-free.app/webhooks/sinch/delivery
- Save the webhook configuration again.
Step 5: Send an SMS and Watch for Callbacks
- Go to your running React app in the browser (
http://localhost:5173
). - Enter a valid recipient phone number (your own mobile is good for testing) and a message.
- Click Send SMS.
- 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.
- Get a Real Payload: Send a message and capture the raw body logged by your server when a valid callback arrives.
- 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. - Send
curl
Request:This is complex and mainly useful for isolated endpoint testing without relying on Sinch sending the callback.# 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""