This guide provides a step-by-step walkthrough for integrating Sinch SMS delivery status callbacks into your RedwoodJS application. By implementing this, your application can programmatically receive real-time updates on the delivery status of SMS messages sent via the Sinch API, enabling you to track message success, handle failures, and maintain accurate communication logs.
We will build a RedwoodJS API function that acts as a webhook endpoint to receive notifications from Sinch. These notifications will then be processed and stored in a database using Prisma, providing a persistent record of message delivery events.
Project Overview and Goals
What We're Building:
- A secure RedwoodJS API endpoint (
/api/sinchCallbacks
) to receive POST requests from Sinch containing SMS delivery status updates. - Database models (using Prisma) to store information about sent messages and their corresponding delivery status updates.
- A RedwoodJS service to handle the incoming callback data, validate it, 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 actually reached the recipient's handset. Sinch delivery status callbacks solve this by pushing status updates (like Dispatched
, Delivered
, Failed
) back to your application, providing crucial visibility into the message lifecycle.
Technologies Used:
- RedwoodJS: A full-stack JavaScript/TypeScript framework for the web. We'll use its API-side features (functions, services) and integrated Prisma ORM.
- Sinch SMS API: Used for sending SMS messages and configured to send delivery status callbacks.
- Prisma: A next-generation ORM for Node.js and TypeScript, used for database modeling and access within RedwoodJS.
- Node.js: The underlying runtime environment.
- (Optional)
ngrok
: For testing webhook callbacks locally during development.
System Architecture:
The basic flow is as follows:
- Your RedwoodJS Application sends an SMS message request (including a request for delivery reports) to the Sinch SMS API.
- The Sinch API accepts the request and attempts to deliver the SMS via mobile networks/carriers to the Recipient's Phone.
- As the delivery status changes (e.g., dispatched, delivered, failed), the Sinch Callback Service receives this information.
- The Sinch Callback Service sends a POST request (the callback) 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 sent.
Prerequisites:
- Node.js (LTS version recommended)
- Yarn (v1) or npm
- RedwoodJS CLI installed (
yarn global add redwoodjs-cli
ornpm install -g redwoodjs-cli
) - A Sinch account with an active SMS Service Plan ID and API Token.
- Basic familiarity with RedwoodJS concepts (API functions, services, Prisma).
- A database supported by Prisma (e.g., PostgreSQL, MySQL, SQLite).
Expected Outcome:
Upon completion, your RedwoodJS application will have a robust mechanism to:
- Receive delivery status updates for individual SMS recipients from Sinch.
- Securely validate these incoming callbacks.
- Store the status updates in your application's database, linked to the original message sent.
1. Setting up the RedwoodJS Project
Let's start by creating a new RedwoodJS project and setting up the basic structure and environment.
1.1. Create RedwoodJS Project:
Open your terminal and run:
# Using Yarn
yarn create redwood-app ./sinch-redwood-callbacks
# Or using npm
npx create-redwood-app ./sinch-redwood-callbacks
This command scaffolds a new RedwoodJS project in the sinch-redwood-callbacks
directory.
1.2. Navigate into Project Directory:
cd sinch-redwood-callbacks
1.3. Configure Environment Variables:
RedwoodJS uses a .env
file for environment variables. Create this file in the project root:
touch .env
Add the following variables. We'll explain how to get the Sinch values shortly.
# .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 chosen database. For local development, SQLite (file:./dev.db
) is often sufficient.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). You will configure this same secret in the Sinch Dashboard later. This is crucial for validating incoming webhooks. Using tools likeopenssl rand -hex 32
is common, but any method that produces a cryptographically strong random string is acceptable.
1.4. Initialize Database:
RedwoodJS uses Prisma for database management. Let's apply the initial schema (which is mostly empty at this point) and create the database file (if using SQLite).
yarn rw prisma migrate dev --name initial_setup
This command initializes the database, creates the migration history table, and ensures your DATABASE_URL
is working.
2. Implementing Core Functionality (Callback Handler)
We need an API endpoint (a RedwoodJS Function) to receive the callbacks from Sinch.
2.1. Generate the API Function:
Use the RedwoodJS CLI to generate a new function. We'll use TypeScript for better type safety.
yarn rw g function sinchCallbacks --ts
This creates api/src/functions/sinchCallbacks.ts
and related files.
2.2. Basic Handler Structure:
Open api/src/functions/sinchCallbacks.ts
. The initial structure will look something like this:
// 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. We will add more logic in subsequent steps.
3. Building the API Layer
In RedwoodJS, API functions automatically map to endpoints. The function api/src/functions/sinchCallbacks.ts
will be accessible at the URL path /api/sinchCallbacks
.
3.1. Endpoint Definition:
- URL:
/api/sinchCallbacks
(relative to your application's base URL) - Method:
POST
- Request Body: JSON payload sent by Sinch (structure detailed in Sinch documentation and below).
- Authentication: HMAC Signature (covered in Security section).
- Responses:
200 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 signature.405 Method Not Allowed
: Request method was not POST.500 Internal Server Error
: Unexpected server-side error during processing.
3.2. Example Sinch Callback Payload (recipient_delivery_report_sms
):
Sinch will send a POST request with a JSON body similar to this when you configure delivery_report
as per_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. Testing the Endpoint Locally (Simulating Sinch):
Once deployed or running locally with ngrok
, you can test the endpoint using curl
:
# 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 (based on the placeholder code).
4. Integrating with Sinch (Configuration)
Now, 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 to the internet.- Install ngrok: https://ngrok.com/download
- Start your Redwood dev server:
yarn rw dev
(Note the API port, usually 8911) - Run ngrok:
ngrok http 8911
- Copy the
https://
forwarding URL provided by ngrok (e.g.,https://<random-chars>.ngrok.io
). Your full callback URL will behttps://<random-chars>.ngrok.io/api/sinchCallbacks
.
- Production/Staging: Use the public URL of your deployed RedwoodJS application (e.g.,
https://yourapp.yourdomain.com/api/sinchCallbacks
).
4.2. Configure Callback URL in Sinch Dashboard:
- Log in to your Sinch Customer Dashboard.
- Navigate to APIs -> SMS APIs.
- Select the Service Plan ID you are using (the one matching
SINCH_SERVICE_PLAN_ID
in your.env
). - Find the Callback URLs section (this might be under ""Settings"" or a similar tab for the service plan).
- Click Add Callback URL or Edit.
- Enter the Callback URL you obtained in step 4.1 into the appropriate field.
- IMPORTANT - Security: Find the field for Webhook Secret or HMAC Secret. Paste the exact same secret string you generated and put in your
.env
file forSINCH_WEBHOOK_SECRET
. - Save the configuration.
(Note: The exact UI and field names in the Sinch dashboard may vary. Refer to the Sinch documentation for the most up-to-date instructions on configuring callback URLs and secrets for your specific service plan.)
4.3. Request Delivery Reports When Sending SMS:
Crucially, Sinch only sends callbacks if you request them when sending the message. When using the Sinch SMS API (e.g., the REST API) to send a message, include the delivery_report
parameter 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_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 would typically reside 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 (e.g., for sending SMS). Obtained from Sinch Dashboard.SINCH_WEBHOOK_SECRET
: Authenticates requests from Sinch to your webhook. You create this secret and configure it in both your.env
and the Sinch Dashboard.
5. Implementing 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.
// 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
// Add other potential fields if needed
}
export const handler = async (event: APIGatewayEvent, context: Context) => {
// Use a unique request ID for logging correlation if available
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 (Covered in Security Section)
try {
// Pass the raw body string. Ensure your deployment environment provides it.
// RedwoodJS typically makes the raw body available in event.body when Content-Type is application/json.
const rawBody = event.body
if (rawBody === null || rawBody === undefined) {
handlerLogger.error('Raw request body is missing or null.')
// Return 400 as the request is fundamentally malformed for signature validation
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 500 as this is a server configuration issue
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')
// Treat unexpected validation errors as potential bad requests or internal issues
return { statusCode: 400, body: JSON.stringify({ error: 'Signature validation failed' }) }
}
// 3. Parse Request Body
let payload: SinchRecipientDeliveryReport
try {
// The body should already be validated for existence in the signature check
payload = JSON.parse(event.body!) // Use non-null assertion after check
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.')
// Still return 200 OK to acknowledge receipt and prevent Sinch retries
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') // Avoid logging full body potentially containing PII
return { statusCode: 400, body: JSON.stringify({ error: 'Bad Request: Malformed JSON' }) }
}
// 4. Process Callback Data (Using Service)
try {
// Call the service function (defined later)
await recordSmsStatusUpdate({
batchId: payload.batch_id,
recipient: payload.recipient,
statusCode: payload.code,
status: payload.status,
statusTimestamp: new Date(payload.at), // Convert ISO string to Date object
clientReference: payload.client_reference,
rawPayload: event.body!, // Store raw payload for debugging
})
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
// Log the error thoroughly for internal investigation.
return {
statusCode: 200, // Acknowledge receipt to prevent Sinch retries
// Alternatively, consider 500 if the error is critical and *not* related
// to the specific callback data itself (e.g., database down).
// However, 200 is generally safer for webhook stability.
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Callback received but internal processing failed' }),
}
}
}
Key Improvements:
- Type Safety: Defined an interface
SinchRecipientDeliveryReport
. - Logging: Using Redwood's
logger
with context (requestId
). Logging key events and errors. Added check to avoid logging full potentially sensitive body on parse failure. - Structured Error Handling:
try...catch
blocks for signature validation, parsing, and service calls. Added checks forevent.body
existence andSINCH_WEBHOOK_SECRET
configuration before validation. - Clear Return Codes: Returning appropriate HTTP status codes (
405
,401
,400
,500
,200
). - Sinch Acknowledgement: Consistently returning
200 OK
once the request is validated and parsed, even if downstream processing fails. This is crucial to prevent Sinch from resending the same callback. Log internal errors aggressively for debugging.
6. Creating the Database Schema and Data Layer
We need database tables to store information about messages sent and the status updates received.
6.1. Define Prisma Schema:
Open api/db/schema.prisma
and define the models. We'll add a Message
table (assuming your app tracks sent messages) and an SmsStatusUpdate
table.
// 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()) // Unique message ID in your system
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
to String // Recipient phone number. IMPORTANT: Store consistently, E.164 format recommended to match Sinch callbacks.
body String? // Optional: Store message body
sentAt DateTime @default(now()) // Timestamp when the send request was made
sinchBatchId String // The Batch ID returned by Sinch upon sending
clientReference String? // Optional: The client reference you sent to Sinch
// Tracking the latest known status directly on the message can be useful
latestStatus String?
latestStatusCode Int?
latestStatusTimestamp DateTime?
// Relation to status updates
statusUpdates SmsStatusUpdate[]
@@index([sinchBatchId])
@@index([clientReference])
@@index([to]) // Index recipient if you query by it often
}
// Model representing a delivery status update received from Sinch
model SmsStatusUpdate {
id String @id @default(cuid())
createdAt DateTime @default(now())
messageId String // Foreign key linking to the Message
// The onDelete: Cascade means if a Message is deleted, all its related SmsStatusUpdates are also deleted.
// Consider alternatives like SetNull (if messageId is optional) or handling deletions
// at the application level if you need to preserve status history after deleting a message.
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
sinchBatchId String // Batch ID from the callback (redundant but useful for querying)
recipient String // Recipient phone number from the callback
status String // e.g., ""Dispatched"", ""Delivered"", ""Failed""
statusCode Int // e.g., 401, 0, 402 (Sinch status codes)
statusTimestamp DateTime // The 'at' timestamp from the callback
clientReference String? // Client reference from the callback
rawPayload String // Store the full JSON payload for auditing/debugging
@@index([messageId])
@@index([sinchBatchId, recipient]) // Efficiently find updates for a specific message in a batch
@@index([statusTimestamp])
}
Key Decisions:
Message
Model: Represents the message your application sent. IncludessinchBatchId
and optionallyclientReference
to link back to Sinch. Added fields (latestStatus
, etc.) for quick access to the most recent status. Added comment emphasizing E.164 format forto
field.SmsStatusUpdate
Model: Represents a single callback received from Sinch. Linked to theMessage
viamessageId
. Stores key fields from the callback and therawPayload
.- Indexes: Added indexes to optimize querying for messages/updates based on
sinchBatchId
,recipient
, andmessageId
. onDelete: Cascade
: Added a comment explaining the implication and suggesting consideration of alternatives based on application requirements.
6.2. Apply Database Migrations:
Run the migration command to apply these schema changes to your database:
yarn rw prisma migrate dev --name add_message_and_status_tables
Answer y
(yes) if prompted to confirm the migration.
6.3. Generate the Data Layer (Service):
Create a RedwoodJS service to encapsulate the logic for handling the status updates and interacting with the database.
yarn rw g service smsStatus --ts
This creates api/src/services/smsStatus/smsStatus.ts
and related test files.
6.4. Implement the Service Logic:
Open api/src/services/smsStatus/smsStatus.ts
and add the recordSmsStatusUpdate
function.
// 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
// We need a reliable way to link the callback to the message *you* sent.
// Using batchId AND recipient is usually the most robust way.
// CRITICAL: This assumes your application stores the recipient phone number
// in the `Message.to` field in the *exact same format* (E.164 recommended)
// as Sinch provides it in the callback's `recipient` field.
// Mismatched formats (e.g., ""+1"" vs ""1"", presence of spaces/dashes) will prevent finding the message.
// Alternatively, if you reliably send a unique `clientReference` per message, you could use that.
const message = await db.message.findFirst({
where: {
sinchBatchId: batchId,
to: recipient, // Ensure 'to' is stored consistently (e.g., E.164 format)
// OR potentially use clientReference if it's guaranteed unique per message
// clientReference: clientReference
},
select: { id: true, latestStatusTimestamp: true }, // Select only necessary fields
})
if (!message) {
// Could not find the original message this status update belongs to.
// This might happen if:
// - The message wasn't stored correctly when sent.
// - There's a significant delay and the message was archived/deleted.
// - The recipient format stored in `Message.to` doesn't match the callback `recipient`.
// Decide how to handle this: Log an error? Create an orphaned status record?
serviceLogger.error(
{ clientReference }, // Log client reference if available, might help debugging
'Could not find original message for this status update. Check recipient format consistency (E.164 recommended) and if the message was stored.'
)
// Option: Throw an error (will be caught by handler, logged, but Sinch gets 200 OK)
throw new Error(
`Original message not found for batchId: ${batchId}, recipient: ${recipient}. Verify recipient format.`
)
// Option: Log and exit gracefully (Sinch gets 200 OK)
// return;
}
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 (Optional but Recommended):
// Avoid creating duplicate status updates if Sinch retries.
// Check if an update with the same core details already exists.
// Note: Checking timestamp equality can be fragile due to potential clock skew or minor variations.
// Relying on messageId + recipient + status + statusCode is generally more robust for DLRs.
const existingUpdate = await db.smsStatusUpdate.findFirst({
where: {
messageId: message.id,
recipient: recipient, // Match the specific recipient
status: status, // Match the specific status event
statusCode: statusCode, // Match the specific status code
// statusTimestamp: statusTimestamp // Avoid matching exact timestamp unless necessary and reliable
},
select: { id: true }
});
if (existingUpdate) {
serviceLogger.warn({ existingUpdateId: existingUpdate.id }, 'Duplicate status update received (same message, recipient, status, code). Ignoring.');
return; // Successfully handled the duplicate, exit.
}
// Create the actual record
const newStatusUpdate = await db.smsStatusUpdate.create({ data: createData })
serviceLogger.info(
{ statusUpdateId: newStatusUpdate.id },
'Created new SmsStatusUpdate record'
)
// 3. (Optional) Update the latest status on the parent Message record
// Only update if this status is newer than the existing latest status
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 on Message record'
)
}
} catch (error) {
serviceLogger.error({ error }, 'Database error while recording status update')
// Re-throw the error to be caught by the handler function
throw error
}
}
// You might add other service functions here later, e.g.,
// - getMessageStatusHistory(messageId: string)
// - findMessagesByStatus(status: string)
Key Service Logic:
- Find Message: Locates the corresponding
Message
record usingsinchBatchId
andrecipient
. Explicitly states the requirement for matching recipient formats (E.164 recommended) and handles the case where the message isn't found with clearer logging. - Idempotency: Includes an optional check to prevent creating duplicate
SmsStatusUpdate
records if Sinch sends the same callback multiple times (due to retries). Refined the check to focus on core status details rather than exact timestamp. - Create Status Record: Creates a new
SmsStatusUpdate
linked to the foundMessage
. - Update Latest Status: Optionally updates the
latestStatus
fields on theMessage
record if the incoming status is newer than the currently stored latest status. Handles same-timestamp case. - Error Handling: Catches potential database errors and logs them before re-throwing.
Now, ensure the recordSmsStatusUpdate
function is correctly imported and called within the api/src/functions/sinchCallbacks.ts
handler (as shown in the updated handler code in Section 5.1).
7. Adding Security Features (HMAC Validation)
Securing your webhook endpoint is critical to ensure that requests genuinely come from Sinch and haven't been tampered with. We'll use HMAC-SHA256 signature validation.
7.1. Create a Security Utility:
It's good practice to place security-related functions in the api/src/lib
directory.
Create a new file: 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'
/**
* Finds a header value case-insensitively.
* @param headers - The headers object.
* @param headerName - The name of the header to find (case-insensitive).
* @returns The header value or undefined if not found.
*/
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;
}
/**
* Validates the Sinch webhook signature using HMAC-SHA256.
*
* @param headers - The request headers object (from APIGatewayEvent). Case-insensitive lookup is performed.
* @param rawBody - The raw, unparsed request body string. MUST be the exact bytes received.
* @param secret - Your Sinch Webhook Secret from environment variables.
* @returns `true` if the signature is valid, `false` otherwise.
* @throws Error if required headers are missing or the 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.')
// Throwing an error here helps distinguish configuration issues from invalid signatures in the handler
throw new Error('Webhook secret is missing or invalid.');
}
// 1. Extract Headers provided by Sinch
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 // Or throw new Error('Missing x-sinch-timestamp header');
}
if (!sinchSignatureHeader) {
securityLogger.warn('Missing x-sinch-signature header')
return false // Or throw new Error('Missing x-sinch-signature header');
}
// 2. Prepare the Signed Content String
// Format: HTTP_Method + '\n' + Content-MD5 + '\n' + Content-Type + '\n' + Timestamp_Header + '\n' + Request_URI
// For Sinch callbacks: Method=POST, MD5=hash of rawBody, ContentType=application/json, Timestamp=x-sinch-timestamp, URI=/api/sinchCallbacks (or the path part of your endpoint)
// IMPORTANT: Sinch documentation might specify slightly different formats. Verify against current Sinch docs.
// Assuming the standard format based on common webhook patterns and Sinch examples:
// StringToSign = <Request Body> + <Timestamp Header Value>
// Sinch typically signs the raw request body concatenated with the timestamp header value.
const stringToSign = rawBody + sinchTimestamp;
securityLogger.debug({ stringToSignLength: stringToSign.length }, 'Constructed string-to-sign')
// 3. Decode the Secret (Sinch secrets are typically Base64 encoded)
let decodedSecret: Buffer;
try {
decodedSecret = Buffer.from(secret, 'base64');
} catch (e) {
securityLogger.error({ error: e }, 'Failed to decode webhook secret from Base64. Ensure it is correctly encoded.');
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
// Sinch signature header format is often `sv1=<signature>` or just the signature.
// Adjust parsing based on the actual header format received. Assuming it's just the Base64 signature.
const providedSignature = sinchSignatureHeader; // Adjust if prefix like 'sv1=' is present
// Use crypto.timingSafeEqual for security against timing attacks
try {
const providedSigBuffer = Buffer.from(providedSignature, 'base64');
const expectedSigBuffer = Buffer.from(expectedSignature, 'base64');
// Ensure buffers have the same length before comparing to prevent timingSafeEqual errors
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) {
// Handle potential errors during buffer conversion (e.g., invalid Base64 in provided signature)
securityLogger.error({ error }, 'Error during signature comparison (possibly invalid Base64 format in provided signature)');
return false;
}
}
Key Security Logic:
- Header Extraction: Safely extracts
x-sinch-timestamp
andx-sinch-signature
headers (case-insensitive). - Secret Handling: Checks if the secret is provided and decodes it from Base64. Throws errors for configuration issues.
- String-to-Sign Construction: Creates the string that Sinch signed, typically the raw request body concatenated with the timestamp header value. Note: Verify this against the current Sinch documentation for callback signatures.
- HMAC Calculation: Computes the expected HMAC-SHA256 signature using the decoded secret and the string-to-sign.
- Secure Comparison: Uses
crypto.timingSafeEqual
to compare the provided signature with the expected signature, preventing timing attacks. Handles potential buffer length mismatches and Base64 decoding errors. - Logging: Includes logging for debugging signature validation issues.
7.2. Integrate Validation into the Handler:
Ensure the validateSinchSignature
function is called within the api/src/functions/sinchCallbacks.ts
handler before parsing the body or processing the data. This was already included in the updated handler code in Section 5.1. Make sure the raw body and headers are passed correctly.
(End of provided text)