Tracking the delivery status of SMS messages is crucial for many applications. Knowing whether a message was successfully delivered, failed, or is still in transit enables better user communication, debugging, and analytics. Twilio provides status callbacks (webhooks) that notify your application about these status changes.
This guide provides a step-by-step walkthrough for integrating Twilio's SMS delivery status callbacks into a RedwoodJS application. We'll build an endpoint on the RedwoodJS API side (which runs Node.js) to receive these callbacks, validate them securely, and update the status of sent messages stored in our database.
Project Goals:
- Send SMS messages via the Twilio API from a RedwoodJS application.
- Configure Twilio to send status updates to a dedicated webhook endpoint in our RedwoodJS app.
- Securely handle incoming webhook requests from Twilio.
- Store message details and update their delivery status in a database using Prisma.
- Provide a robust foundation for reliable SMS status tracking.
Technologies Used:
- RedwoodJS: A full-stack JavaScript/TypeScript framework for the web. It provides structure and tooling for both frontend (React) and backend (Node.js, GraphQL API, Prisma) development.
- Node.js: The runtime environment for the RedwoodJS API side.
- Twilio Programmable Messaging: The Twilio service used for sending SMS and receiving status callbacks.
- Prisma: The database toolkit used by RedwoodJS for schema definition, migrations, and database access.
- PostgreSQL (or SQLite/MySQL): The underlying database where message statuses will be stored.
System Architecture:
+-----------------+ +-----------------+ +-----------------+ +------------------+
| RedwoodJS Web |----->| RedwoodJS API |----->| Twilio API |----->| Carrier Network |
| (React) | | (Node.js/GraphQL)| | (Send SMS) | | (Delivers SMS) |
+-----------------+ +-----------------+ +-----------------+ +------------------+
^ | ^ |
| User Action | | Send Request | SMS Status Update
| | | w/ StatusCallback URL v
| | +-----------------------------------------+
| | |
| +-----------------------+ |
| | Update DB | |
| v | |
| +-----------------+ <---------+ +-----------------+ |
| | Database | | Twilio Webhook| <--+
| | (Prisma) | | (Status Update)|
+---------------+ +-------------+-----------------+
- A user action (or backend process) triggers the RedwoodJS API to send an SMS.
- The RedwoodJS API calls the Twilio API_ including a
statusCallback
URL pointing back to a specific endpoint on the RedwoodJS API. - Twilio sends the SMS message via the carrier network.
- As the message status changes (e.g._ sent_ delivered_ failed)_ Twilio sends an HTTP POST request (webhook) to the specified
statusCallback
URL. - The RedwoodJS API endpoint receives the webhook_ validates its authenticity_ parses the status_ and updates the corresponding message record in the database.
Prerequisites:
- Node.js (>=18.x recommended) and Yarn installed.
- A Twilio account with Account SID, Auth Token, and a Twilio phone number capable of sending SMS. Find these in your Twilio Console.
- Basic understanding of RedwoodJS concepts (CLI, services, functions).
- A tool like
ngrok
for testing webhooks locally during development.
1. Setting up the RedwoodJS Project
If you don't have an existing RedwoodJS project, create one:
yarn create redwood-app ./twilio-status-app
cd twilio-status-app
Install Twilio Helper Library:
Navigate to the API side and install the official Twilio Node.js helper library:
cd api
yarn add twilio
cd .. # Return to project root
Configure Environment Variables:
RedwoodJS uses .env
files for environment variables. Create a .env
file in the project root if it doesn't exist:
# .env
# Twilio Credentials
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token
TWILIO_PHONE_NUMBER=+15017122661 # Your Twilio phone number
# Base URL for API callbacks (used for ngrok/deployment)
# Example for ngrok: https://<your-ngrok-subdomain>.ngrok.io
# Example for deployment: https://your-app-domain.com
API_BASE_URL=http://localhost:8911 # Default local dev, update for testing/deployment
TWILIO_ACCOUNT_SID
/TWILIO_AUTH_TOKEN
: Found on your Twilio Console dashboard. Keep these secret! Do not commit them to version control.TWILIO_PHONE_NUMBER
: The Twilio number you'll send messages from.API_BASE_URL
: The publicly accessible base URL where your RedwoodJS API is hosted. During local development withngrok
, this will be your ngrok forwarding URL. In production, it's your deployed application's domain. This is crucial for constructing thestatusCallback
URL.
Project Structure:
RedwoodJS organizes code into web
(frontend) and api
(backend) sides. We'll primarily work within the api
directory:
api/src/functions/
: For serverless function handlers (like our webhook endpoint).api/src/services/
: For business logic and interacting with third-party APIs (like Twilio).api/db/schema.prisma
: For database schema definition.api/src/lib/
: For shared utilities (like the Twilio client instance).
2. Implementing Core Functionality: Sending SMS & Handling Callbacks
We need two main pieces: logic to send an SMS and specify the callback URL, and logic to receive and process the callback.
2.1. Database Schema for Messages
Define a model in your Prisma schema to store message information, including its status.
// api/db/schema.prisma
datasource db {
provider = ""postgresql"" // Or ""sqlite"", ""mysql""
url = env(""DATABASE_URL"")
}
generator client {
provider = ""prisma-client-js""
binaryTargets = ""native""
}
// Add this model
model Message {
id String @id @default(cuid()) // Unique database ID
sid String @unique // Twilio Message SID
to String // Recipient phone number
from String // Sender phone number (Twilio number)
body String? // Message content
status String // e.g., queued, sending, sent, delivered, failed, undelivered
errorCode Int? // Twilio error code if failed/undelivered
errorMessage String? // Twilio error message if failed/undelivered
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Apply Migrations:
Generate and apply the database migration:
yarn rw prisma migrate dev --name add_message_model
This creates the Message
table in your database.
2.2. Creating a Twilio Client Instance
It's good practice to initialize the Twilio client once and reuse it.
// api/src/lib/twilio.js
import twilio from 'twilio'
const accountSid = process.env.TWILIO_ACCOUNT_SID
const authToken = process.env.TWILIO_AUTH_TOKEN
if (!accountSid || !authToken) {
// Throwing an error here provides immediate feedback in development if credentials are missing.
// For certain production scenarios where a temporary missing variable shouldn't halt startup
// (though these are generally required), you might consider logging a critical error
// and attempting to proceed, though functionality relying on Twilio would fail.
// However, for required credentials, failing fast is usually the safest approach.
throw new Error(
'Twilio credentials (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) are required. Check your .env file.'
)
}
export const twilioClient = twilio(accountSid, authToken)
export const twilioPhoneNumber = process.env.TWILIO_PHONE_NUMBER
if (!twilioPhoneNumber) {
// Similar consideration as above for the phone number.
throw new Error(
'Twilio phone number (TWILIO_PHONE_NUMBER) is required. Check your .env file.'
)
}
2.3. Service for Sending SMS
Create a RedwoodJS service to encapsulate the logic for sending SMS messages via Twilio and creating the initial record in our database.
yarn rw g service sms
Now, implement the sendSms
function in the generated service file.
// api/src/services/sms/sms.js
import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'
import { twilioClient, twilioPhoneNumber } from 'src/lib/twilio'
// Construct the full callback URL using the base URL from environment variables
// Ensure API_BASE_URL ends without a trailing slash
const apiBaseUrl = (process.env.API_BASE_URL || '').replace(/\/$/, '')
const statusCallbackUrl = `${apiBaseUrl}/.redwood/functions/twilioCallback`
export const sendSms = async ({ to, body }) => {
if (!to || !body) {
throw new Error(""Both 'to' phone number and 'body' are required."")
}
if (!apiBaseUrl) {
logger.error('API_BASE_URL environment variable is not set. Cannot construct StatusCallback URL.')
throw new Error('Application configuration error: API_BASE_URL is missing.')
}
logger.info(`Attempting to send SMS to ${to}`)
try {
// Send message via Twilio, providing the StatusCallback URL
const twilioMessage = await twilioClient.messages.create({
body: body,
from: twilioPhoneNumber,
to: to,
statusCallback: statusCallbackUrl, // Critical: Tell Twilio where to send updates
})
logger.info(
`SMS queued successfully with SID: ${twilioMessage.sid}, Status: ${twilioMessage.status}`
)
// Create initial record in our database
const storedMessage = await db.message.create({
data: {
sid: twilioMessage.sid,
to: twilioMessage.to,
from: twilioMessage.from,
body: twilioMessage.body,
status: twilioMessage.status, // Initial status from Twilio (e.g., 'queued')
// errorCode and errorMessage will be updated by the callback if needed
},
})
logger.info(
`Stored message in DB with ID: ${storedMessage.id} and SID: ${storedMessage.sid}`
)
// Return the database record, not the raw Twilio response,
// as it reflects what's stored locally.
return storedMessage
} catch (error) {
logger.error(`Error sending SMS to ${to}: ${error.message}`, error)
// You might want to throw a more specific error or handle it differently
throw new Error(`Failed to send SMS: ${error.message}`)
}
}
// Optional: Service function to retrieve message status from DB
export const getMessageStatus = async ({ sid }) => {
logger.info(`Retrieving status for message SID: ${sid}`)
const message = await db.message.findUnique({
where: { sid },
})
if (!message) {
logger.warn(`Message with SID ${sid} not found in database.`)
// Consider throwing an error or returning a specific status
return null // Or throw new Error('Message not found')
}
return message
}
// NOTE: Redwood typically generates CRUD operations here.
// We are keeping only the relevant functions for this guide.
// You can remove unused generated functions like messages, message, createMessage, etc.
// if they were generated automatically, or adapt them.
export const messages = () => {
return db.message.findMany()
}
Explanation:
- Imports: We import the Prisma client (
db
), Redwood's logger, and our configuredtwilioClient
andtwilioPhoneNumber
. statusCallbackUrl
: We construct the full URL for our webhook endpoint. It combines theAPI_BASE_URL
(which needs to be publicly accessible) with the conventional Redwood path for functions (/.redwood/functions/
) and the specific function name (twilioCallback
). Crucially, ensureAPI_BASE_URL
is correctly set for your environment. Added a check forAPI_BASE_URL
.twilioClient.messages.create
: We call the Twilio API to send the message.body
,from
,to
: Standard message parameters.statusCallback
: This tells Twilio to send POST requests with status updates for this specific message to ourtwilioCallback
function URL.
- Database Creation: After successfully queueing the message with Twilio, we create a record in our
Message
table usingdb.message.create
. We store thesid
returned by Twilio, along with other details and the initialstatus
(usuallyqueued
). - Error Handling: Basic
try...catch
block logs errors. Production applications would need more robust error handling. - Return Value: We return the message record created in our database.
2.4. Handling the Status Callback (Webhook)
Now, create the RedwoodJS function that Twilio will call.
yarn rw g function twilioCallback --typescript=false # Or --typescript=true if using TS
Implement the handler logic in the generated file. This endpoint needs to:
- Receive the POST request from Twilio.
- Validate the request signature to ensure it genuinely came from Twilio.
- Parse the
MessageSid
andMessageStatus
(andErrorCode
if present) from the request body. - Update the corresponding message record in the database.
- Return a
200 OK
response to Twilio.
// api/src/functions/twilioCallback.js
import { logger } from 'src/lib/logger'
import { db } from 'src/lib/db'
import { validateRequest } from 'twilio' // Import the validator
/**
* @param {import(""@redwoodjs/api"").APIGatewayEvent} event - The incoming request event
* @param {import(""@redwoodjs/api"").Context} context - The context object
*/
export const handler = async (event, context) => {
logger.info('Received Twilio status callback request')
// --- 1. Validate Twilio Request Signature ---
const twilioSignature = event.headers['x-twilio-signature']
const authToken = process.env.TWILIO_AUTH_TOKEN
// Construct the full URL the request was made to.
// Ensure API_BASE_URL ends without a trailing slash
const apiBaseUrl = (process.env.API_BASE_URL || '').replace(/\/$/, '')
const callbackUrl = `${apiBaseUrl}${event.path}` // event.path includes /.redwood/functions/twilioCallback
if (!authToken || !apiBaseUrl) {
logger.error('Missing TWILIO_AUTH_TOKEN or API_BASE_URL environment variable. Cannot validate request.')
return {
statusCode: 500,
body: 'Server configuration error.',
}
}
// Parse the body - RedwoodJS v6+ automatically parses common content types
// including 'application/x-www-form-urlencoded' into event.body (as an object).
// For older versions or different setups, you might need manual parsing:
// const params = new URLSearchParams(event.body);
// And pass `Object.fromEntries(params)` to validateRequest.
const params = event.body // Assumes Redwood v6+ parsed urlencoded body correctly
// Important: validateRequest expects the raw POST body params as an object.
const isValid = validateRequest(authToken, twilioSignature, callbackUrl, params || {}) // Pass empty object if body is null/undefined
if (!isValid) {
logger.error('Invalid Twilio signature. Request rejected.')
return {
statusCode: 403, // Forbidden
body: 'Invalid Twilio signature.',
}
}
logger.info('Twilio signature validated successfully.')
// --- 2. Parse Request Body ---
// Ensure params exists before accessing properties
const messageSid = params?.MessageSid
const messageStatus = params?.MessageStatus
const errorCode = params?.ErrorCode ? parseInt(params.ErrorCode, 10) : null
const errorMessage = params?.ErrorMessage || null // Some channels use ErrorMessage
if (!messageSid || !messageStatus) {
logger.warn('Missing MessageSid or MessageStatus in callback body.')
// Even if parameters are missing, signature was valid, so return 200 to avoid retries.
// Log the issue for investigation.
return {
statusCode: 200, // OK, but log the issue
headers: { 'Content-Type': 'text/xml' },
body: '<Response></Response>', // Acknowledge receipt
}
}
logger.info(
`Processing status update for SID: ${messageSid}, New Status: ${messageStatus}, ErrorCode: ${errorCode}`
)
// --- 3. Update Database ---
try {
const updatedMessage = await db.message.update({
where: { sid: messageSid },
data: {
status: messageStatus,
errorCode: errorCode,
errorMessage: errorMessage, // Store error message if available
// Add other relevant fields from callback if needed (e.g., RawDlrDoneDate)
},
})
logger.info(
`Successfully updated status for message SID: ${messageSid} to ${messageStatus} in DB (ID: ${updatedMessage.id})`
)
} catch (error) {
// Handle cases where the message SID might not be found (e.g., race condition, data issue)
if (error.code === 'P2025') { // Prisma code for Record not found
logger.error(
`Message with SID ${messageSid} not found in database for update.`
)
// Decide how to handle: Log, maybe create a record if appropriate, or just ignore?
// For robustness, often best to log and return 200 if signature was valid.
} else {
logger.error(
`Database error updating status for SID ${messageSid}: ${error.message}`,
error
)
// Even on DB error, if signature was valid, consider returning 200
// so Twilio doesn't retry excessively if the issue is temporary.
// A 500 could be returned, but might lead to many retries. Let's return 200 here.
return {
statusCode: 200, // OK, acknowledge receipt despite DB error to stop retries
headers: { 'Content-Type': 'text/xml' },
body: '<Response></Response>',
}
}
}
// --- 4. Respond to Twilio ---
// Twilio expects a 200 OK response to acknowledge receipt.
// The response body is typically ignored by Twilio for status callbacks.
return {
statusCode: 200,
headers: { 'Content-Type': 'text/xml' }, // Twilio often expects XML, but empty is fine
body: '<Response></Response>', // Empty TwiML response
}
}
Explanation:
- Signature Validation: This is critical for security.
validateRequest
from thetwilio
library checks if thex-twilio-signature
header matches a signature calculated using yourTWILIO_AUTH_TOKEN
, the request URL (callbackUrl
), and the POST parameters (params
). This verifies the request originated from Twilio and wasn't tampered with. Reject requests with invalid signatures using a403 Forbidden
status. - Parsing: We extract
MessageSid
,MessageStatus
,ErrorCode
, andErrorMessage
from the request parameters (event.body
). Note: This code assumes you are using RedwoodJS v6 or later, which automatically parsesapplication/x-www-form-urlencoded
request bodies into theevent.body
object. If using an older version or a different setup, you might need to manually parseevent.body
(which would be a string) usingnew URLSearchParams(event.body)
and then potentiallyObject.fromEntries()
before passing tovalidateRequest
and extracting parameters. Added checks for parameter existence. - Database Update: We use
db.message.update
to find the message record by its uniquesid
and update thestatus
,errorCode
, anderrorMessage
fields. We include error handling, especially for the case where the message might not (yet) exist in the DB (Prisma errorP2025
). - Response: We return a
200 OK
status code with an empty TwiML<Response></Response>
body. This acknowledges receipt to Twilio, preventing unnecessary retries. Even if a database error occurs after validating the signature, returning 200 is often preferred to avoid excessive retries for potentially transient DB issues.
3. Exposing the Send Functionality (API Layer)
While the callback handler is a direct webhook, you need a way to trigger the sendSms
service. A common RedwoodJS approach is via a GraphQL mutation.
Define GraphQL Schema:
// api/src/graphql/sms.sdl.ts
// The `gql` tag is typically available globally in Redwood SDL files
// via build-time transformations or global setup.
export const schema = gql`
type Message {
id: String!
sid: String!
to: String!
from: String!
body: String
status: String!
errorCode: Int
errorMessage: String
createdAt: DateTime!
updatedAt: DateTime!
}
type Mutation {
sendSms(to: String!, body: String!): Message! @requireAuth
# Or use @skipAuth if authentication isn't needed for this action
}
# Optional: Query to fetch status by SID
type Query {
messageStatus(sid: String!): Message @requireAuth
}
`
Implement Resolvers:
Redwood maps the SDL definitions to the service functions. Ensure your api/src/services/sms/sms.js
file exports functions matching the mutation and query names (sendSms
and messageStatus
). We already did this in Step 2.3.
Now, you can call this mutation from your RedwoodJS web side (or any GraphQL client) after implementing authentication (@requireAuth
).
Example Frontend Call (React Component):
// web/src/components/SendMessageForm/SendMessageForm.js
import { useMutation, gql } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import {
Form,
TextField,
TextAreaField,
Submit,
FieldError,
Label,
} from '@redwoodjs/forms'
const SEND_SMS_MUTATION = gql`
mutation SendSmsMutation($to: String!, $body: String!) {
sendSms(to: $to, body: $body) {
id
sid
status
# Add 'to' field if you want it back in the response for the toast
to
}
}
`
const SendMessageForm = () => {
const [sendSms, { loading, error }] = useMutation(SEND_SMS_MUTATION, {
onCompleted: (data) => {
toast.success(
`SMS to ${data.sendSms.to} queued! SID: ${data.sendSms.sid}, Status: ${data.sendSms.status}`
)
},
onError: (error) => {
toast.error(`Error sending SMS: ${error.message}`)
},
})
const onSubmit = (data) => {
sendSms({ variables: data })
}
return (
<Form onSubmit={onSubmit}>
{error && <p style={{ color: 'red' }}>Error: {error.message}</p>}
<Label name="to" htmlFor="to" errorClassName="error">To Phone Number:</Label>
<TextField
name="to"
id="to" // Match htmlFor
validation={{ required: true_ pattern: /^\+?[1-9]\d{1_14}$/ }}
errorClassName="error"
/>
<FieldError name="to" className="error" />
<Label name="body" htmlFor="body" errorClassName="error">Message:</Label>
<TextAreaField
name="body"
id="body" // Match htmlFor
validation={{ required: true }}
errorClassName="error"
/>
<FieldError name="body" className="error" />
<Submit disabled={loading}>{loading ? 'Sending...' : 'Send SMS'}</Submit>
</Form>
)
}
export default SendMessageForm
4. Integrating with Twilio (Configuration Details)
- API Credentials: As covered in Step 1, store
TWILIO_ACCOUNT_SID
andTWILIO_AUTH_TOKEN
securely in your.env
file and ensure they are available as environment variables in your deployment environment. Access them viaprocess.env
. - Twilio Phone Number: Store your
TWILIO_PHONE_NUMBER
in.env
. - Status Callback URL:
- Per-Message (Implemented Above): By providing the
statusCallback
parameter in theclient.messages.create
call, you tell Twilio where to send updates for that specific message. This is flexible but requires constructing the correct URL in your code. EnsureAPI_BASE_URL
is correctly set. - Messaging Service Level: Alternatively, you can configure a default Status Callback URL within a Twilio Messaging Service. If you send messages using a
messagingServiceSid
instead of afrom
number, and don't provide astatusCallback
parameter in the API call, Twilio will use the URL configured on the service.- Go to Twilio Console > Messaging > Services.
- Select or create a Messaging Service.
- Under ""Integration"", configure the ""Status callback URL"". Enter the full public URL to your
twilioCallback
function (e.g.,https://your-app-domain.com/.redwood/functions/twilioCallback
). - When sending, use
messagingServiceSid
instead offrom
. - Note: A
statusCallback
URL provided in the API call overrides the Messaging Service setting for that specific message.
- Per-Message (Implemented Above): By providing the
5. Error Handling, Logging, and Retries
- Error Handling:
- Wrap Twilio API calls (
messages.create
) intry...catch
blocks in your service (sendSms
). Log errors clearly. - In the callback handler (
twilioCallback
), handle potential database errors gracefully (e.g., message not foundP2025
, other DB connection issues). Return200 OK
even on handled DB errors post-signature validation to prevent excessive Twilio retries. - Validate incoming callback parameters (
MessageSid
,MessageStatus
).
- Wrap Twilio API calls (
- Logging:
- Use Redwood's built-in
logger
(import { logger } from 'src/lib/logger'
). - Log key events: sending attempt, successful queuing, received callback, signature validation result, database update attempt/success/failure. Include
MessageSid
in logs for correlation. - Configure appropriate log levels for development vs. production.
- Use Redwood's built-in
- Twilio Retries:
- If your
twilioCallback
endpoint returns a non-2xx
status code (e.g.,4xx
,5xx
) or times out, Twilio will retry the request. - Retries occur with exponential backoff for a certain period.
- Ensure your endpoint is idempotent: processing the same callback multiple times should not cause incorrect state changes (e.g., use
update
withwhere: { sid: ... }
, which is generally safe). - A crucial reason to validate signatures and return
200 OK
promptly (even on some internal errors after validation) is to prevent unnecessary retries.
- If your
6. Database Schema and Data Layer (Covered)
- The
Message
model inschema.prisma
(Step 2.1) defines the structure. - Prisma migrations (
yarn rw prisma migrate dev
) handle schema changes (Step 2.1). - The data access layer is implemented within the RedwoodJS service (
sms.js
) using the Prisma client (db
) for creating and updating records (Steps 2.3 & 2.4). - Performance: For high volume, ensure the
sid
column in theMessage
table has an index (Prisma adds@unique
which typically creates an index). Database connection pooling (handled by Prisma) is essential.
7. Security Features
- Webhook Signature Validation: This is the most critical security measure (implemented in Step 2.4 using
validateRequest
). It prevents unauthorized actors from forging status updates. Never skip this step. - Environment Variables: Protect your
TWILIO_AUTH_TOKEN
andTWILIO_ACCOUNT_SID
. Do not commit them to your repository. Use.env
locally and secure environment variable management in your deployment environment. - Input Validation: Validate inputs to the
sendSms
mutation (e.g., format of theto
number, length of thebody
). Redwood's forms provide basic validation helpers. - Authentication/Authorization: Use Redwood's built-in auth (
@requireAuth
) to protect the GraphQL mutation (sendSms
) so only authenticated users can trigger sending messages, if applicable. The callback endpoint itself is secured by signature validation, not user login. - Rate Limiting: Consider adding rate limiting to the
sendSms
mutation endpoint to prevent abuse. The callback endpoint implicitly relies on Twilio's rate limiting for sending webhooks.
8. Handling Special Cases
- Callback Order: Twilio status callbacks might arrive out of order due to network latency. A message might transition
queued
->sending
->sent
quickly, but the callbacks could arrive asqueued
->sent
->sending
. Design your logic to handle this. Usually, updating with the latest received status is sufficient, but you might store timestamps if order is critical. TheRawDlrDoneDate
property (present for delivered/undelivered SMS/MMS) can provide a carrier timestamp. - Message Not Found in DB: The callback might arrive before the
sendSms
function finishes writing the initial record to the database (a race condition, less likely but possible). The error handling intwilioCallback
(checking for PrismaP2025
) should log this. You might implement a small delay/retry mechanism within the callback if this becomes a frequent issue, but often logging and returning200 OK
is sufficient. - Time Zones: Timestamps from Twilio are typically UTC. Ensure your database stores timestamps consistently (Prisma's
DateTime
usually handles this well with the database's timezone settings, often defaulting to UTC). Be mindful when displaying timestamps to users.
9. Performance Optimizations
- Webhook Handler Speed: Keep the
twilioCallback
function lightweight. Perform the essential tasks (validate signature, parse body, update DB) and return200 OK
quickly. Defer any heavy processing (e.g., complex analytics, triggering other workflows) to a background job queue if necessary. - Database Indexing: Ensure the
sid
column in theMessage
table is indexed for fast lookups during updates (@unique
usually ensures this). - Asynchronous Operations: Use
async/await
correctly to avoid blocking the Node.js event loop.
10. Monitoring, Observability, and Analytics
- Logging: Centralized logging (using platforms like Logtail, Datadog, Sentry) is crucial for monitoring activity and diagnosing issues in production. Ensure logs include the
MessageSid
. - Error Tracking: Integrate an error tracking service (like Sentry) to capture and alert on exceptions in both the
sendSms
service and thetwilioCallback
function. RedwoodJS has integrations available. - Health Checks: Implement a basic health check endpoint for your API side (e.g.,
/healthz
) that verifies database connectivity. Monitor this endpoint. - Twilio Console: Utilize the Twilio Console's Messaging Logs and Debugger tools to inspect message statuses, delivery errors, and webhook request details.
- Key Metrics: Monitor:
- Rate of outgoing SMS messages.
- Rate of incoming status callbacks.
- Latency of the
twilioCallback
handler. - Rate of signature validation failures (could indicate misconfiguration or attack).
- Database update success/failure rates for callbacks.
- Distribution of message statuses (
delivered
,failed
,undelivered
).
11. Troubleshooting and Caveats
- Callbacks Not Received:
- Incorrect
API_BASE_URL
/statusCallback
URL: Double-check the URL being sent to Twilio and ensure it's publicly accessible. Usengrok
for local testing. VerifyAPI_BASE_URL
is set in your environment. - Firewall Issues: Ensure your server/deployment environment allows incoming POST requests from Twilio's IP ranges (though signature validation is generally preferred over IP whitelisting).
- Function Errors: Check your API logs (
yarn rw log api
or your production logging service) for errors in thetwilioCallback
handler preventing it from returning200 OK
. - Missing
statusCallback
Parameter: Ensure you are actually providing thestatusCallback
URL when callingmessages.create
or that it's correctly configured on the Messaging Service.
- Incorrect
- Signature Validation Fails:
- Incorrect
TWILIO_AUTH_TOKEN
: Verify the token in your.env
matches the one in the Twilio console. - Incorrect URL in
validateRequest
: Ensure thecallbackUrl
passed tovalidateRequest
exactly matches the URL Twilio is sending the request to, including protocol (http
/https
), domain, and path (/.redwood/functions/twilioCallback
). Check for trailing slashes or port number discrepancies, especially behind proxies or load balancers. - Body Parsing Issues: Ensure the
params
object passed tovalidateRequest
accurately represents the raw POST body parameters sent by Twilio (usuallyapplication/x-www-form-urlencoded
). Ifevent.body
isn't automatically parsed correctly in your environment, manual parsing is required.
- Incorrect
- Database Errors:
- Connection Issues: Verify database credentials and network connectivity between your API function and the database.
- Record Not Found (
P2025
): As discussed, this can happen due to race conditions. Log the error and return200 OK
if the signature was valid. - Schema Mismatches: Ensure your database schema is up-to-date with your
schema.prisma
file (yarn rw prisma migrate dev
).
ngrok
Specifics:- When using
ngrok
, the public URL changes each time you restart it. Remember to update yourAPI_BASE_URL
in.env
(or wherever it's set) and potentially restart your Redwood dev server (yarn rw dev
) to reflect the new URL used in thestatusCallback
. - Use the
https
URL provided byngrok
.
- When using