How to Track Twilio SMS Delivery Status in RedwoodJS: Webhooks & Callbacks Guide
Step-by-step guide to implement Twilio SMS delivery status callbacks in RedwoodJS. Set up webhooks, validate signatures, and track message delivery with Prisma integration.
Tracking SMS delivery status is essential for reliable messaging applications. Twilio status callbacks (webhooks) notify your application when message status changes—from queued to sent, delivered, or failed. This allows you to monitor delivery in real-time, handle failures, and provide users with accurate delivery updates.
This guide shows you how to implement Twilio SMS delivery status webhooks in your RedwoodJS application. You'll learn to set up a webhook endpoint, validate Twilio callback requests securely, and track message status using Prisma database integration.
Project Goals:
Send SMS messages via the Twilio API from your RedwoodJS application.
Configure Twilio to send status updates to a dedicated webhook endpoint in your RedwoodJS app.
Securely handle incoming webhook requests from Twilio.
Store message details and update their delivery status in your database using Prisma.
Build a robust foundation for reliable SMS status tracking.
Technologies Used:
RedwoodJS: A full-stack JavaScript/TypeScript framework for the web. You'll use its structure and tooling for both frontend (React) and backend (Node.js, GraphQL API, Prisma) development.
Node.js: The runtime environment for your RedwoodJS API side.
Twilio Programmable Messaging: The Twilio service you'll use for sending SMS and receiving status callbacks.
Prisma: The database toolkit RedwoodJS uses for schema definition, migrations, and database access.
PostgreSQL (or SQLite/MySQL): Your underlying database where you'll store message statuses.
A user action (or backend process) triggers your RedwoodJS API to send an SMS.
Your RedwoodJS API calls the Twilio API, including a statusCallback URL pointing back to a specific endpoint on your 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 your specified statusCallback URL.
Your RedwoodJS API endpoint receives the webhook, validates its authenticity, parses the status, and updates the corresponding message record in your database.
Prerequisites:
Node.js: Version 18.x or higher required for RedwoodJS v7; Node.js 20.17.0+ required for RedwoodJS v8 (released June 2024). Node.js 21+ may cause compatibility issues with some deploy targets like AWS Lambda.
RedwoodJS: This guide is compatible with RedwoodJS v6+. For v8+, note that Webpack support has been dropped in favor of Vite.
Yarn: v1.15+ for RedwoodJS v7; v4.1.1+ required for RedwoodJS v8.
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 Your RedwoodJS Project for SMS Tracking
Create a new RedwoodJS project if you don't have one:
Bash
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:
Bash
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 your project root if it doesn't exist:
Text
# .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: Find these on your Twilio Console dashboard. Keep these secret! Never commit them to version control.
TWILIO_PHONE_NUMBER: Your Twilio number for sending messages.
API_BASE_URL: The publicly accessible base URL where your RedwoodJS API runs. Use your ngrok forwarding URL during local development. In production, use your deployed application's domain. You need this to construct the statusCallback URL.
Project Structure:
RedwoodJS organizes code into web (frontend) and api (backend) sides. You'll primarily work within the api directory:
api/src/functions/: For serverless function handlers (like your 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 your Twilio client instance).
2. Implementing SMS Delivery Status Tracking: Core Functionality
You need two main pieces: logic to send an SMS and specify the callback URL, and logic to receive and process the callback.
2.1. Create Database Schema to Store Message Status
Define a model in your Prisma schema to store message information, including its status.
Prisma
// 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:
Bash
yarn rw prisma migrate dev --name add_message_model
This creates the Message table in your database.
Understanding Twilio Message Status Values:
Twilio status callbacks include the MessageStatus parameter indicating the current state of your message. The possible values are:
queued: Message is queued and waiting to be sent
sending: Message is currently being sent to the carrier
sent: Message has been sent from Twilio to the carrier
delivered: Carrier confirmed delivery to the recipient's device
undelivered: Carrier could not deliver the message to the device
failed: Message failed to send (API request failed or carrier rejected)
Note: The ErrorCode field only appears in callback events for failed or undelivered messages. Common error codes include:
30003: Unreachable destination handset (device powered off or out of service)
30005: Unknown destination handset (number no longer in service)
30008: Unknown error (generic carrier error with no details)
Billing Note: Starting September 30, 2024, Twilio charges a "Failed message processing fee" for most messages in failed status. See Twilio's Error and Warning Dictionary for a complete list of error codes.
2.2. Creating a Twilio Client Instance
Initialize the Twilio client once and reuse it throughout your application.
Create a RedwoodJS service to encapsulate the logic for sending SMS messages via Twilio and creating the initial record in your database.
Bash
yarn rw g service sms
Implement the sendSms function in the generated service file.
JavaScript
// api/src/services/sms/sms.jsimport { 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 slashconst apiBaseUrl = (process.env.API_BASE_URL || '').replace(/\/$/, '')
const statusCallbackUrl = `${apiBaseUrl}/.redwood/functions/twilioCallback`exportconstsendSms = async ({ to, body }) => {
if (!to || !body) {
thrownewError("Both 'to' phone number and 'body' are required.")
}
if (!apiBaseUrl) {
logger.error('API_BASE_URL environment variable is not set. Cannot construct StatusCallback URL.')
thrownewError('Application configuration error: API_BASE_URL is missing.')
}
logger.info(`Attempting to send SMS to ${to}`)
try {
// Send message via Twilio, providing the StatusCallback URLconst twilioMessage = await twilioClient.messages.create({
body: body,
from: twilioPhoneNumber,
to: to,
statusCallback: statusCallbackUrl, // Tell Twilio where to send updates
})
logger.info(
`SMS queued successfully with SID: ${twilioMessage.sid}, Status: ${twilioMessage.status}`
)
// Create initial record in the databaseconst 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')
},
})
logger.info(
`Stored message in DB with ID: ${storedMessage.id} and SID: ${storedMessage.sid}`
)
// Return the database record, not the raw Twilio responsereturn storedMessage
} catch (error) {
logger.error(`Error sending SMS to ${to}: ${error.message}`, error)
thrownewError(`Failed to send SMS: ${error.message}`)
}
}
// Service function to retrieve message status from DBexportconstgetMessageStatus = 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.`)
returnnull
}
return message
}
exportconstmessages = () => {
return db.message.findMany()
}
Explanation:
Imports: Import the Prisma client (db), Redwood's logger, and your configured twilioClient and twilioPhoneNumber.
statusCallbackUrl: Construct the full URL for your webhook endpoint. It combines the API_BASE_URL (which must be publicly accessible) with the conventional Redwood path for functions (/.redwood/functions/) and your specific function name (twilioCallback). Set API_BASE_URL correctly for your environment.
twilioClient.messages.create: Call the Twilio API to send the message.
body, from, to: Standard message parameters.
statusCallback: Tell Twilio to send POST requests with status updates for this specific message to your twilioCallback function URL.
Database Creation: After successfully queueing the message with Twilio, create a record in your Message table using db.message.create. Store the sid returned by Twilio, along with other details and the initial status (usually queued).
Error Handling: The try...catch block logs errors. Production applications need more robust error handling.
Return Value: Return the message record created in your database.
2.4. Handle Twilio Status Callback Webhooks
Create the RedwoodJS function that Twilio will call.
Bash
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 and MessageStatus (and ErrorCode if present) from the request body.
Update the corresponding message record in the database.
Return a 200 OK response to Twilio.
JavaScript
// api/src/functions/twilioCallback.jsimport { logger } from'src/lib/logger'import { db } from'src/lib/db'import { validateRequest } from'twilio'/**
* @param {import("@redwoodjs/api").APIGatewayEvent} event – The incoming request event
* @param {import("@redwoodjs/api").Context} context – The context object
*/exportconsthandler = 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 slashconst apiBaseUrl = (process.env.API_BASE_URL || '').replace(/\/$/, '')
const callbackUrl = `${apiBaseUrl}${event.path}`// event.path includes /.redwood/functions/twilioCallbackif (!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).const params = event.body// validateRequest expects the raw POST body params as an object.const isValid = validateRequest(authToken, twilioSignature, callbackUrl, params || {})
if (!isValid) {
logger.error('Invalid Twilio signature. Request rejected.')
return {
statusCode: 403, // Forbiddenbody: 'Invalid Twilio signature.',
}
}
logger.info('Twilio signature validated successfully.')
// --- 2. Parse Request Body ---const messageSid = params?.MessageSidconst messageStatus = params?.MessageStatusconst errorCode = params?.ErrorCode ? parseInt(params.ErrorCode, 10) : nullconst errorMessage = params?.ErrorMessage || nullif (!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.return {
statusCode: 200,
headers: { 'Content-Type': 'text/xml' },
body: '<Response></Response>',
}
}
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,
},
})
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 foundif (error.code === 'P2025') { // Prisma code for Record not found
logger.error(
`Message with SID ${messageSid} not found in database for update.`
)
} else {
logger.error(
`Database error updating status for SID ${messageSid}: ${error.message}`,
error
)
return {
statusCode: 200, // Acknowledge receipt despite DB error to stop retriesheaders: { 'Content-Type': 'text/xml' },
body: '<Response></Response>',
}
}
}
// --- 4. Respond to Twilio ---// Twilio expects a 200 OK response to acknowledge receipt.return {
statusCode: 200,
headers: { 'Content-Type': 'text/xml' },
body: '<Response></Response>',
}
}
Explanation:
Signature Validation: This is critical for security. validateRequest from the twilio library checks if the x-twilio-signature header matches a signature calculated using your TWILIO_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 a 403 Forbidden status.
Parsing: Extract MessageSid, MessageStatus, ErrorCode, and ErrorMessage from the request parameters (event.body). Note: This code assumes you are using RedwoodJS v6 or later, which automatically parses application/x-www-form-urlencoded request bodies into the event.body object.
Database Update: Use db.message.update to find the message record by its unique sid and update the status, errorCode, and errorMessage fields. Include error handling, especially for the case where the message might not (yet) exist in the DB (Prisma error P2025).
Response: 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:
GraphQL
// api/src/graphql/sms.sdl.ts
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!}typeMutation{
sendSms(to: String!, body: String!): Message!@requireAuth# Or use @skipAuth if authentication isn't needed for this action}# Query to fetch status by SIDtypeQuery{
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.
Call this mutation from your RedwoodJS web side (or any GraphQL client) after implementing authentication (@requireAuth).
API Credentials: As covered in Step 1, store TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN securely in your .env file and ensure they are available as environment variables in your deployment environment. Access them via process.env.
Twilio Phone Number: Store your TWILIO_PHONE_NUMBER in .env.
Status Callback URL:
Per-Message (Implemented Above): By providing the statusCallback parameter in the client.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. Ensure API_BASE_URL is correctly set.
Messaging Service Level: Alternatively, configure a default Status Callback URL within a Twilio Messaging Service. If you send messages using a messagingServiceSid instead of a from number, and don't provide a statusCallback parameter in the API call, Twilio will use the URL configured on the 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 of from.
Note: A statusCallback URL provided in the API call overrides the Messaging Service setting for that specific message.
5. Handle Errors and Retry Logic for Delivery Callbacks
Error Handling:
Wrap Twilio API calls (messages.create) in try...catch blocks in your service (sendSms). Log errors clearly.
In the callback handler (twilioCallback), handle potential database errors gracefully (e.g., message not found P2025, other DB connection issues). Return 200 OK even on handled DB errors post-signature validation to prevent excessive Twilio retries.
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.
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 with where: { sid: ... }, which is generally safe).
Validate signatures and return 200 OK promptly (even on some internal errors after validation) to prevent unnecessary retries.
6. Database Schema and Data Layer
The Message model in schema.prisma (Step 2.1) defines the structure.
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 the Message table has an index (Prisma adds @unique which typically creates an index). Database connection pooling (handled by Prisma) is essential.
7. Secure Your Webhook Endpoint with Signature Validation
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.
Twilio cryptographically signs all HTTP requests using HMAC-SHA1 with your Auth Token as the key, providing the hash in the X-Twilio-Signature header.
Always use Twilio's SDK helper libraries for validation rather than implementing your own. The helper libraries handle signature validation correctly and account for evolving parameters.
Twilio occasionally adds new parameters to webhooks without advance notice, so your implementation must accept and validate an evolving parameter set.
HTTPS Endpoints (Required): Host your webhook endpoints on https:// URLs with valid SSL certificates. While signature validation prevents request tampering, HTTPS prevents replay attacks where captured requests could be resent by attackers.
Enable SSL Certificate Validation in your Twilio Console project Settings to enforce validation on all webhooks.
Avoid certificate pinning – Twilio strongly recommends against this outdated practice as certificates can be rotated at any time.
Environment Variables: Protect your TWILIO_AUTH_TOKEN and TWILIO_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 the to number, length of the body). 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.
Additional Security Layers: Add HTTP Basic Authentication to webhook URLs for defense in depth, requiring username/password combinations to access endpoints.
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 as queued → 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. The RawDlrDoneDate property (present for delivered/undelivered SMS/MMS) can provide a carrier timestamp.
Evolving Webhook Parameters: Twilio may add new parameters to status callback requests without advance notice. The properties included vary by messaging channel and event type. Design your webhook handler to gracefully accept additional parameters beyond the core fields (MessageSid, MessageStatus, ErrorCode). Both SmsStatus and MessageStatus fields are included in callbacks for backward compatibility.
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 in twilioCallback (checking for Prisma P2025) should log this. You might implement a small delay/retry mechanism within the callback if this becomes a frequent issue, but often logging and returning 200 OK is sufficient.
9. Performance Optimizations
Webhook Handler Speed: Keep the twilioCallback function lightweight. Perform the essential tasks (validate signature, parse body, update DB) and return 200 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 the Message 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 the twilioCallback 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 Common Status Callback Issues
Callbacks Not Received:
Incorrect API_BASE_URL / statusCallback URL: Double-check the URL being sent to Twilio and ensure it's publicly accessible. Use ngrok for local testing. Verify API_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 the twilioCallback handler preventing it from returning 200 OK.
Missing statusCallback Parameter: Ensure you are actually providing the statusCallback URL when calling messages.create or that it's correctly configured on the Messaging Service.
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 the callbackUrl passed to validateRequest 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 to validateRequest accurately represents the raw POST body parameters sent by Twilio (usually application/x-www-form-urlencoded). If event.body isn't automatically parsed correctly in your environment, manual parsing is required.
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 return 200 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 your API_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 the statusCallback.
Use the https URL provided by ngrok.
Related Resources
Learn more about SMS delivery tracking and RedwoodJS integration:
Set up a status callback webhook endpoint in your application and provide the webhook URL when sending messages via the Twilio API. Twilio will POST status updates to your endpoint as the message progresses through queued, sent, delivered, or failed states.
What is the difference between Twilio sent and delivered status?
"Sent" means Twilio successfully handed the message to the carrier network. "Delivered" means the carrier confirmed the message reached the recipient's device. Delivery confirmation depends on carrier support and may not be available for all networks.
Why are my Twilio SMS messages undelivered?
Common causes include invalid phone numbers, devices powered off or out of service (error 30003), numbers no longer in service (error 30005), carrier filtering, or network issues. Check the ErrorCode in your webhook callback for specific failure reasons.
How much does Twilio charge for failed SMS messages?
As of September 30, 2024, Twilio charges a $0.001 failed message processing fee per message that terminates in "Failed" status. This applies to most failure scenarios but not all error codes.
Frequently asked questions
Twilio's status callbacks (webhooks) notify your application about message status changes. You need to configure a webhook endpoint in your application to receive these real-time updates, which include delivery, failure, or in-transit statuses. This enables better communication and debugging.
A Twilio status callback is a webhook that Twilio sends to your application to provide updates on the delivery status of your SMS messages. These updates are sent as HTTP POST requests to a URL you specify, containing details like message SID and status (e.g., queued, sent, delivered, failed).
Twilio offers a reliable and programmable messaging API that simplifies sending SMS and receiving delivery status updates via webhooks. It handles the complexities of carrier networks, providing a robust foundation for SMS communication.
Use Twilio status callbacks whenever real-time tracking of SMS message delivery is important. This includes scenarios requiring user notifications, message debugging, delivery confirmation, and analytics on message success/failure rates.
Yes, this article provides a comprehensive guide on integrating Twilio's SMS delivery status callbacks into a RedwoodJS application. You'll set up a webhook endpoint, validate requests securely, and update message statuses in your database.
Set up a serverless function in your RedwoodJS API to act as the webhook endpoint. Then, configure your Twilio account to send status updates to this endpoint by providing its URL when sending messages or configuring it at the Messaging Service level in the Twilio Console.
The statusCallback URL is the endpoint in your application that Twilio will send HTTP POST requests to with message delivery status updates. It must be a publicly accessible URL and is provided when sending the message or configured in your Twilio Messaging Service.
Validating Twilio webhook signatures is crucial for security. It ensures that incoming requests genuinely originated from Twilio and haven't been tampered with, protecting your application from fraudulent status updates.
Use the validateRequest function from the Twilio Node.js helper library to verify the signature of incoming webhook requests. This function compares the signature in the x-twilio-signature header with a calculated signature using your auth token and request details, ensuring authenticity.
The article recommends Prisma as the database toolkit with RedwoodJS, allowing for flexible schema definition, migrations, and data access. It supports PostgreSQL, SQLite, and MySQL as the underlying database.
Use ngrok during local development to create a publicly accessible URL for your RedwoodJS API. This allows Twilio to send status callbacks to your local machine for testing before deployment.
Wrap your callback handler logic in a try...catch block to handle potential database errors or missing parameters. Log errors for debugging, but return a 200 OK response to Twilio even if errors occur after signature validation to prevent excessive retries.
The x-twilio-signature header in Twilio webhook requests contains a cryptographic signature of the request. This signature is used to validate the request's authenticity and ensure it came from Twilio, preventing unauthorized access.
Use a tool like ngrok to create a temporary public URL that forwards requests to your local development server. Configure this URL as your statusCallback URL in Twilio, enabling you to test webhook handling locally.
Tracking SMS delivery status is essential for reliable messaging applications. Twilio status callbacks (webhooks) notify your application when message status changes—from queued to sent, delivered, or failed. This allows you to monitor delivery in real-time, handle failures, and provide users with accurate delivery updates.
This guide shows you how to implement Twilio SMS delivery status webhooks in your RedwoodJS application. You'll learn to set up a webhook endpoint, validate Twilio callback requests securely, and track message status using Prisma database integration.
Project Goals:
Technologies Used:
System Architecture:
statusCallbackURL pointing back to a specific endpoint on your RedwoodJS API.statusCallbackURL.Prerequisites:
ngrokfor testing webhooks locally during development.1. Setting Up Your RedwoodJS Project for SMS Tracking
Create a new RedwoodJS project if you don't have one:
yarn create redwood-app ./twilio-status-app cd twilio-status-appInstall 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 rootConfigure Environment Variables:
RedwoodJS uses
.envfiles for environment variables. Create a.envfile in your project root if it doesn't exist:TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN: Find these on your Twilio Console dashboard. Keep these secret! Never commit them to version control.TWILIO_PHONE_NUMBER: Your Twilio number for sending messages.API_BASE_URL: The publicly accessible base URL where your RedwoodJS API runs. Use your ngrok forwarding URL during local development. In production, use your deployed application's domain. You need this to construct thestatusCallbackURL.Project Structure:
RedwoodJS organizes code into
web(frontend) andapi(backend) sides. You'll primarily work within theapidirectory:api/src/functions/: For serverless function handlers (like your 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 your Twilio client instance).2. Implementing SMS Delivery Status Tracking: Core Functionality
You need two main pieces: logic to send an SMS and specify the callback URL, and logic to receive and process the callback.
2.1. Create Database Schema to Store Message Status
Define a model in your Prisma schema to store message information, including its status.
Apply Migrations:
Generate and apply the database migration:
This creates the
Messagetable in your database.Understanding Twilio Message Status Values:
Twilio status callbacks include the
MessageStatusparameter indicating the current state of your message. The possible values are:queued: Message is queued and waiting to be sentsending: Message is currently being sent to the carriersent: Message has been sent from Twilio to the carrierdelivered: Carrier confirmed delivery to the recipient's deviceundelivered: Carrier could not deliver the message to the devicefailed: Message failed to send (API request failed or carrier rejected)Note: The
ErrorCodefield only appears in callback events forfailedorundeliveredmessages. Common error codes include:Billing Note: Starting September 30, 2024, Twilio charges a "Failed message processing fee" for most messages in
failedstatus. See Twilio's Error and Warning Dictionary for a complete list of error codes.2.2. Creating a Twilio Client Instance
Initialize the Twilio client once and reuse it throughout your application.
// 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) { 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) { 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 your database.
Implement the
sendSmsfunction 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, // Tell Twilio where to send updates }) logger.info( `SMS queued successfully with SID: ${twilioMessage.sid}, Status: ${twilioMessage.status}` ) // Create initial record in the 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') }, }) logger.info( `Stored message in DB with ID: ${storedMessage.id} and SID: ${storedMessage.sid}` ) // Return the database record, not the raw Twilio response return storedMessage } catch (error) { logger.error(`Error sending SMS to ${to}: ${error.message}`, error) throw new Error(`Failed to send SMS: ${error.message}`) } } // 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.`) return null } return message } export const messages = () => { return db.message.findMany() }Explanation:
db), Redwood's logger, and your configuredtwilioClientandtwilioPhoneNumber.statusCallbackUrl: Construct the full URL for your webhook endpoint. It combines theAPI_BASE_URL(which must be publicly accessible) with the conventional Redwood path for functions (/.redwood/functions/) and your specific function name (twilioCallback). SetAPI_BASE_URLcorrectly for your environment.twilioClient.messages.create: Call the Twilio API to send the message.body,from,to: Standard message parameters.statusCallback: Tell Twilio to send POST requests with status updates for this specific message to yourtwilioCallbackfunction URL.Messagetable usingdb.message.create. Store thesidreturned by Twilio, along with other details and the initialstatus(usuallyqueued).try...catchblock logs errors. Production applications need more robust error handling.2.4. Handle Twilio Status Callback Webhooks
Create the RedwoodJS function that Twilio will call.
yarn rw g function twilioCallback --typescript=false # Or --typescript=true if using TSImplement the handler logic in the generated file. This endpoint needs to:
MessageSidandMessageStatus(andErrorCodeif present) from the request body.200 OKresponse to Twilio.// api/src/functions/twilioCallback.js import { logger } from 'src/lib/logger' import { db } from 'src/lib/db' import { validateRequest } from 'twilio' /** * @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). const params = event.body // validateRequest expects the raw POST body params as an object. const isValid = validateRequest(authToken, twilioSignature, callbackUrl, params || {}) 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 --- const messageSid = params?.MessageSid const messageStatus = params?.MessageStatus const errorCode = params?.ErrorCode ? parseInt(params.ErrorCode, 10) : null const errorMessage = params?.ErrorMessage || null 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. return { statusCode: 200, headers: { 'Content-Type': 'text/xml' }, body: '<Response></Response>', } } 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, }, }) 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 if (error.code === 'P2025') { // Prisma code for Record not found logger.error( `Message with SID ${messageSid} not found in database for update.` ) } else { logger.error( `Database error updating status for SID ${messageSid}: ${error.message}`, error ) return { statusCode: 200, // 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. return { statusCode: 200, headers: { 'Content-Type': 'text/xml' }, body: '<Response></Response>', } }Explanation:
validateRequestfrom thetwiliolibrary checks if thex-twilio-signatureheader 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 Forbiddenstatus.MessageSid,MessageStatus,ErrorCode, andErrorMessagefrom the request parameters (event.body). Note: This code assumes you are using RedwoodJS v6 or later, which automatically parsesapplication/x-www-form-urlencodedrequest bodies into theevent.bodyobject.db.message.updateto find the message record by its uniquesidand update thestatus,errorCode, anderrorMessagefields. Include error handling, especially for the case where the message might not (yet) exist in the DB (Prisma errorP2025).200 OKstatus 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
sendSmsservice. A common RedwoodJS approach is via a GraphQL mutation.Define GraphQL Schema:
// api/src/graphql/sms.sdl.ts 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 } # 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.jsfile exports functions matching the mutation and query names (sendSmsandmessageStatus). We already did this in Step 2.3.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 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" 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" validation={{ required: true }} errorClassName="error" /> <FieldError name="body" className="error" /> <Submit disabled={loading}>{loading ? 'Sending…' : 'Send SMS'}</Submit> </Form> ) } export default SendMessageForm4. Configure Twilio Status Callback URL
TWILIO_ACCOUNT_SIDandTWILIO_AUTH_TOKENsecurely in your.envfile and ensure they are available as environment variables in your deployment environment. Access them viaprocess.env.TWILIO_PHONE_NUMBERin.env.statusCallbackparameter in theclient.messages.createcall, 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_URLis correctly set.messagingServiceSidinstead of afromnumber, and don't provide astatusCallbackparameter in the API call, Twilio will use the URL configured on the service.twilioCallbackfunction (e.g.,https://your-app-domain.com/.redwood/functions/twilioCallback).messagingServiceSidinstead offrom.statusCallbackURL provided in the API call overrides the Messaging Service setting for that specific message.5. Handle Errors and Retry Logic for Delivery Callbacks
messages.create) intry...catchblocks in your service (sendSms). Log errors clearly.twilioCallback), handle potential database errors gracefully (e.g., message not foundP2025, other DB connection issues). Return200 OKeven on handled DB errors post-signature validation to prevent excessive Twilio retries.MessageSid,MessageStatus).logger(import { logger } from 'src/lib/logger').MessageSidin logs for correlation.twilioCallbackendpoint returns a non-2xxstatus code (e.g.,4xx,5xx) or times out, Twilio will retry the request.updatewithwhere: { sid: ... }, which is generally safe).200 OKpromptly (even on some internal errors after validation) to prevent unnecessary retries.6. Database Schema and Data Layer
Messagemodel inschema.prisma(Step 2.1) defines the structure.yarn rw prisma migrate dev) handle schema changes (Step 2.1).sms.js) using the Prisma client (db) for creating and updating records (Steps 2.3 & 2.4).sidcolumn in theMessagetable has an index (Prisma adds@uniquewhich typically creates an index). Database connection pooling (handled by Prisma) is essential.7. Secure Your Webhook Endpoint with Signature Validation
validateRequest). It prevents unauthorized actors from forging status updates. Never skip this step.X-Twilio-Signatureheader.https://URLs with valid SSL certificates. While signature validation prevents request tampering, HTTPS prevents replay attacks where captured requests could be resent by attackers.TWILIO_AUTH_TOKENandTWILIO_ACCOUNT_SID. Do not commit them to your repository. Use.envlocally and secure environment variable management in your deployment environment.sendSmsmutation (e.g., format of thetonumber, length of thebody). Redwood's forms provide basic validation helpers.@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.sendSmsmutation endpoint to prevent abuse. The callback endpoint implicitly relies on Twilio's rate limiting for sending webhooks.8. Handling Special Cases
queued→sending→sentquickly, 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. TheRawDlrDoneDateproperty (present for delivered/undelivered SMS/MMS) can provide a carrier timestamp.MessageSid,MessageStatus,ErrorCode). BothSmsStatusandMessageStatusfields are included in callbacks for backward compatibility.sendSmsfunction 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 OKis sufficient.9. Performance Optimizations
twilioCallbackfunction lightweight. Perform the essential tasks (validate signature, parse body, update DB) and return200 OKquickly. Defer any heavy processing (e.g., complex analytics, triggering other workflows) to a background job queue if necessary.sidcolumn in theMessagetable is indexed for fast lookups during updates (@uniqueusually ensures this).async/awaitcorrectly to avoid blocking the Node.js event loop.10. Monitoring, Observability, and Analytics
MessageSid.sendSmsservice and thetwilioCallbackfunction. RedwoodJS has integrations available./healthz) that verifies database connectivity. Monitor this endpoint.twilioCallbackhandler.delivered,failed,undelivered).11. Troubleshooting Common Status Callback Issues
API_BASE_URL/statusCallbackURL: Double-check the URL being sent to Twilio and ensure it's publicly accessible. Usengrokfor local testing. VerifyAPI_BASE_URLis set in your environment.yarn rw log apior your production logging service) for errors in thetwilioCallbackhandler preventing it from returning200 OK.statusCallbackParameter: Ensure you are actually providing thestatusCallbackURL when callingmessages.createor that it's correctly configured on the Messaging Service.TWILIO_AUTH_TOKEN: Verify the token in your.envmatches the one in the Twilio console.validateRequest: Ensure thecallbackUrlpassed tovalidateRequestexactly 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.paramsobject passed tovalidateRequestaccurately represents the raw POST body parameters sent by Twilio (usuallyapplication/x-www-form-urlencoded). Ifevent.bodyisn't automatically parsed correctly in your environment, manual parsing is required.P2025): As discussed, this can happen due to race conditions. Log the error and return200 OKif the signature was valid.schema.prismafile (yarn rw prisma migrate dev).ngrokSpecifics:ngrok, the public URL changes each time you restart it. Remember to update yourAPI_BASE_URLin.env(or wherever it's set) and potentially restart your Redwood dev server (yarn rw dev) to reflect the new URL used in thestatusCallback.httpsURL provided byngrok.Related Resources
Learn more about SMS delivery tracking and RedwoodJS integration:
Frequently Asked Questions
How do I check SMS delivery status in Twilio?
Set up a status callback webhook endpoint in your application and provide the webhook URL when sending messages via the Twilio API. Twilio will POST status updates to your endpoint as the message progresses through queued, sent, delivered, or failed states.
What is the difference between Twilio sent and delivered status?
"Sent" means Twilio successfully handed the message to the carrier network. "Delivered" means the carrier confirmed the message reached the recipient's device. Delivery confirmation depends on carrier support and may not be available for all networks.
Why are my Twilio SMS messages undelivered?
Common causes include invalid phone numbers, devices powered off or out of service (error 30003), numbers no longer in service (error 30005), carrier filtering, or network issues. Check the
ErrorCodein your webhook callback for specific failure reasons.How much does Twilio charge for failed SMS messages?
As of September 30, 2024, Twilio charges a $0.001 failed message processing fee per message that terminates in "Failed" status. This applies to most failure scenarios but not all error codes.