Twilio SMS Delivery Status & Callbacks with Fastify: Complete Guide
Build a production-ready Node.js Fastify app to send SMS via Twilio and track delivery status using webhooks. Includes secure validation, error handling, and database integration.
This guide provides a step-by-step walkthrough for building a production-ready Node.js application using the Fastify framework to send SMS messages via Twilio and reliably track their delivery status using Twilio's status callback webhooks. You'll learn how to implement Twilio SMS delivery tracking with secure webhook validation in a high-performance Node.js SMS tracking system.
We will build a system that can:
Expose an API endpoint to trigger sending an SMS message.
Send the SMS message using the Twilio Programmable Messaging API.
Provide a webhook endpoint for Twilio to send message status updates (e.g., queued, sent, delivered, undelivered).
Log message status updates for tracking and debugging.
This approach solves the common problem of needing to know if and when an SMS message reaches the recipient, which is crucial for applications involving notifications, alerts, verification codes, or transactional messaging.
Technologies Used:
Node.js: A JavaScript runtime environment.
Fastify: A high-performance, low-overhead web framework for Node.js. Chosen for its speed, extensibility, and developer-friendly features.
Twilio: A communication platform as a service (CPaaS) providing APIs for SMS, voice, and more.
twilio Node.js Helper Library: Simplifies interaction with the Twilio API.
dotenv: Loads environment variables from a .env file for secure configuration.
nodemon (Development): Monitors for file changes and automatically restarts the server during development.
ngrok (Development): Creates a secure tunnel to expose your local server to the internet, necessary for receiving Twilio webhooks during development.
System Architecture:
graph LR
A[Client/User] -- 1. POST /send-sms --> B(Fastify App);
B -- 2. Send SMS Request --> C(Twilio API);
C -- 3. SMS Sent --> D(Recipient's Phone);
C -- 4. Status Update (Webhook POST) --> E{ngrok (Dev) / Public URL (Prod)};
E -- 5. Forward Webhook --> B;
B -- 6. Process Status & Log --> F[(Logs / Database)];
Prerequisites:
Node.js and npm (or yarn) installed.
A free or paid Twilio account.
A Twilio phone number capable of sending SMS.
ngrok installed or accessible (e.g., via npx ngrok). Download from ngrok.com or use npx ngrok http <port> directly in your terminal for temporary use without global installation.
Final Outcome:
By the end of this guide, you will have a running Fastify application capable of sending SMS messages and logging their delivery status updates received via Twilio webhooks. You will also have a solid foundation for integrating this functionality into larger applications, including secure webhook handling and basic status tracking.
1. Project Setup
Let's initialize our Node.js project and install the necessary dependencies.
Create Project Directory:
Open your terminal and create a new directory for the project, then navigate into it.
Bash
mkdir fastify-twilio-sms
cd fastify-twilio-sms
Initialize Node.js Project:
This creates a package.json file to manage dependencies and scripts.
Bash
npm init -y
Install Dependencies:
We need Fastify, the Twilio helper library, and dotenv.
Bash
npm install fastify twilio dotenv
Install Development Dependencies:nodemon helps streamline development by auto-restarting the server on file changes.
Bash
npm install --save-dev nodemon
Configure nodemon Script:
Open your package.json file and add a dev script within the "scripts" section:
JSON
{"scripts":{"test":"echo \"Error: no test specified\" && exit 1","start":"node server.js","dev":"nodemon server.js"}}
This allows you to run npm run dev to start the server with nodemon.
Create Server File:
Create the main application file, server.js.
Bash
touch server.js
Basic Fastify Server:
Add the initial Fastify setup code to server.js.
Now, create a .gitignore file to prevent committing secrets:
Bash
touch .gitignore
Add the following to .gitignore:
Text
node_modules
.env
npm-debug.log
Explanation:
We use dotenv to load variables from .env into process.env.
.env.example serves as documentation for required variables.
.gitignore ensures sensitive data (.env) and generated folders (node_modules) aren't committed.
host: '0.0.0.0' makes the server accessible within your local network and is often needed for containerized environments.
Run the Development Server:
Start the server using the nodemon script.
Bash
npm run dev
You should see output indicating the server is listening on port 3000 (or your configured PORT). You can test the basic route by visiting http://localhost:3000 in your browser or using curl http://localhost:3000.
2. Twilio Configuration
Before sending messages, you need your Twilio credentials and a phone number.
We expect a JSON body with to (recipient phone number) and body (message text).
Basic validation checks if to and body are provided.
Crucially, we construct the statusCallback URL using the BASE_URL environment variable and appending /message-status (the path for our webhook handler, which we'll create next).
We call client.messages.create, passing the to, from (our Twilio number), body, and the statusCallback URL.
If successful, Twilio immediately returns a message object, often with status queued. The actual sending happens asynchronously. We log the SID and initial status.
Error handling catches potential issues during the API call (e.g., invalid credentials, invalid to number format). We log relevant details from the Twilio error object.
4. Implementing the Status Callback Endpoint
This endpoint will receive POST requests from Twilio whenever the status of an outbound message changes.
Create /message-status Route:
Add another POST route in server.js to handle incoming webhooks.
Twilio sends data as application/x-www-form-urlencoded, but Fastify typically parses this into request.body automatically.
We extract key fields like MessageSid, MessageStatus, and ErrorCode (if present). See Twilio docs for all possible fields.
We log the received information. This is where you'd later add logic to update a database or trigger other actions based on the status.
Crucially, we send back an HTTP 200 OK response. If Twilio doesn't receive a 200, it might retry the webhook, leading to duplicate processing.
5. Exposing Your Local Server with ngrok
Twilio needs to send HTTP requests to your application, which means your app needs a publicly accessible URL. During development on your local machine, ngrok provides this.
Ensure Your Server is Running:
Make sure your Fastify server is running (npm run dev). Note the port (e.g., 3000).
Start ngrok:
Open a new, separate terminal window (leave the server running in the first one) and run:
Bash
npx ngrok http 3000
Copy Your Public URL:ngrok will display output similar to this:
Code
ngrok by @inconshreveable (Ctrl+C to quit)
Session Status online
Account Your Name (Plan: Free)
Version 3.x.x
Region United States (us-east-1)
Web Interface http://127.0.0.1:4040
Forwarding https://<random-string>.ngrok-free.app -> http://localhost:3000
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
Copy the https://<random-string>.ngrok-free.app URL (use the https version). This is your temporary public URL.
Update BASE_URL:
Go back to your .env file and update the BASE_URL variable with the full ngrok URL you just copied.
.env
BASE_URL=https://<random-string>.ngrok-free.app
Restart Your Server:
Stop your Fastify server (Ctrl+C in its terminal) and restart it (npm run dev) to load the new BASE_URL.
Now, when your application calls client.messages.create, it will provide Twilio with the ngrok URL (e.g., https://<random-string>.ngrok-free.app/message-status) as the statusCallback. Twilio can reach this URL via ngrok, which tunnels the request back to your local Fastify server running on port 3000.
6. Securing Your Callback Endpoint
Anyone could potentially send requests to your /message-status endpoint. You must verify that incoming webhook requests genuinely originate from Twilio. The twilio helper library provides a function for this.
Import Webhook Validation Function:
Add the necessary import at the top of server.js, alongside the main twilio import.
Modify /message-status Route for Validation:
Update the /message-status route to use the validation function. Twilio's validation requires the Auth Token, the signature header it sends (X-Twilio-Signature), the full request URL, and the request parameters (body).
We import validateRequest from twilio.webhook at the top of the file.
Inside the /message-status handler, we retrieve the X-Twilio-Signature header sent by Twilio.
We reconstruct the fullUrl that Twilio used to send the request. request.url in Fastify includes the path and query string (e.g., /message-status). baseUrl must be accurate (your ngrok or production URL).
We call validateRequest with your authToken, the signature, the URL, and the request body (request.body which Fastify has parsed).
If isValid is false, we log a warning and return a 403 Forbidden status, stopping further processing. This prevents unauthorized access.
If valid, we proceed to log the status and (in this example) store it in the simple in-memory object messageStatuses. Important: This in-memory store is for demonstration purposes only and is unsuitable for production as data is lost on server restart. Use a persistent database.
An optional /get-status/:sid route is added to retrieve the stored status history for a given message SID, useful for debugging.
7. Persisting Message Status (Database Layer - Conceptual)
The previous step used an in-memory object (messageStatuses) for simplicity. In a production application, you must use a persistent data store like a database (e.g., PostgreSQL, MongoDB, Redis) to store message status updates.
Conceptual Steps:
Choose a Database: Select a database suitable for your needs.
Design Schema: Create a table or collection to store message information. A possible schema could include:
message_sid (Primary Key, VARCHAR/String) - The Twilio Message SID (SMxxx...)
account_sid (VARCHAR/String) - Your Twilio Account SID
to_number (VARCHAR/String)
from_number (VARCHAR/String)
body (TEXT) - Optional, the message content
initial_status (VARCHAR/String) - Status returned by messages.create (e.g., queued)
latest_status (VARCHAR/String) - The most recent status received via webhook (e.g., delivered, undelivered)
error_code (INTEGER) - The Twilio error code if status is failed or undelivered
status_history (JSON/TEXT) - Optional, store the full history of status updates with timestamps.
created_at (TIMESTAMP)
updated_at (TIMESTAMP)
Implement Data Layer: Use an ORM (like Prisma, Sequelize, TypeORM) or a database driver (like pg, mysql2, mongodb) to interact with your database.
Update /send-sms: When a message is successfully sent (client.messages.create), insert a new record into your database with the message_sid, to_number, from_number, initial_status, etc.
Update /message-status: Inside the validated webhook handler, find the database record matching the incoming MessageSid. Update the latest_status, error_code (if applicable), and updated_at timestamp. Optionally append the new status to the status_history field.
Conceptual Example using Prisma:
Note: The following example uses Prisma with TypeScript syntax for illustration. You would adapt this using Prisma's JavaScript client or your chosen database library in your JavaScript server.js file.
TypeScript
// prisma/schema.prisma (Example Schema)
model MessageLog {
messageSid String@id@unique
accountSid String
toNumber String
fromNumber String
body String?
initialStatus String
latestStatus String
errorCode Int?
statusHistory Json? @default("[]")
createdAt DateTime@default(now())
updatedAt DateTime@updatedAt
}
// Conceptual usage in /message-status handlertry {
const updatedLog = await prisma.messageLog.update({
where: { messageSid: MessageSid },
data: {
latestStatus: MessageStatus,
errorCode: ErrorCode ? parseInt(ErrorCode, 10) : null,
},
});
fastify.log.info(`Updated DB status for ${MessageSid} to ${MessageStatus}`);
} catch (dbError) {
fastify.log.error(`DB update failed for ${MessageSid}: ${dbError.message}`);
}
This section provides the high-level approach. Implementing a full database layer with migrations, connection pooling, and robust error handling is beyond the scope of this specific guide but is essential for production.
8. Implement Error Handling and Logging
Build robust error handling and logging to diagnose issues effectively.
Fastify Logging: You initialized Fastify with logger: true, which provides basic request/response logging and methods like fastify.log.info(), fastify.log.error(), etc. Configure log levels and output destinations (e.g., file, external service) in production using Fastify's logging options or Pino directly.
Twilio API Errors: The try…catch block in /send-sms handles errors from client.messages.create. Log the error.message and potentially error.code or error.status provided by the Twilio library for more specific details.
Webhook Errors:
Log invalid signature attempts in /message-status.
Log any errors encountered while processing the webhook (e.g., database update failures). Ensure you still send a 200 OK to Twilio unless the error is fundamental to receiving the request itself.
Log the ErrorCode received from Twilio when MessageStatus is failed or undelivered. Consult the Twilio Error Dictionary to understand these codes.
Network Issues: Implement retries with exponential backoff if your application needs to call other external services based on status updates, as network conditions can cause transient failures. Twilio handles retries for webhook delivery itself if it doesn't receive a 200 OK from your endpoint.
Unit Tests: Test individual functions, like the webhook validation logic or database interaction functions, in isolation using testing frameworks like Jest or Mocha.
Integration Tests:
Send SMS Endpoint (/send-sms):
Use curl or Postman/Insomnia to send requests.
Test success case: Valid to number and body. Verify a 200 OK response with messageSid. Check server logs for "Attempting to send SMS…" and "SMS queued successfully…" messages.
Test missing fields: Send request without to or body. Verify a 400 Bad Request response.
Test invalid recipient: Send to a known invalid number format (e.g., +1). Verify appropriate error response from Twilio (likely 400) proxied by your endpoint.
Test invalid credentials (temporarily modify .env): Verify a 401 or similar error.
Status Callback Endpoint (/message-status):
Real Webhooks: Send a valid SMS using /send-sms. Monitor the server logs (and ngrok console http://127.0.0.1:4040) for incoming POST requests to /message-status. Verify statuses like queued, sent, and finally delivered (or undelivered/failed if applicable) are logged. Check the X-Twilio-Signature is present and the Webhook VALIDATED message appears.
Manual Test (Validation): Use curl to send a POST request to your /message-status ngrok URL without a valid X-Twilio-Signature header, but with a sample body. Verify you get a 403 Forbidden response and a log message about an invalid signature.
Manual Test (Processing): Use curl to send a POST request with a valid signature (can be tricky to generate manually, easier to rely on real Twilio webhooks) or temporarily disable validation for testing only. Verify the status is logged/stored correctly. Check the /get-status/:sid endpoint (if implemented).
Create .env file with valid Twilio credentials and correct BASE_URL.
Start server without errors (npm run dev).
Run ngrok (if testing locally) and point BASE_URL to the https ngrok URL.
Send POST to /send-sms with valid to and body – returns 200 OK and logs success.
Receive test SMS message on target phone.
View incoming POST requests to /message-status in server logs after sending SMS.
Confirm Webhook VALIDATED message appears in logs for incoming status updates.
Observe expected status transitions (e.g., queued → sent → delivered). (Check /get-status/:sid if implemented).
Send POST to /message-status without valid signature – returns 403 Forbidden.
Query /get-status/:sid (if implemented) – returns status history for valid SID.
Example curl command for /send-sms:
Bash
curl -X POST http://localhost:3000/send-sms \
-H "Content-Type: application/json" \
-d '{
"to": "+15558675309",
"body": "Hello from Fastify and Twilio! Testing callbacks."
}'
10. Deploy to Production
Move from local development (ngrok) to production with these adjustments:
Choose Hosting: Select a hosting provider (e.g., Render, Heroku, AWS EC2/Fargate, Google Cloud Run, Azure App Service). Ensure your chosen environment can run Node.js applications.
Get a Public URL: Your application needs a stable, publicly accessible HTTPS URL. Configure your hosting provider or use a reverse proxy (like Nginx or Caddy) to handle HTTPS termination.
Manage Environment Variables: Securely manage production environment variables (Twilio credentials, database connection strings, BASE_URL pointing to your public URL) using your hosting provider's configuration management system. Do not commit production secrets to Git.
Update BASE_URL: Set the BASE_URL environment variable in your production environment to your application's public HTTPS URL (e.g., https://your-app-domain.com).
Provision Database: Provision and configure your chosen production database. Ensure connection details are securely stored as environment variables. Run database migrations if applicable.
Use Process Management: Use a process manager like pm2 or rely on your hosting platform's built-in mechanisms (e.g., systemd, Docker orchestration) to keep your Node.js application running reliably and restart it if it crashes. Update your package.jsonstart script if necessary (e.g., pm2 start server.js).
Configure Production Logging: Configure production logging to output to files or a centralized logging service (e.g., Datadog, Logtail, Sentry) instead of just the console. Adjust log levels appropriately (info or warn usually).
Enhance Security:
Ensure webhook validation is enabled and uses the correct production authToken.
Apply standard web security practices (rate limiting, input validation beyond the basics shown here, security headers).
Keep dependencies updated (npm audit fix).
Update Twilio Configuration: You might need to update the webhook URL configured in Twilio if you previously set it manually, although using the statusCallback parameter in client.messages.create as shown is generally preferred as it sets it per message.
Frequently Asked Questions
How do I track Twilio SMS delivery status in Node.js?
Track Twilio SMS delivery status by setting the statusCallback parameter when calling client.messages.create(). Twilio sends POST requests to your callback URL with status updates (queued, sent, delivered, undelivered, failed). Implement a webhook endpoint in your Node.js application to receive and process these status notifications, then store them in your database for tracking and analytics.
What is a Twilio status callback webhook?
A Twilio status callback webhook is an HTTP POST request that Twilio sends to your specified URL whenever an SMS message status changes. The webhook includes parameters like MessageSid, MessageStatus, ErrorCode, From, and To. You configure the callback URL using the statusCallback parameter in the Twilio API, allowing your application to receive real-time delivery notifications without polling.
How do I validate Twilio webhook signatures in Fastify?
Validate Twilio webhook signatures using the validateRequest function from the twilio.webhook module. Pass your Twilio Auth Token, the X-Twilio-Signature header, the full request URL (including your public domain), and the request body parameters. This cryptographic validation ensures webhook requests genuinely originate from Twilio, preventing unauthorized access and webhook spoofing attacks.
Why use Fastify for Twilio SMS applications?
Fastify is ideal for Twilio SMS applications because it offers high performance with low overhead, handling thousands of webhook requests efficiently. It provides built-in JSON schema validation, automatic request parsing, excellent logging capabilities through Pino, and a developer-friendly plugin architecture. Fastify processes webhook callbacks significantly faster than Express, making it perfect for high-volume SMS applications.
What Twilio message status codes should I handle?
Handle these key Twilio message status codes: queued (accepted by Twilio), sent (dispatched to carrier), delivered (confirmed receipt by device), undelivered (failed delivery), failed (rejected by Twilio or carrier), and canceled (canceled before sending). When status is undelivered or failed, check the ErrorCode parameter against the Twilio Error Dictionary to understand why delivery failed.
How do I use ngrok with Twilio webhooks for local development?
Use ngrok to create a secure tunnel exposing your local Fastify server to the internet. Run npx ngrok http 3000 (replace 3000 with your port) to get a public HTTPS URL. Copy this URL and set it as your BASE_URL environment variable, then restart your server. Twilio sends webhooks to your ngrok URL, which forwards them to your local development server.
Should I store Twilio message status updates in a database?
Yes, always store Twilio message status updates in a production database (PostgreSQL, MongoDB, or Redis). The in-memory storage shown in tutorials is only for demonstration. Create a database schema with fields for message_sid, latest_status, error_code, status_history (JSON), and timestamps. Update records when webhooks arrive, enabling delivery tracking, failure analysis, and compliance reporting.
How do I secure my Twilio credentials in production?
Secure Twilio credentials by storing them as environment variables using your hosting provider's configuration management system (never commit to Git). Use a .env file locally with .gitignore protection. Store TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER securely. Always validate webhook signatures to prevent unauthorized access, and rotate your Auth Token if compromised. Consider using secrets management services like AWS Secrets Manager or HashiCorp Vault.
Frequently asked questions
Use the Twilio Node.js helper library and a framework like Fastify to create an API endpoint. This endpoint will handle sending SMS messages via the Twilio API by providing the recipient's phone number and the message body. Remember to configure your Twilio credentials in a .env file.
Status callbacks provide real-time updates on the delivery status of your SMS messages. Twilio sends these updates to a URL you specify, allowing your application to track whether a message was queued, sent, delivered, or failed. This is crucial for applications like two-factor authentication or order notifications.
Webhooks provide a real-time, efficient way for Twilio to communicate message status changes without your application constantly polling the Twilio API. Twilio sends an HTTP POST request to your specified URL whenever a status change occurs, delivering the update immediately.
ngrok is essential during local development with Twilio. It creates a public URL that tunnels requests to your local server, enabling Twilio to deliver webhooks to your application even though it's not publicly hosted. For production, configure your server's public URL directly.
Create a POST route in your Fastify application (e.g., '/message-status') to receive incoming webhooks. The Twilio helper library provides a validation function to ensure the requests are genuine. Inside this route, log the status updates and update the application's database accordingly.
Fastify is a high-performance Node.js web framework. Its speed and extensibility make it a good choice for building efficient applications that interact with Twilio. The article demonstrates building a production-ready application using Fastify for handling SMS delivery and status updates.
Use the validateRequest function from the Twilio Node.js helper library. This function verifies the signature of incoming webhooks, confirming they originate from Twilio. Never process webhook data without validating the signature to prevent security risks.
Set up a status callback URL when sending messages via the Twilio API. Create a database table to log status updates received via webhooks. Update this table whenever your application receives a status callback, storing the Message SID, status, and any error codes.
You need your Twilio Account SID, Auth Token, and a Twilio phone number. These credentials are used to authenticate with the Twilio API and specify the 'from' number for your messages. Keep these credentials secure, ideally in a .env file.
The dotenv library loads environment variables from a .env file into process.env. This enables you to store sensitive information like your Twilio credentials outside your codebase, improving security and simplifying configuration.
Set the statusCallback URL during the client.messages.create call using the statusCallback parameter. This associates each outbound SMS with the callback URL and ensures real-time updates. The example code uses a dynamic URL that incorporates the base URL to handle both local development and production.
Use a tool like ngrok to expose your local server. Include the full ngrok HTTPS URL in your statusCallback and test the flow. This will enable testing locally without needing to deploy or make your server publicly available.
Yes, you can use any suitable database (e.g., PostgreSQL, MongoDB). The provided example uses an in-memory store, which is not suitable for production. Design your database schema to store message SIDs, status history, and any relevant error codes.
This guide provides a step-by-step walkthrough for building a production-ready Node.js application using the Fastify framework to send SMS messages via Twilio and reliably track their delivery status using Twilio's status callback webhooks. You'll learn how to implement Twilio SMS delivery tracking with secure webhook validation in a high-performance Node.js SMS tracking system.
We will build a system that can:
queued,sent,delivered,undelivered).This approach solves the common problem of needing to know if and when an SMS message reaches the recipient, which is crucial for applications involving notifications, alerts, verification codes, or transactional messaging.
Technologies Used:
twilioNode.js Helper Library: Simplifies interaction with the Twilio API.dotenv: Loads environment variables from a.envfile for secure configuration.nodemon(Development): Monitors for file changes and automatically restarts the server during development.ngrok(Development): Creates a secure tunnel to expose your local server to the internet, necessary for receiving Twilio webhooks during development.System Architecture:
Prerequisites:
ngrokinstalled or accessible (e.g., vianpx ngrok). Download from ngrok.com or usenpx ngrok http <port>directly in your terminal for temporary use without global installation.Final Outcome:
By the end of this guide, you will have a running Fastify application capable of sending SMS messages and logging their delivery status updates received via Twilio webhooks. You will also have a solid foundation for integrating this functionality into larger applications, including secure webhook handling and basic status tracking.
1. Project Setup
Let's initialize our Node.js project and install the necessary dependencies.
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir fastify-twilio-sms cd fastify-twilio-smsInitialize Node.js Project: This creates a
package.jsonfile to manage dependencies and scripts.Install Dependencies: We need Fastify, the Twilio helper library, and
dotenv.Install Development Dependencies:
nodemonhelps streamline development by auto-restarting the server on file changes.Configure
nodemonScript: Open yourpackage.jsonfile and add adevscript within the"scripts"section:{ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "node server.js", "dev": "nodemon server.js" } }This allows you to run
npm run devto start the server withnodemon.Create Server File: Create the main application file,
server.js.touch server.jsBasic Fastify Server: Add the initial Fastify setup code to
server.js.// server.js 'use strict'; require('dotenv').config(); const fastify = require('fastify')({ logger: true }); fastify.get('/', async (request, reply) => { return { hello: 'world' }; }); const start = async () => { try { const port = process.env.PORT || 3000; await fastify.listen({ port: port, host: '0.0.0.0' }); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();Environment Variables Setup: Create two files for managing environment variables:
.env: Stores your actual secret credentials (add this to.gitignore)..env.example: A template showing required variables (commit this to Git).touch .env .env.exampleAdd the following to
.env.example:Now, create a
.gitignorefile to prevent committing secrets:touch .gitignoreAdd the following to
.gitignore:Explanation:
dotenvto load variables from.envintoprocess.env..env.exampleserves as documentation for required variables..gitignoreensures sensitive data (.env) and generated folders (node_modules) aren't committed.host: '0.0.0.0'makes the server accessible within your local network and is often needed for containerized environments.Run the Development Server: Start the server using the
nodemonscript.You should see output indicating the server is listening on port 3000 (or your configured
PORT). You can test the basic route by visitinghttp://localhost:3000in your browser or usingcurl http://localhost:3000.2. Twilio Configuration
Before sending messages, you need your Twilio credentials and a phone number.
Find Account SID and Auth Token:
Get a Twilio Phone Number:
Update
.envFile: Copy the contents of.env.exampleto.envand fill in your actual Twilio Account SID, Auth Token, and Twilio Phone Number.Restart your server (
npm run dev) after updating.envfor the changes to take effect.3. Implementing the SMS Sending Endpoint
Now, let's create an API endpoint in our Fastify app to trigger sending an SMS.
Initialize Twilio Client: Modify
server.jsto initialize the Twilio client using the credentials from environment variables.// server.js 'use strict'; require('dotenv').config(); const fastify = require('fastify')({ logger: true }); const twilio = require('twilio'); const accountSid = process.env.TWILIO_ACCOUNT_SID; const authToken = process.env.TWILIO_AUTH_TOKEN; const twilioPhoneNumber = process.env.TWILIO_PHONE_NUMBER; const baseUrl = process.env.BASE_URL; if (!accountSid || !authToken || !twilioPhoneNumber || !baseUrl) { fastify.log.error('Twilio credentials or BASE_URL missing in .env file. Check your .env configuration.'); process.exit(1); } const client = twilio(accountSid, authToken); fastify.get('/', async (request, reply) => { return { hello: 'world' }; }); const start = async () => { try { const port = process.env.PORT || 3000; await fastify.listen({ port: port, host: '0.0.0.0' }); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();Create
/send-smsRoute: Add a new POST route to handle SMS sending requests.fastify.post('/send-sms', async (request, reply) => { const { to, body } = request.body; if (!to || !body) { reply.code(400); return { error: 'Missing required fields: to, body' }; } const statusCallbackUrl = `${baseUrl}/message-status`; try { fastify.log.info(`Attempting to send SMS to ${to}`); const message = await client.messages.create({ body: body, from: twilioPhoneNumber, to: to, statusCallback: statusCallbackUrl }); fastify.log.info(`SMS queued successfully! SID: ${message.sid}, Status: ${message.status}`); return { success: true, messageSid: message.sid, status: message.status }; } catch (error) { fastify.log.error({ msg: 'Error sending SMS via Twilio', error: error.message, code: error.code, status: error.status }); const statusCode = error.status || 500; reply.code(statusCode); return { success: false, error: 'Failed to send SMS', details: error.message, code: error.code }; } });Explanation:
POST /send-smsroute.to(recipient phone number) andbody(message text).toandbodyare provided.statusCallbackURL using theBASE_URLenvironment variable and appending/message-status(the path for our webhook handler, which we'll create next).client.messages.create, passing theto,from(our Twilio number),body, and thestatusCallbackURL.queued. The actual sending happens asynchronously. We log the SID and initial status.tonumber format). We log relevant details from the Twilio error object.4. Implementing the Status Callback Endpoint
This endpoint will receive POST requests from Twilio whenever the status of an outbound message changes.
Create
/message-statusRoute: Add another POST route inserver.jsto handle incoming webhooks.fastify.post('/message-status', async (request, reply) => { const { MessageSid, MessageStatus, ErrorCode, From, To } = request.body; fastify.log.info( `Webhook Received – SID: ${MessageSid}, Status: ${MessageStatus}, From: ${From}, To: ${To}${ErrorCode ? ', ErrorCode: ' + ErrorCode : ''}` ); reply.code(200).send('OK'); });Explanation:
POST /message-statusroute.application/x-www-form-urlencoded, but Fastify typically parses this intorequest.bodyautomatically.MessageSid,MessageStatus, andErrorCode(if present). See Twilio docs for all possible fields.200 OKresponse. If Twilio doesn't receive a 200, it might retry the webhook, leading to duplicate processing.5. Exposing Your Local Server with ngrok
Twilio needs to send HTTP requests to your application, which means your app needs a publicly accessible URL. During development on your local machine,
ngrokprovides this.Ensure Your Server is Running: Make sure your Fastify server is running (
npm run dev). Note the port (e.g., 3000).Start ngrok: Open a new, separate terminal window (leave the server running in the first one) and run:
Copy Your Public URL:
ngrokwill display output similar to this:Copy the
https://<random-string>.ngrok-free.appURL (use thehttpsversion). This is your temporary public URL.Update
BASE_URL: Go back to your.envfile and update theBASE_URLvariable with the full ngrok URL you just copied.Restart Your Server: Stop your Fastify server (Ctrl+C in its terminal) and restart it (
npm run dev) to load the newBASE_URL.Now, when your application calls
client.messages.create, it will provide Twilio with the ngrok URL (e.g.,https://<random-string>.ngrok-free.app/message-status) as thestatusCallback. Twilio can reach this URL via ngrok, which tunnels the request back to your local Fastify server running on port 3000.6. Securing Your Callback Endpoint
Anyone could potentially send requests to your
/message-statusendpoint. You must verify that incoming webhook requests genuinely originate from Twilio. Thetwiliohelper library provides a function for this.Import Webhook Validation Function: Add the necessary import at the top of
server.js, alongside the maintwilioimport.// server.js 'use strict'; require('dotenv').config(); const fastify = require('fastify')({ logger: true }); const twilio = require('twilio'); const { validateRequest } = twilio.webhook; const accountSid = process.env.TWILIO_ACCOUNT_SID; const authToken = process.env.TWILIO_AUTH_TOKEN; const twilioPhoneNumber = process.env.TWILIO_PHONE_NUMBER; const baseUrl = process.env.BASE_URL; if (!accountSid || !authToken || !twilioPhoneNumber || !baseUrl) { fastify.log.error('Twilio credentials or BASE_URL missing in .env file. Check your .env configuration.'); process.exit(1); } const client = twilio(accountSid, authToken);Modify
/message-statusRoute for Validation: Update the/message-statusroute to use the validation function. Twilio's validation requires the Auth Token, the signature header it sends (X-Twilio-Signature), the full request URL, and the request parameters (body).const messageStatuses = {}; function updateMessageStatus(sid, status, errorCode) { if (!messageStatuses[sid]) { messageStatuses[sid] = []; } const newStatusEntry = { status, errorCode, timestamp: new Date().toISOString() }; messageStatuses[sid].push(newStatusEntry); fastify.log.info({ msg: `Stored status for ${sid}`, status: newStatusEntry }); } fastify.post('/message-status', async (request, reply) => { const twilioSignature = request.headers['x-twilio-signature']; const fullUrl = `${baseUrl}${request.url}`; const isValid = validateRequest( authToken, twilioSignature, fullUrl, request.body ); if (!isValid) { fastify.log.warn({ msg: 'Received invalid Twilio signature.', url: fullUrl, signature: twilioSignature }); reply.code(403); return { error: 'Invalid Twilio Signature' }; } const { MessageSid, MessageStatus, ErrorCode, From, To } = request.body; fastify.log.info( `Webhook VALIDATED – SID: ${MessageSid}, Status: ${MessageStatus}, From: ${From}, To: ${To}${ErrorCode ? ', ErrorCode: ' + ErrorCode : ''}` ); updateMessageStatus(MessageSid, MessageStatus, ErrorCode); reply.code(200).send('OK'); }); fastify.get('/get-status/:sid', async (request, reply) => { const sid = request.params.sid; const statusHistory = messageStatuses[sid]; if (statusHistory) { return { sid: sid, history: statusHistory }; } else { reply.code(404); return { error: 'Status not found for SID', sid: sid }; } });Explanation:
validateRequestfromtwilio.webhookat the top of the file./message-statushandler, we retrieve theX-Twilio-Signatureheader sent by Twilio.fullUrlthat Twilio used to send the request.request.urlin Fastify includes the path and query string (e.g.,/message-status).baseUrlmust be accurate (your ngrok or production URL).validateRequestwith yourauthToken, the signature, the URL, and the request body (request.bodywhich Fastify has parsed).isValidis false, we log a warning and return a403 Forbiddenstatus, stopping further processing. This prevents unauthorized access.messageStatuses. Important: This in-memory store is for demonstration purposes only and is unsuitable for production as data is lost on server restart. Use a persistent database./get-status/:sidroute is added to retrieve the stored status history for a given message SID, useful for debugging.7. Persisting Message Status (Database Layer - Conceptual)
The previous step used an in-memory object (
messageStatuses) for simplicity. In a production application, you must use a persistent data store like a database (e.g., PostgreSQL, MongoDB, Redis) to store message status updates.Conceptual Steps:
message_sid(Primary Key, VARCHAR/String) - The Twilio Message SID (SMxxx...)account_sid(VARCHAR/String) - Your Twilio Account SIDto_number(VARCHAR/String)from_number(VARCHAR/String)body(TEXT) - Optional, the message contentinitial_status(VARCHAR/String) - Status returned bymessages.create(e.g.,queued)latest_status(VARCHAR/String) - The most recent status received via webhook (e.g.,delivered,undelivered)error_code(INTEGER) - The Twilio error code if status isfailedorundeliveredstatus_history(JSON/TEXT) - Optional, store the full history of status updates with timestamps.created_at(TIMESTAMP)updated_at(TIMESTAMP)pg,mysql2,mongodb) to interact with your database./send-sms: When a message is successfully sent (client.messages.create), insert a new record into your database with themessage_sid,to_number,from_number,initial_status, etc./message-status: Inside the validated webhook handler, find the database record matching the incomingMessageSid. Update thelatest_status,error_code(if applicable), andupdated_attimestamp. Optionally append the new status to thestatus_historyfield.Conceptual Example using Prisma:
Note: The following example uses Prisma with TypeScript syntax for illustration. You would adapt this using Prisma's JavaScript client or your chosen database library in your JavaScript
server.jsfile.// prisma/schema.prisma (Example Schema) model MessageLog { messageSid String @id @unique accountSid String toNumber String fromNumber String body String? initialStatus String latestStatus String errorCode Int? statusHistory Json? @default("[]") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } // Conceptual usage in /message-status handler try { const updatedLog = await prisma.messageLog.update({ where: { messageSid: MessageSid }, data: { latestStatus: MessageStatus, errorCode: ErrorCode ? parseInt(ErrorCode, 10) : null, }, }); fastify.log.info(`Updated DB status for ${MessageSid} to ${MessageStatus}`); } catch (dbError) { fastify.log.error(`DB update failed for ${MessageSid}: ${dbError.message}`); }This section provides the high-level approach. Implementing a full database layer with migrations, connection pooling, and robust error handling is beyond the scope of this specific guide but is essential for production.
8. Implement Error Handling and Logging
Build robust error handling and logging to diagnose issues effectively.
logger: true, which provides basic request/response logging and methods likefastify.log.info(),fastify.log.error(), etc. Configure log levels and output destinations (e.g., file, external service) in production using Fastify's logging options or Pino directly.try…catchblock in/send-smshandles errors fromclient.messages.create. Log theerror.messageand potentiallyerror.codeorerror.statusprovided by the Twilio library for more specific details./message-status.200 OKto Twilio unless the error is fundamental to receiving the request itself.ErrorCodereceived from Twilio whenMessageStatusisfailedorundelivered. Consult the Twilio Error Dictionary to understand these codes.Example Enhanced Logging in
/message-status:async function processStatusUpdate(details) { fastify.log.info({ msg: "Processing status update", details }); updateMessageStatus(details.MessageSid, details.MessageStatus, details.ErrorCode); } // Inside the post('/message-status', ...) route, after validation const { MessageSid, MessageStatus, ErrorCode, From, To } = request.body; const logDetails = { MessageSid, MessageStatus, From, To }; if (ErrorCode) { logDetails.ErrorCode = ErrorCode; } try { await processStatusUpdate(logDetails); fastify.log.info({ msg: "Webhook processed successfully", ...logDetails }); reply.code(200).send('OK'); } catch (processingError) { fastify.log.error({ msg: "Error processing webhook status update", error: processingError.message, stack: processingError.stack, ...logDetails }); reply.code(200).send('OK'); }9. Test and Verify Your Implementation
Test your implementation thoroughly:
/send-sms):curlor Postman/Insomnia to send requests.tonumber andbody. Verify a 200 OK response withmessageSid. Check server logs for "Attempting to send SMS…" and "SMS queued successfully…" messages.toorbody. Verify a 400 Bad Request response.+1). Verify appropriate error response from Twilio (likely 400) proxied by your endpoint..env): Verify a 401 or similar error./message-status):/send-sms. Monitor the server logs (and ngrok consolehttp://127.0.0.1:4040) for incoming POST requests to/message-status. Verify statuses likequeued,sent, and finallydelivered(orundelivered/failedif applicable) are logged. Check theX-Twilio-Signatureis present and theWebhook VALIDATEDmessage appears.curlto send a POST request to your/message-statusngrok URL without a validX-Twilio-Signatureheader, but with a sample body. Verify you get a403 Forbiddenresponse and a log message about an invalid signature.curlto send a POST request with a valid signature (can be tricky to generate manually, easier to rely on real Twilio webhooks) or temporarily disable validation for testing only. Verify the status is logged/stored correctly. Check the/get-status/:sidendpoint (if implemented).npm install)..envfile with valid Twilio credentials and correctBASE_URL.npm run dev).ngrok(if testing locally) and pointBASE_URLto thehttpsngrok URL./send-smswith validtoandbody– returns 200 OK and logs success./message-statusin server logs after sending SMS.Webhook VALIDATEDmessage appears in logs for incoming status updates.queued→sent→delivered). (Check/get-status/:sidif implemented)./message-statuswithout valid signature – returns 403 Forbidden./get-status/:sid(if implemented) – returns status history for valid SID.Example
curlcommand for/send-sms:curl -X POST http://localhost:3000/send-sms \ -H "Content-Type: application/json" \ -d '{ "to": "+15558675309", "body": "Hello from Fastify and Twilio! Testing callbacks." }'10. Deploy to Production
Move from local development (
ngrok) to production with these adjustments:BASE_URLpointing to your public URL) using your hosting provider's configuration management system. Do not commit production secrets to Git.BASE_URL: Set theBASE_URLenvironment variable in your production environment to your application's public HTTPS URL (e.g.,https://your-app-domain.com).pm2or rely on your hosting platform's built-in mechanisms (e.g., systemd, Docker orchestration) to keep your Node.js application running reliably and restart it if it crashes. Update yourpackage.jsonstartscript if necessary (e.g.,pm2 start server.js).infoorwarnusually).authToken.npm audit fix).statusCallbackparameter inclient.messages.createas shown is generally preferred as it sets it per message.Frequently Asked Questions
How do I track Twilio SMS delivery status in Node.js?
Track Twilio SMS delivery status by setting the
statusCallbackparameter when callingclient.messages.create(). Twilio sends POST requests to your callback URL with status updates (queued,sent,delivered,undelivered,failed). Implement a webhook endpoint in your Node.js application to receive and process these status notifications, then store them in your database for tracking and analytics.What is a Twilio status callback webhook?
A Twilio status callback webhook is an HTTP POST request that Twilio sends to your specified URL whenever an SMS message status changes. The webhook includes parameters like
MessageSid,MessageStatus,ErrorCode,From, andTo. You configure the callback URL using thestatusCallbackparameter in the Twilio API, allowing your application to receive real-time delivery notifications without polling.How do I validate Twilio webhook signatures in Fastify?
Validate Twilio webhook signatures using the
validateRequestfunction from thetwilio.webhookmodule. Pass your Twilio Auth Token, theX-Twilio-Signatureheader, the full request URL (including your public domain), and the request body parameters. This cryptographic validation ensures webhook requests genuinely originate from Twilio, preventing unauthorized access and webhook spoofing attacks.Why use Fastify for Twilio SMS applications?
Fastify is ideal for Twilio SMS applications because it offers high performance with low overhead, handling thousands of webhook requests efficiently. It provides built-in JSON schema validation, automatic request parsing, excellent logging capabilities through Pino, and a developer-friendly plugin architecture. Fastify processes webhook callbacks significantly faster than Express, making it perfect for high-volume SMS applications.
What Twilio message status codes should I handle?
Handle these key Twilio message status codes:
queued(accepted by Twilio),sent(dispatched to carrier),delivered(confirmed receipt by device),undelivered(failed delivery),failed(rejected by Twilio or carrier), andcanceled(canceled before sending). When status isundeliveredorfailed, check theErrorCodeparameter against the Twilio Error Dictionary to understand why delivery failed.How do I use ngrok with Twilio webhooks for local development?
Use ngrok to create a secure tunnel exposing your local Fastify server to the internet. Run
npx ngrok http 3000(replace 3000 with your port) to get a public HTTPS URL. Copy this URL and set it as yourBASE_URLenvironment variable, then restart your server. Twilio sends webhooks to your ngrok URL, which forwards them to your local development server.Should I store Twilio message status updates in a database?
Yes, always store Twilio message status updates in a production database (PostgreSQL, MongoDB, or Redis). The in-memory storage shown in tutorials is only for demonstration. Create a database schema with fields for
message_sid,latest_status,error_code,status_history(JSON), and timestamps. Update records when webhooks arrive, enabling delivery tracking, failure analysis, and compliance reporting.How do I secure my Twilio credentials in production?
Secure Twilio credentials by storing them as environment variables using your hosting provider's configuration management system (never commit to Git). Use a
.envfile locally with.gitignoreprotection. StoreTWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN, andTWILIO_PHONE_NUMBERsecurely. Always validate webhook signatures to prevent unauthorized access, and rotate your Auth Token if compromised. Consider using secrets management services like AWS Secrets Manager or HashiCorp Vault.