Sinch SMS Delivery Status Callbacks in RedwoodJS: Complete Implementation Guide
Learn how to implement Sinch SMS delivery status callbacks in RedwoodJS. Step-by-step webhook setup, HMAC validation, Prisma database integration, and real-time SMS message tracking with TypeScript.
Integrate Sinch SMS delivery status callbacks into your RedwoodJS application to receive real-time updates on message delivery. Track message success, handle failures, and maintain accurate communication logs – transforming SMS from a "fire and forget" operation into a trackable, auditable communication channel.
Build a RedwoodJS API function that acts as a webhook endpoint to receive notifications from Sinch, then process and store these notifications in your database using Prisma.
What Are SMS Delivery Status Callbacks?
SMS delivery status callbacks (also called delivery receipts or delivery reports) are webhook notifications that SMS providers send to your application when message status changes. Unlike basic SMS sending where you only know the API accepted your request, delivery callbacks provide real-time updates throughout the message lifecycle:
Dispatched: Message sent from Sinch to carrier networks
Delivered: Message successfully received by recipient's device
Expired: Message not delivered within timeout period
Project Overview and Goals
What You'll Build:
A secure RedwoodJS API endpoint (/api/sinchCallbacks) to receive POST requests from Sinch
Database models using Prisma to store messages and delivery status updates
A RedwoodJS service to validate incoming callback data and persist it to the database
HMAC signature validation to ensure callbacks genuinely originate from Sinch
Problem Solved:
Sending an SMS via an API is often a "fire and forget" operation. You know the request was accepted, but you don't know if the message reached the recipient's handset. Delivery status callbacks solve this by pushing status updates (like Dispatched, Delivered, Failed) back to your application, giving you complete visibility into the message lifecycle.
Technologies Used:
RedwoodJS: Full-stack JavaScript/TypeScript framework with API functions, services, and integrated Prisma ORM
Sinch SMS API: Sends SMS messages and delivery status callbacks
Prisma: Next-generation ORM for Node.js and TypeScript, handling database modeling and access
Node.js: Runtime environment
ngrok (optional): For testing webhook callbacks locally during development
System Architecture:
Your RedwoodJS application sends an SMS message request (including a request for delivery reports) to the Sinch SMS API.
Sinch accepts the request and attempts to deliver the SMS via mobile networks to the recipient's phone.
As the delivery status changes (e.g., dispatched, delivered, failed), the Sinch callback service receives this information.
Sinch sends a POST request containing the status update to your configured RedwoodJS API function endpoint (/api/sinchCallbacks).
Your RedwoodJS API function validates the incoming request (HMAC signature), parses the data, and passes it to a RedwoodJS service.
The RedwoodJS service processes the status update and persists it to your database, linking it to the original message.
Prerequisites:
Node.js 20.x or later (LTS version recommended)
Yarn 1.22.21 or higher (managed via Corepack for Node.js 18+)
RedwoodJS project (latest version: 8.8.1 as of January 2025)
Sinch account with an active SMS Service Plan ID and API Token
Basic familiarity with RedwoodJS concepts (API functions, services, Prisma)
Database supported by Prisma (PostgreSQL, MySQL, or SQLite)
Expected Outcome:
Your RedwoodJS application will:
Receive delivery status updates for individual SMS recipients from Sinch
Securely validate incoming callbacks
Store status updates in your database, linked to the original message
1. Set Up the RedwoodJS Project
Create a new RedwoodJS project and configure the basic structure and environment.
1.1. Create RedwoodJS Project:
Bash
# Using Yarn
yarn create redwood-app ./sinch-redwood-callbacks
# Or using npm
npx create-redwood-app ./sinch-redwood-callbacks
This scaffolds a new RedwoodJS project in the sinch-redwood-callbacks directory.
1.2. Navigate to the Project Directory:
Bash
cd sinch-redwood-callbacks
1.3. Configure Environment Variables:
Create a .env file in the project root:
Bash
touch .env
Add the following variables:
.env
# .env
# --- Database ---
# Replace with your actual database connection string
# Example for PostgreSQL: DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
# Example for SQLite (default): DATABASE_URL="file:./dev.db"
DATABASE_URL="file:./dev.db"
# --- Sinch API Credentials ---
# Found on your Sinch Customer Dashboard under your API/Service Plan
SINCH_SERVICE_PLAN_ID="YOUR_SERVICE_PLAN_ID"
SINCH_API_TOKEN="YOUR_API_TOKEN"
# --- Sinch Webhook Security ---
# A secret string you create. Must match the secret configured in the Sinch Dashboard for the callback URL.
# Generate a strong random string (e.g., using `openssl rand -hex 32`, a password manager, or other secure generator)
SINCH_WEBHOOK_SECRET="YOUR_STRONG_RANDOM_SECRET_STRING"
# --- RedwoodJS Logger ---
# Optional: Set log level (trace, debug, info, warn, error, fatal)
LOG_LEVEL="info"
DATABASE_URL: Configure this to point to your database. For local development, use SQLite (file:./dev.db).
SINCH_SERVICE_PLAN_ID / SINCH_API_TOKEN: Find these in your Sinch Customer Dashboard under APIs > SMS APIs > [Your Service Plan Name].
SINCH_WEBHOOK_SECRET: Generate a strong, unique secret (at least 32 characters). Configure this same secret in the Sinch Dashboard later. Use openssl rand -hex 32 or any method that produces a cryptographically strong random string.
1.4. Initialize the Database:
Apply the initial Prisma schema and create the database file (if using SQLite):
Bash
yarn rw prisma migrate dev --name initial_setup
This initializes the database, creates the migration history table, and verifies your DATABASE_URL is configured correctly.
Test the endpoint using curl once deployed or running locally with ngrok:
Bash
# NOTE: Replace YOUR_ENDPOINT_URL with your actual ngrok or deployed URL# NOTE: This example does *not* include the HMAC signature yet.
curl -X POST \
YOUR_ENDPOINT_URL/api/sinchCallbacks \
-H 'Content-Type: application/json' \
-d '{
"type": "recipient_delivery_report_sms",
"batch_id": "TEST_BATCH_ID_123",
"recipient": "+15559998888",
"code": 0,
"status": "Delivered",
"at": "2025-04-20T10:00:00.000Z"
}'
You should see logs in your Redwood API server console (yarn rw dev api) and receive a 200 OK response.
4. Integrate with Sinch (Configuration)
Configure Sinch to send delivery reports to your RedwoodJS endpoint.
4.1. Obtain Your Webhook URL:
Local Development: Use ngrok to expose your local development server.
Start your Redwood dev server: yarn rw dev (API runs on port 8911)
Run ngrok: ngrok http 8911
Copy the https:// forwarding URL (e.g., https://<random-chars>.ngrok.io). Your callback URL: https://<random-chars>.ngrok.io/api/sinchCallbacks
Production/Staging: Use your deployed application URL (e.g., https://yourapp.yourdomain.com/api/sinchCallbacks)
4.2. Configure Callback URL in the Sinch Dashboard:
Log in to your Sinch Customer Dashboard
Navigate to APIs → SMS APIs
Select your Service Plan ID (matching SINCH_SERVICE_PLAN_ID in .env)
Find the Callback URLs section (typically under "Settings")
Click Add Callback URL or Edit
Enter your Callback URL from step 4.1
IMPORTANT: Find the Webhook Secret or HMAC Secret field and paste the exact same secret from your .env file (SINCH_WEBHOOK_SECRET)
Save the configuration
Note: The exact UI and field names may vary. Refer to the Sinch documentation for current instructions.
4.3. Request Delivery Reports When Sending SMS:
Sinch only sends callbacks if you request them when sending the message. Include the delivery_report parameter set to "per_recipient" in your request body.
Example: Sending SMS via Sinch REST API (Conceptual curl)
Bash
# Replace placeholders with your actual values
SINCH_SERVICE_PLAN_ID="YOUR_SERVICE_PLAN_ID"
SINCH_API_TOKEN="YOUR_API_TOKEN"
YOUR_SINCH_NUMBER_OR_ALPHANUMERIC="YOUR_SENDER_ID"
curl -X POST \
"https://us.sms.api.sinch.com/xms/v1/${SINCH_SERVICE_PLAN_ID}/batches" \
-H "Authorization: Bearer ${SINCH_API_TOKEN}" \
-H 'Content-Type: application/json' \
-d '{
"from": "'${YOUR_SINCH_NUMBER_OR_ALPHANUMERIC}'",
"to": [ "+15551234567" ],
"body": "Hello from RedwoodJS!",
"delivery_report": "per_recipient",
"client_reference": "optional_your_ref_123"
}'
Setting delivery_report to "per_recipient" tells Sinch to send a separate callback to your configured URL for status updates concerning each recipient in the batch. You can also use "per_recipient_final" to only get the final status (e.g., Delivered, Failed). "per_recipient" provides intermediate statuses too (like Dispatched).
Note: The logic for sending SMS messages like the example above typically resides within a RedwoodJS service function, possibly triggered by a mutation, background job, or another application event.
Environment Variable Summary:
SINCH_SERVICE_PLAN_ID: Identifies your Sinch service plan (obtained from Sinch Dashboard)
SINCH_API_TOKEN: Authenticates your API requests to Sinch for sending SMS (obtained from Sinch Dashboard)
SINCH_WEBHOOK_SECRET: Authenticates requests from Sinch to your webhook (you create this and configure it in both .env and Sinch Dashboard)
5. Implement Error Handling and Logging
Robust error handling and logging are essential for a reliable webhook.
5.1. Update the Handler Function:
Modify api/src/functions/sinchCallbacks.ts to include try...catch blocks and detailed logging:
TypeScript
// api/src/functions/sinchCallbacks.tsimporttype { APIGatewayEvent, Context } from'aws-lambda'import { logger } from'src/lib/logger'// Import validation function (we'll create this in the Security section)import { validateSinchSignature } from'src/lib/sinchSecurity'// Import the service function (we'll create this in the Data Layer section)import { recordSmsStatusUpdate } from'src/services/smsStatus/smsStatus'// Define an interface for the expected callback payloadinterfaceSinchRecipientDeliveryReport {
type: 'recipient_delivery_report_sms' | 'recipient_delivery_report_mms'// Focus on SMSbatch_id: stringrecipient: stringcode: numberstatus: stringat: string// ISO 8601 timestamp stringoperator_status_at?: stringclient_reference?: string
}
exportconsthandler = async (event: APIGatewayEvent, context: Context) => {
// Use a unique request ID for logging correlationconst requestId = context.awsRequestId || `local-${Date.now()}`const handlerLogger = logger.child({ requestId })
handlerLogger.info('Incoming request to sinchCallbacks function')
// 1. Basic Method Validationif (event.httpMethod !== 'POST') {
handlerLogger.warn('Received non-POST request')
return { statusCode: 405, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'Method Not Allowed' }) }
}
// 2. HMAC Signature Validationtry {
const rawBody = event.bodyif (rawBody === null || rawBody === undefined) {
handlerLogger.error('Raw request body is missing or null')
return { statusCode: 400, body: JSON.stringify({ error: 'Bad Request: Missing body' }) }
}
const webhookSecret = process.env.SINCH_WEBHOOK_SECRETif (!webhookSecret) {
handlerLogger.error('SINCH_WEBHOOK_SECRET environment variable is not set')
return { statusCode: 500, body: JSON.stringify({ error: 'Internal Server Error: Webhook secret not configured' }) }
}
const isValid = validateSinchSignature(
event.headers,
rawBody,
webhookSecret
)
if (!isValid) {
handlerLogger.error('Invalid Sinch signature')
return { statusCode: 401, body: JSON.stringify({ error: 'Unauthorized' }) }
}
handlerLogger.info('Sinch signature validated successfully')
} catch (error) {
handlerLogger.error({ error }, 'Error during signature validation')
return { statusCode: 400, body: JSON.stringify({ error: 'Signature validation failed' }) }
}
// 3. Parse Request Bodyletpayload: SinchRecipientDeliveryReporttry {
payload = JSON.parse(event.body!)
handlerLogger.info({ payloadType: payload.type }, 'Parsed request body')
// Basic payload type checkif (payload.type !== 'recipient_delivery_report_sms') {
handlerLogger.warn({ type: payload.type }, 'Received non-SMS recipient report type, ignoring')
return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'Callback type ignored' }) }
}
} catch (error) {
handlerLogger.error({ error, bodySnippet: event.body?.substring(0, 100) }, 'Failed to parse request body')
return { statusCode: 400, body: JSON.stringify({ error: 'Bad Request: Malformed JSON' }) }
}
// 4. Process Callback Data (Using Service)try {
awaitrecordSmsStatusUpdate({
batchId: payload.batch_id,
recipient: payload.recipient,
statusCode: payload.code,
status: payload.status,
statusTimestamp: newDate(payload.at),
clientReference: payload.client_reference,
rawPayload: event.body!,
})
handlerLogger.info({ batchId: payload.batch_id, recipient: payload.recipient, status: payload.status }, 'Successfully processed Sinch callback')
// 5. Return Success Response to Sinch// IMPORTANT: Always return 2xx to Sinch upon successful receipt,// even if your internal processing encounters a recoverable issue.// This prevents Sinch from retrying unnecessarily.return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Callback processed successfully' }),
}
} catch (error) {
handlerLogger.error({ error, batchId: payload.batch_id, recipient: payload.recipient }, 'Error processing Sinch callback in service')
// Even on internal error, acknowledge receipt to Sinchreturn {
statusCode: 200, // Acknowledge receipt to prevent Sinch retriesheaders: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Callback received but internal processing failed' }),
}
}
}
Key Improvements:
Type Safety: Defined SinchRecipientDeliveryReport interface
Logging: Using Redwood's logger with context (requestId), logging key events and errors
Structured Error Handling:try...catch blocks for signature validation, parsing, and service calls
Sinch Acknowledgement: Consistently returning 200 OK once validated and parsed, even if downstream processing fails (prevents Sinch retries)
6. Create the Database Schema and Data Layer
Create database tables to store messages and status updates.
6.1. Define Prisma Schema:
Open api/db/schema.prisma and define the models:
Prisma
// api/db/schema.prisma
datasource db {
provider = "sqlite" // Or "postgresql", "mysql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = "native"
}
// Model representing an SMS message sent via Sinch
model Message {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
to String // Recipient phone number. Store in E.164 format to match Sinch callbacks.
body String?
sentAt DateTime @default(now())
sinchBatchId String // The Batch ID returned by Sinch
clientReference String?
// Track the latest known status
latestStatus String?
latestStatusCode Int?
latestStatusTimestamp DateTime?
// Relation to status updates
statusUpdates SmsStatusUpdate[]
@@index([sinchBatchId])
@@index([clientReference])
@@index([to])
}
// Model representing a delivery status update received from Sinch
model SmsStatusUpdate {
id String @id @default(cuid())
createdAt DateTime @default(now())
messageId String
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
sinchBatchId String
recipient String
status String // e.g., "Dispatched", "Delivered", "Failed"
statusCode Int // e.g., 401, 0, 402 (Sinch status codes)
statusTimestamp DateTime
clientReference String?
rawPayload String // Store the full JSON payload for auditing
@@index([messageId])
@@index([sinchBatchId, recipient])
@@index([statusTimestamp])
}
Key Decisions:
Message Model: Represents messages your application sent, includes sinchBatchId and clientReference to link back to Sinch, and tracks latest status
SmsStatusUpdate Model: Represents callbacks from Sinch, linked to Message via messageId, stores key fields and rawPayload
Indexes: Optimize querying by sinchBatchId, recipient, and messageId
onDelete: Cascade: Deletes related status updates when a message is deleted
6.2. Apply Database Migrations:
Apply schema changes to your database:
Bash
yarn rw prisma migrate dev --name add_message_and_status_tables
Answer y if prompted to confirm.
6.3. Generate the Data Layer (Service):
Create a RedwoodJS service:
Bash
yarn rw g service smsStatus --ts
This creates api/src/services/smsStatus/smsStatus.ts.
6.4. Implement the Service Logic:
Open api/src/services/smsStatus/smsStatus.ts and add the recordSmsStatusUpdate function:
TypeScript
// api/src/services/smsStatus/smsStatus.tsimporttype { Prisma } from'@prisma/client'import { db } from'src/lib/db'import { logger } from'src/lib/logger'interfaceRecordSmsStatusInput {
batchId: stringrecipient: stringstatusCode: numberstatus: stringstatusTimestamp: DateclientReference?: stringrawPayload: string
}
exportconst recordSmsStatusUpdate = async ({
batchId,
recipient,
statusCode,
status,
statusTimestamp,
clientReference,
rawPayload,
}: RecordSmsStatusInput): Promise<void> => {
const serviceLogger = logger.child({
batchId,
recipient,
status,
functionName: 'recordSmsStatusUpdate',
})
serviceLogger.info('Attempting to record SMS status update')
// 1. Find the original message// CRITICAL: Store recipient in Message.to in E.164 format to match Sinch callbacks.// Mismatched formats will prevent finding the message.const message = await db.message.findFirst({
where: {
sinchBatchId: batchId,
to: recipient, // Must match format (E.164 recommended)
},
select: { id: true, latestStatusTimestamp: true },
})
if (!message) {
serviceLogger.error(
{ clientReference },
'Could not find original message for this status update. Verify recipient format (E.164) and that the message was stored.'
)
thrownewError(
`Original message not found for batchId: ${batchId}, recipient: ${recipient}. Verify recipient format.`
)
}
serviceLogger.info({ messageId: message.id }, 'Found corresponding message')
// 2. Create the SmsStatusUpdate recordtry {
constcreateData: Prisma.SmsStatusUpdateCreateInput = {
message: { connect: { id: message.id } },
sinchBatchId: batchId,
recipient: recipient,
status: status,
statusCode: statusCode,
statusTimestamp: statusTimestamp,
clientReference: clientReference,
rawPayload: rawPayload,
}
// Idempotency Check: Avoid duplicates if Sinch retriesconst existingUpdate = await db.smsStatusUpdate.findFirst({
where: {
messageId: message.id,
recipient: recipient,
status: status,
statusCode: statusCode,
},
select: { id: true }
});
if (existingUpdate) {
serviceLogger.warn({ existingUpdateId: existingUpdate.id }, 'Duplicate status update received, ignoring')
return;
}
const newStatusUpdate = await db.smsStatusUpdate.create({ data: createData })
serviceLogger.info(
{ statusUpdateId: newStatusUpdate.id },
'Created new SmsStatusUpdate record'
)
// 3. Update the latest status on the parent Message (if newer)if (
!message.latestStatusTimestamp ||
statusTimestamp > message.latestStatusTimestamp
) {
await db.message.update({
where: { id: message.id },
data: {
latestStatus: status,
latestStatusCode: statusCode,
latestStatusTimestamp: statusTimestamp,
},
})
serviceLogger.info({ messageId: message.id }, 'Updated latest status on Message record')
} else {
serviceLogger.info(
{ messageId: message.id, newStatusTimestamp: statusTimestamp, latestStatusTimestamp: message.latestStatusTimestamp },
'Received older or same-timestamp status update, not updating latest status'
)
}
} catch (error) {
serviceLogger.error({ error }, 'Database error while recording status update')
throw error
}
}
Key Service Logic:
Find Message: Locates the Message using sinchBatchId and recipient (requires matching formats – E.164 recommended)
Idempotency: Prevents duplicate SmsStatusUpdate records if Sinch retries
Create Status Record: Creates new SmsStatusUpdate linked to the Message
Update Latest Status: Updates latestStatus on Message if incoming status is newer
Error Handling: Catches and logs database errors
Ensure recordSmsStatusUpdate is imported in api/src/functions/sinchCallbacks.ts (as shown in Section 5.1).
7. Add Security Features (HMAC Validation)
Secure your webhook endpoint to ensure requests genuinely come from Sinch using HMAC-SHA256 signature validation.
7.1. Create a Security Utility:
Create api/src/lib/sinchSecurity.ts:
TypeScript
// api/src/lib/sinchSecurity.tsimport crypto from'crypto'import { logger } from'src/lib/logger'importtype { APIGatewayProxyEventHeaders } from'aws-lambda'/**
* Find a header value case-insensitively
*/const findHeader = (headers: APIGatewayProxyEventHeaders, headerName: string): string | undefined => {
const lowerCaseHeaderName = headerName.toLowerCase();
const headerKey = Object.keys(headers).find(key => key.toLowerCase() === lowerCaseHeaderName);
return headerKey ? headers[headerKey] : undefined;
}
/**
* Validate the Sinch webhook signature using HMAC-SHA256
*
* @paramheaders - Request headers (case-insensitive lookup)
* @paramrawBody - Raw, unparsed request body string (exact bytes received)
* @paramsecret - Your Sinch Webhook Secret from environment variables
* @returns true if signature is valid, false otherwise
* @throws Error if required headers are missing or secret is invalid
*/exportconst validateSinchSignature = (
headers: APIGatewayProxyEventHeaders,
rawBody: string,
secret: string | undefined
): boolean => {
const securityLogger = logger.child({ functionName: 'validateSinchSignature' })
if (!secret) {
securityLogger.error('Webhook secret is not provided or empty')
thrownewError('Webhook secret is missing or invalid');
}
// 1. Extract Sinch headersconst sinchTimestamp = findHeader(headers, 'x-sinch-timestamp')
const sinchSignatureHeader = findHeader(headers, 'x-sinch-signature')
if (!sinchTimestamp) {
securityLogger.warn('Missing x-sinch-timestamp header')
returnfalse
}
if (!sinchSignatureHeader) {
securityLogger.warn('Missing x-sinch-signature header')
returnfalse
}
// 2. Prepare the signed content string// Sinch typically signs: raw request body + timestamp header value// IMPORTANT: Verify this format against current Sinch documentationconst stringToSign = rawBody + sinchTimestamp;
securityLogger.debug({ stringToSignLength: stringToSign.length }, 'Constructed string-to-sign')
// 3. Decode the secret (Sinch secrets are Base64 encoded)letdecodedSecret: Buffer;
try {
decodedSecret = Buffer.from(secret, 'base64');
} catch (e) {
securityLogger.error({ error: e }, 'Failed to decode webhook secret from Base64')
thrownewError('Invalid webhook secret encoding');
}
// 4. Calculate the expected signatureconst hmac = crypto.createHmac('sha256', decodedSecret);
const expectedSignature = hmac.update(stringToSign).digest('base64');
securityLogger.debug('Calculated expected signature');
// 5. Compare signatures securelyconst providedSignature = sinchSignatureHeader;
try {
const providedSigBuffer = Buffer.from(providedSignature, 'base64');
const expectedSigBuffer = Buffer.from(expectedSignature, 'base64');
if (providedSigBuffer.length !== expectedSigBuffer.length) {
securityLogger.warn('Signature length mismatch');
returnfalse;
}
const isValid = crypto.timingSafeEqual(providedSigBuffer, expectedSigBuffer);
securityLogger.info(`Signature validation result: ${isValid}`);
return isValid;
} catch (error) {
securityLogger.error({ error }, 'Error during signature comparison (possibly invalid Base64 format)');
returnfalse;
}
}
Key Security Logic:
Header Extraction: Safely extracts x-sinch-timestamp and x-sinch-signature (case-insensitive)
Secret Handling: Validates and decodes secret from Base64
String-to-Sign Construction: Creates the string Sinch signed (raw body + timestamp). Note: Verify format against current Sinch documentation
Secure Comparison: Uses crypto.timingSafeEqual to prevent timing attacks
Logging: Includes debugging for signature validation issues
7.2. Integrate Validation into the Handler:
The validateSinchSignature function is already integrated in the handler code from Section 5.1. Ensure raw body and headers are passed correctly.
8. Testing and Debugging Your SMS Webhook
Before deploying to production, thoroughly test your webhook implementation locally and in staging environments.
8.1. Local Testing with ngrok:
Start your RedwoodJS development server:
Bash
yarn rw dev
In a separate terminal, start ngrok:
Bash
ngrok http 8911
Copy the ngrok HTTPS URL and configure it in your Sinch Dashboard
Send a test SMS through Sinch with delivery_report: "per_recipient"
Monitor your local console for webhook callbacks and logs
8.2. Common Debugging Issues:
401 Unauthorized Errors: HMAC signature mismatch. Verify your SINCH_WEBHOOK_SECRET matches exactly in both .env and Sinch Dashboard (including Base64 encoding)
Message Not Found Errors: Phone number format mismatch. Ensure you store recipient numbers in E.164 format (e.g., +15551234567)
Duplicate Status Updates: Normal behavior if Sinch retries. The idempotency check in the service prevents duplicate database records
Missing Callbacks: Verify you included delivery_report: "per_recipient" when sending the SMS
8.3. Monitoring Production Webhooks:
Review logs regularly using your RedwoodJS logger output
Set up alerts for repeated 401 or 500 errors
Track delivery rates and failure patterns in your database
Consider implementing a dashboard to visualize message delivery statistics
Q: What's the difference between per_recipient and per_recipient_final delivery reports?
A: per_recipient sends callbacks for every status change (Dispatched, Delivered, Failed), while per_recipient_final only sends the final status once delivery completes or fails. Use per_recipient for detailed tracking and per_recipient_final to reduce webhook traffic.
Q: How long should I wait before considering a message failed?
A: Sinch typically delivers status updates within minutes, but carrier delays can extend this to hours. Messages that remain in "Dispatched" status for 24-48 hours are likely failed. Sinch will send a final "Expired" status if delivery times out.
Q: Can I use this webhook handler for MMS delivery reports?
A: Yes, with minor modifications. Change the type check to include recipient_delivery_report_mms and update your database schema to track MMS-specific fields if needed.
Q: Should I retry failed messages automatically?
A: It depends on the failure reason. Check the statusCode in the callback – some codes indicate permanent failures (invalid number), while others may be temporary (network issues). Implement retry logic with exponential backoff for temporary failures only.
Q: How do I handle webhooks during local development?
A: Use ngrok (as shown in section 4.1) to create a public HTTPS URL that forwards to your local development server. Update the callback URL in Sinch Dashboard to your ngrok URL while developing, then switch to your production URL when deploying.
Frequently asked questions
Create a RedwoodJS API function as a webhook endpoint, set up database models with Prisma to store message delivery data, and use a service to process incoming Sinch notifications. This allows your application to receive real-time delivery status updates for SMS messages sent via the Sinch API and store them persistently.
Sinch delivery reports provide real-time updates on the status of sent SMS messages, moving beyond "fire and forget" API calls. Knowing whether a message was dispatched, delivered, or failed allows for better handling of communication failures and logging.
HMAC signature validation ensures the authenticity of incoming webhooks, confirming they originate from Sinch and haven't been tampered with. This adds a crucial layer of security to your application by verifying the integrity of the callback data.
Delivery reports must be requested when you send the initial SMS message via the Sinch API. Include the 'delivery_report' parameter set to 'per_recipient' in the API request body to receive updates for each recipient.
The RedwoodJS API endpoint for Sinch callbacks is typically '/api/sinchCallbacks'. This endpoint, created as a RedwoodJS function, receives POST requests from Sinch containing delivery status updates.
Create a '.env' file in your RedwoodJS project root. Add your 'DATABASE_URL', 'SINCH_SERVICE_PLAN_ID', 'SINCH_API_TOKEN', and a randomly generated 'SINCH_WEBHOOK_SECRET', which will also be configured in your Sinch dashboard for security.
The integration uses RedwoodJS for the API and Prisma ORM, the Sinch SMS API for messaging, Prisma for database interactions, and Node.js as the runtime environment. Ngrok is optional for local testing.
Use ngrok to create a public URL for your local server. Then, use curl to send POST requests with sample JSON payloads to your ngrok URL, simulating Sinch callbacks. You should see logs and receive a 200 OK response.
A Sinch SMS callback includes the message type, batch ID, recipient number, status code, delivery status (e.g., 'Dispatched', 'Delivered'), timestamps, and an optional client reference.
Log in to your Sinch Dashboard, navigate to APIs -> SMS APIs, and select your Service Plan ID. Under Callback URLs, add your ngrok URL or production URL. Importantly, add your Webhook Secret to secure the connection.
The Prisma schema includes a 'Message' model to store sent messages and an 'SmsStatusUpdate' model to store the delivery status updates received from Sinch. These models are linked, and indices are included for efficient querying.
The RedwoodJS service handles the logic for processing Sinch callback data, including finding the original message, creating the status update record, and optionally updating the latest status on the message. It also contains error handling and logging.
Implement try-catch blocks for signature validation, parsing, and service calls. Provide detailed logging with request IDs and context for debugging and return appropriate HTTP status codes. Ensure a 200 OK is sent to Sinch even if internal processes fail, to avoid retries.
Integrate Sinch SMS delivery status callbacks into your RedwoodJS application to receive real-time updates on message delivery. Track message success, handle failures, and maintain accurate communication logs – transforming SMS from a "fire and forget" operation into a trackable, auditable communication channel.
Build a RedwoodJS API function that acts as a webhook endpoint to receive notifications from Sinch, then process and store these notifications in your database using Prisma.
What Are SMS Delivery Status Callbacks?
SMS delivery status callbacks (also called delivery receipts or delivery reports) are webhook notifications that SMS providers send to your application when message status changes. Unlike basic SMS sending where you only know the API accepted your request, delivery callbacks provide real-time updates throughout the message lifecycle:
Project Overview and Goals
What You'll Build:
/api/sinchCallbacks) to receive POST requests from SinchProblem Solved:
Sending an SMS via an API is often a "fire and forget" operation. You know the request was accepted, but you don't know if the message reached the recipient's handset. Delivery status callbacks solve this by pushing status updates (like
Dispatched,Delivered,Failed) back to your application, giving you complete visibility into the message lifecycle.Technologies Used:
ngrok(optional): For testing webhook callbacks locally during developmentSystem Architecture:
/api/sinchCallbacks).Prerequisites:
Expected Outcome:
Your RedwoodJS application will:
1. Set Up the RedwoodJS Project
Create a new RedwoodJS project and configure the basic structure and environment.
1.1. Create RedwoodJS Project:
# Using Yarn yarn create redwood-app ./sinch-redwood-callbacks # Or using npm npx create-redwood-app ./sinch-redwood-callbacksThis scaffolds a new RedwoodJS project in the
sinch-redwood-callbacksdirectory.1.2. Navigate to the Project Directory:
cd sinch-redwood-callbacks1.3. Configure Environment Variables:
Create a
.envfile in the project root:touch .envAdd the following variables:
DATABASE_URL: Configure this to point to your database. For local development, use SQLite (file:./dev.db).SINCH_SERVICE_PLAN_ID/SINCH_API_TOKEN: Find these in your Sinch Customer Dashboard under APIs > SMS APIs > [Your Service Plan Name].SINCH_WEBHOOK_SECRET: Generate a strong, unique secret (at least 32 characters). Configure this same secret in the Sinch Dashboard later. Useopenssl rand -hex 32or any method that produces a cryptographically strong random string.1.4. Initialize the Database:
Apply the initial Prisma schema and create the database file (if using SQLite):
This initializes the database, creates the migration history table, and verifies your
DATABASE_URLis configured correctly.2. Implement Core Functionality (Callback Handler)
Create an API endpoint (a RedwoodJS function) to receive callbacks from Sinch.
2.1. Generate the API Function:
Generate a new TypeScript function for type safety:
yarn rw g function sinchCallbacks --tsThis creates
api/src/functions/sinchCallbacks.ts.2.2. Basic Handler Structure:
Open
api/src/functions/sinchCallbacks.ts:// api/src/functions/sinchCallbacks.ts import type { APIGatewayEvent, Context } from 'aws-lambda' import { logger } from 'src/lib/logger' /** * The handler function is your code that processes http request events. * You already have access to the logger and use it to log any errors. * * @typedef { import('aws-lambda').APIGatewayEvent } APIGatewayEvent * @typedef { import('aws-lambda').Context } Context * @param { APIGatewayEvent } event - an object which contains information from the invoker. * @param { Context } context - contains information about the invocation, * function, and execution environment. */ export const handler = async (event: APIGatewayEvent, context: Context) => { logger.info('Incoming request to sinchCallbacks function') // Basic validation: Ensure it's a POST request if (event.httpMethod !== 'POST') { logger.warn('Received non-POST request') return { statusCode: 405, // Method Not Allowed headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'Method Not Allowed' }), } } // TODO: Add HMAC Signature Validation // TODO: Parse Request Body // TODO: Process Callback Data (using a Service) // TODO: Return appropriate response to Sinch return { statusCode: 200, // Placeholder: Acknowledge receipt headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'Callback received' }), } }This basic structure checks if the request method is POST. You'll add more logic in subsequent steps.
3. Build the API Layer
API functions automatically map to endpoints. The
api/src/functions/sinchCallbacks.tsfunction is accessible at/api/sinchCallbacks.3.1. Endpoint Definition:
/api/sinchCallbacks(relative to your application's base URL)POST200 OK: Callback received and successfully processed (or acknowledged even if internal processing fails, to prevent Sinch retries)400 Bad Request: Invalid request body (e.g., malformed JSON)401 Unauthorized: Invalid or missing HMAC signature405 Method Not Allowed: Request method was not POST500 Internal Server Error: Unexpected server-side error during processing3.2. Example Sinch Callback Payload:
Sinch sends a POST request with a JSON body like this when you configure
delivery_reportasper_recipient:{ "type": "recipient_delivery_report_sms", "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", "recipient": "+15551231234", "code": 401, "status": "Dispatched", "at": "2022-08-30T08:16:08.930Z", "operator_status_at": "2022-08-30T08:16:08.900Z", "client_reference": "your_optional_reference_123" }3.3. Test the Endpoint Locally:
Test the endpoint using
curlonce deployed or running locally withngrok:# NOTE: Replace YOUR_ENDPOINT_URL with your actual ngrok or deployed URL # NOTE: This example does *not* include the HMAC signature yet. curl -X POST \ YOUR_ENDPOINT_URL/api/sinchCallbacks \ -H 'Content-Type: application/json' \ -d '{ "type": "recipient_delivery_report_sms", "batch_id": "TEST_BATCH_ID_123", "recipient": "+15559998888", "code": 0, "status": "Delivered", "at": "2025-04-20T10:00:00.000Z" }'You should see logs in your Redwood API server console (
yarn rw dev api) and receive a200 OKresponse.4. Integrate with Sinch (Configuration)
Configure Sinch to send delivery reports to your RedwoodJS endpoint.
4.1. Obtain Your Webhook URL:
ngrokto expose your local development server.yarn rw dev(API runs on port 8911)ngrok http 8911https://forwarding URL (e.g.,https://<random-chars>.ngrok.io). Your callback URL:https://<random-chars>.ngrok.io/api/sinchCallbackshttps://yourapp.yourdomain.com/api/sinchCallbacks)4.2. Configure Callback URL in the Sinch Dashboard:
SINCH_SERVICE_PLAN_IDin.env).envfile (SINCH_WEBHOOK_SECRET)Note: The exact UI and field names may vary. Refer to the Sinch documentation for current instructions.
4.3. Request Delivery Reports When Sending SMS:
Sinch only sends callbacks if you request them when sending the message. Include the
delivery_reportparameter set to"per_recipient"in your request body.Example: Sending SMS via Sinch REST API (Conceptual
curl)# Replace placeholders with your actual values SINCH_SERVICE_PLAN_ID="YOUR_SERVICE_PLAN_ID" SINCH_API_TOKEN="YOUR_API_TOKEN" YOUR_SINCH_NUMBER_OR_ALPHANUMERIC="YOUR_SENDER_ID" curl -X POST \ "https://us.sms.api.sinch.com/xms/v1/${SINCH_SERVICE_PLAN_ID}/batches" \ -H "Authorization: Bearer ${SINCH_API_TOKEN}" \ -H 'Content-Type: application/json' \ -d '{ "from": "'${YOUR_SINCH_NUMBER_OR_ALPHANUMERIC}'", "to": [ "+15551234567" ], "body": "Hello from RedwoodJS!", "delivery_report": "per_recipient", "client_reference": "optional_your_ref_123" }'Setting
delivery_reportto"per_recipient"tells Sinch to send a separate callback to your configured URL for status updates concerning each recipient in the batch. You can also use"per_recipient_final"to only get the final status (e.g.,Delivered,Failed)."per_recipient"provides intermediate statuses too (likeDispatched).Note: The logic for sending SMS messages like the example above typically resides within a RedwoodJS service function, possibly triggered by a mutation, background job, or another application event.
Environment Variable Summary:
SINCH_SERVICE_PLAN_ID: Identifies your Sinch service plan (obtained from Sinch Dashboard)SINCH_API_TOKEN: Authenticates your API requests to Sinch for sending SMS (obtained from Sinch Dashboard)SINCH_WEBHOOK_SECRET: Authenticates requests from Sinch to your webhook (you create this and configure it in both.envand Sinch Dashboard)5. Implement Error Handling and Logging
Robust error handling and logging are essential for a reliable webhook.
5.1. Update the Handler Function:
Modify
api/src/functions/sinchCallbacks.tsto includetry...catchblocks and detailed logging:// api/src/functions/sinchCallbacks.ts import type { APIGatewayEvent, Context } from 'aws-lambda' import { logger } from 'src/lib/logger' // Import validation function (we'll create this in the Security section) import { validateSinchSignature } from 'src/lib/sinchSecurity' // Import the service function (we'll create this in the Data Layer section) import { recordSmsStatusUpdate } from 'src/services/smsStatus/smsStatus' // Define an interface for the expected callback payload interface SinchRecipientDeliveryReport { type: 'recipient_delivery_report_sms' | 'recipient_delivery_report_mms' // Focus on SMS batch_id: string recipient: string code: number status: string at: string // ISO 8601 timestamp string operator_status_at?: string client_reference?: string } export const handler = async (event: APIGatewayEvent, context: Context) => { // Use a unique request ID for logging correlation const requestId = context.awsRequestId || `local-${Date.now()}` const handlerLogger = logger.child({ requestId }) handlerLogger.info('Incoming request to sinchCallbacks function') // 1. Basic Method Validation if (event.httpMethod !== 'POST') { handlerLogger.warn('Received non-POST request') return { statusCode: 405, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'Method Not Allowed' }) } } // 2. HMAC Signature Validation try { const rawBody = event.body if (rawBody === null || rawBody === undefined) { handlerLogger.error('Raw request body is missing or null') return { statusCode: 400, body: JSON.stringify({ error: 'Bad Request: Missing body' }) } } const webhookSecret = process.env.SINCH_WEBHOOK_SECRET if (!webhookSecret) { handlerLogger.error('SINCH_WEBHOOK_SECRET environment variable is not set') return { statusCode: 500, body: JSON.stringify({ error: 'Internal Server Error: Webhook secret not configured' }) } } const isValid = validateSinchSignature( event.headers, rawBody, webhookSecret ) if (!isValid) { handlerLogger.error('Invalid Sinch signature') return { statusCode: 401, body: JSON.stringify({ error: 'Unauthorized' }) } } handlerLogger.info('Sinch signature validated successfully') } catch (error) { handlerLogger.error({ error }, 'Error during signature validation') return { statusCode: 400, body: JSON.stringify({ error: 'Signature validation failed' }) } } // 3. Parse Request Body let payload: SinchRecipientDeliveryReport try { payload = JSON.parse(event.body!) handlerLogger.info({ payloadType: payload.type }, 'Parsed request body') // Basic payload type check if (payload.type !== 'recipient_delivery_report_sms') { handlerLogger.warn({ type: payload.type }, 'Received non-SMS recipient report type, ignoring') return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'Callback type ignored' }) } } } catch (error) { handlerLogger.error({ error, bodySnippet: event.body?.substring(0, 100) }, 'Failed to parse request body') return { statusCode: 400, body: JSON.stringify({ error: 'Bad Request: Malformed JSON' }) } } // 4. Process Callback Data (Using Service) try { await recordSmsStatusUpdate({ batchId: payload.batch_id, recipient: payload.recipient, statusCode: payload.code, status: payload.status, statusTimestamp: new Date(payload.at), clientReference: payload.client_reference, rawPayload: event.body!, }) handlerLogger.info({ batchId: payload.batch_id, recipient: payload.recipient, status: payload.status }, 'Successfully processed Sinch callback') // 5. Return Success Response to Sinch // IMPORTANT: Always return 2xx to Sinch upon successful receipt, // even if your internal processing encounters a recoverable issue. // This prevents Sinch from retrying unnecessarily. return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'Callback processed successfully' }), } } catch (error) { handlerLogger.error({ error, batchId: payload.batch_id, recipient: payload.recipient }, 'Error processing Sinch callback in service') // Even on internal error, acknowledge receipt to Sinch return { statusCode: 200, // Acknowledge receipt to prevent Sinch retries headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'Callback received but internal processing failed' }), } } }Key Improvements:
SinchRecipientDeliveryReportinterfaceloggerwith context (requestId), logging key events and errorstry...catchblocks for signature validation, parsing, and service calls405,401,400,500,200)200 OKonce validated and parsed, even if downstream processing fails (prevents Sinch retries)6. Create the Database Schema and Data Layer
Create database tables to store messages and status updates.
6.1. Define Prisma Schema:
Open
api/db/schema.prismaand define the models:Key Decisions:
MessageModel: Represents messages your application sent, includessinchBatchIdandclientReferenceto link back to Sinch, and tracks latest statusSmsStatusUpdateModel: Represents callbacks from Sinch, linked toMessageviamessageId, stores key fields andrawPayloadsinchBatchId,recipient, andmessageIdonDelete: Cascade: Deletes related status updates when a message is deleted6.2. Apply Database Migrations:
Apply schema changes to your database:
Answer
yif prompted to confirm.6.3. Generate the Data Layer (Service):
Create a RedwoodJS service:
This creates
api/src/services/smsStatus/smsStatus.ts.6.4. Implement the Service Logic:
Open
api/src/services/smsStatus/smsStatus.tsand add therecordSmsStatusUpdatefunction:// api/src/services/smsStatus/smsStatus.ts import type { Prisma } from '@prisma/client' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' interface RecordSmsStatusInput { batchId: string recipient: string statusCode: number status: string statusTimestamp: Date clientReference?: string rawPayload: string } export const recordSmsStatusUpdate = async ({ batchId, recipient, statusCode, status, statusTimestamp, clientReference, rawPayload, }: RecordSmsStatusInput): Promise<void> => { const serviceLogger = logger.child({ batchId, recipient, status, functionName: 'recordSmsStatusUpdate', }) serviceLogger.info('Attempting to record SMS status update') // 1. Find the original message // CRITICAL: Store recipient in Message.to in E.164 format to match Sinch callbacks. // Mismatched formats will prevent finding the message. const message = await db.message.findFirst({ where: { sinchBatchId: batchId, to: recipient, // Must match format (E.164 recommended) }, select: { id: true, latestStatusTimestamp: true }, }) if (!message) { serviceLogger.error( { clientReference }, 'Could not find original message for this status update. Verify recipient format (E.164) and that the message was stored.' ) throw new Error( `Original message not found for batchId: ${batchId}, recipient: ${recipient}. Verify recipient format.` ) } serviceLogger.info({ messageId: message.id }, 'Found corresponding message') // 2. Create the SmsStatusUpdate record try { const createData: Prisma.SmsStatusUpdateCreateInput = { message: { connect: { id: message.id } }, sinchBatchId: batchId, recipient: recipient, status: status, statusCode: statusCode, statusTimestamp: statusTimestamp, clientReference: clientReference, rawPayload: rawPayload, } // Idempotency Check: Avoid duplicates if Sinch retries const existingUpdate = await db.smsStatusUpdate.findFirst({ where: { messageId: message.id, recipient: recipient, status: status, statusCode: statusCode, }, select: { id: true } }); if (existingUpdate) { serviceLogger.warn({ existingUpdateId: existingUpdate.id }, 'Duplicate status update received, ignoring') return; } const newStatusUpdate = await db.smsStatusUpdate.create({ data: createData }) serviceLogger.info( { statusUpdateId: newStatusUpdate.id }, 'Created new SmsStatusUpdate record' ) // 3. Update the latest status on the parent Message (if newer) if ( !message.latestStatusTimestamp || statusTimestamp > message.latestStatusTimestamp ) { await db.message.update({ where: { id: message.id }, data: { latestStatus: status, latestStatusCode: statusCode, latestStatusTimestamp: statusTimestamp, }, }) serviceLogger.info({ messageId: message.id }, 'Updated latest status on Message record') } else { serviceLogger.info( { messageId: message.id, newStatusTimestamp: statusTimestamp, latestStatusTimestamp: message.latestStatusTimestamp }, 'Received older or same-timestamp status update, not updating latest status' ) } } catch (error) { serviceLogger.error({ error }, 'Database error while recording status update') throw error } }Key Service Logic:
MessageusingsinchBatchIdandrecipient(requires matching formats – E.164 recommended)SmsStatusUpdaterecords if Sinch retriesSmsStatusUpdatelinked to theMessagelatestStatusonMessageif incoming status is newerEnsure
recordSmsStatusUpdateis imported inapi/src/functions/sinchCallbacks.ts(as shown in Section 5.1).7. Add Security Features (HMAC Validation)
Secure your webhook endpoint to ensure requests genuinely come from Sinch using HMAC-SHA256 signature validation.
7.1. Create a Security Utility:
Create
api/src/lib/sinchSecurity.ts:// api/src/lib/sinchSecurity.ts import crypto from 'crypto' import { logger } from 'src/lib/logger' import type { APIGatewayProxyEventHeaders } from 'aws-lambda' /** * Find a header value case-insensitively */ const findHeader = (headers: APIGatewayProxyEventHeaders, headerName: string): string | undefined => { const lowerCaseHeaderName = headerName.toLowerCase(); const headerKey = Object.keys(headers).find(key => key.toLowerCase() === lowerCaseHeaderName); return headerKey ? headers[headerKey] : undefined; } /** * Validate the Sinch webhook signature using HMAC-SHA256 * * @param headers - Request headers (case-insensitive lookup) * @param rawBody - Raw, unparsed request body string (exact bytes received) * @param secret - Your Sinch Webhook Secret from environment variables * @returns true if signature is valid, false otherwise * @throws Error if required headers are missing or secret is invalid */ export const validateSinchSignature = ( headers: APIGatewayProxyEventHeaders, rawBody: string, secret: string | undefined ): boolean => { const securityLogger = logger.child({ functionName: 'validateSinchSignature' }) if (!secret) { securityLogger.error('Webhook secret is not provided or empty') throw new Error('Webhook secret is missing or invalid'); } // 1. Extract Sinch headers const sinchTimestamp = findHeader(headers, 'x-sinch-timestamp') const sinchSignatureHeader = findHeader(headers, 'x-sinch-signature') if (!sinchTimestamp) { securityLogger.warn('Missing x-sinch-timestamp header') return false } if (!sinchSignatureHeader) { securityLogger.warn('Missing x-sinch-signature header') return false } // 2. Prepare the signed content string // Sinch typically signs: raw request body + timestamp header value // IMPORTANT: Verify this format against current Sinch documentation const stringToSign = rawBody + sinchTimestamp; securityLogger.debug({ stringToSignLength: stringToSign.length }, 'Constructed string-to-sign') // 3. Decode the secret (Sinch secrets are Base64 encoded) let decodedSecret: Buffer; try { decodedSecret = Buffer.from(secret, 'base64'); } catch (e) { securityLogger.error({ error: e }, 'Failed to decode webhook secret from Base64') throw new Error('Invalid webhook secret encoding'); } // 4. Calculate the expected signature const hmac = crypto.createHmac('sha256', decodedSecret); const expectedSignature = hmac.update(stringToSign).digest('base64'); securityLogger.debug('Calculated expected signature'); // 5. Compare signatures securely const providedSignature = sinchSignatureHeader; try { const providedSigBuffer = Buffer.from(providedSignature, 'base64'); const expectedSigBuffer = Buffer.from(expectedSignature, 'base64'); if (providedSigBuffer.length !== expectedSigBuffer.length) { securityLogger.warn('Signature length mismatch'); return false; } const isValid = crypto.timingSafeEqual(providedSigBuffer, expectedSigBuffer); securityLogger.info(`Signature validation result: ${isValid}`); return isValid; } catch (error) { securityLogger.error({ error }, 'Error during signature comparison (possibly invalid Base64 format)'); return false; } }Key Security Logic:
x-sinch-timestampandx-sinch-signature(case-insensitive)crypto.timingSafeEqualto prevent timing attacks7.2. Integrate Validation into the Handler:
The
validateSinchSignaturefunction is already integrated in the handler code from Section 5.1. Ensure raw body and headers are passed correctly.8. Testing and Debugging Your SMS Webhook
Before deploying to production, thoroughly test your webhook implementation locally and in staging environments.
8.1. Local Testing with ngrok:
delivery_report: "per_recipient"8.2. Common Debugging Issues:
SINCH_WEBHOOK_SECRETmatches exactly in both.envand Sinch Dashboard (including Base64 encoding)+15551234567)delivery_report: "per_recipient"when sending the SMS8.3. Monitoring Production Webhooks:
Related Resources
Frequently Asked Questions
Q: What's the difference between
per_recipientandper_recipient_finaldelivery reports?A:
per_recipientsends callbacks for every status change (Dispatched, Delivered, Failed), whileper_recipient_finalonly sends the final status once delivery completes or fails. Useper_recipientfor detailed tracking andper_recipient_finalto reduce webhook traffic.Q: How long should I wait before considering a message failed?
A: Sinch typically delivers status updates within minutes, but carrier delays can extend this to hours. Messages that remain in "Dispatched" status for 24-48 hours are likely failed. Sinch will send a final "Expired" status if delivery times out.
Q: Can I use this webhook handler for MMS delivery reports?
A: Yes, with minor modifications. Change the type check to include
recipient_delivery_report_mmsand update your database schema to track MMS-specific fields if needed.Q: Should I retry failed messages automatically?
A: It depends on the failure reason. Check the
statusCodein the callback – some codes indicate permanent failures (invalid number), while others may be temporary (network issues). Implement retry logic with exponential backoff for temporary failures only.Q: How do I handle webhooks during local development?
A: Use ngrok (as shown in section 4.1) to create a public HTTPS URL that forwards to your local development server. Update the callback URL in Sinch Dashboard to your ngrok URL while developing, then switch to your production URL when deploying.