code examples
code examples
Twilio SMS with Fastify & Node.js: Build Two-Way Messaging (2025 Guide)
Complete guide to building two-way SMS with Twilio, Node.js, and Fastify. Learn webhook handling, TwiML responses, request validation, and production deployment with code examples.
Build Two-Way SMS with Twilio, Node.js & Fastify
Learn how to build two-way SMS messaging with Twilio and Fastify in this comprehensive Node.js tutorial. This guide covers everything from setting up Twilio webhooks and sending SMS messages to implementing request validation, TwiML responses, and production-ready security practices.
You'll master everything from initial project setup and core messaging logic to essential production considerations like security, error handling, deployment, and testing. By the end, you'll have a solid foundation for integrating two-way SMS communication into your services. While this guide covers core production considerations, true "production readiness" depends heavily on your specific application's scale, risk profile, and requirements – you may need deeper dives into databases, advanced monitoring, and scaling strategies beyond this foundational guide's scope.
Project Overview and Goals
What We're Building:
We will create a Node.js application using the Fastify framework that:
- Receives Inbound SMS: Listens for incoming SMS messages sent to a designated Twilio phone number via a webhook.
- Processes and Replies: Parses the incoming message and sends an automated reply back to the sender using Twilio's TwiML.
- Sends Outbound SMS: Exposes a secure API endpoint to programmatically send SMS messages to specified recipients via the Twilio REST API.
Problem Solved:
This application provides the core infrastructure needed for various SMS-based features, such as:
- Automated customer support bots.
- Two-factor authentication (2FA) code delivery.
- Appointment reminders and notifications.
- Marketing alerts (ensure compliance).
- Interactive SMS campaigns.
Technologies Used:
- Node.js: A JavaScript runtime environment ideal for building scalable, event-driven network applications. Learn more about Node.js server frameworks.
- Fastify: A high-performance, low-overhead web framework for Node.js focused on developer experience and speed. Compare Fastify vs Express for API development.
- Twilio Programmable Messaging: A cloud communications platform providing REST APIs and tools for sending and receiving SMS messages globally. Explore Twilio SMS API documentation.
twilioNode.js Helper Library: Simplifies interaction with the Twilio API.dotenv/@fastify/env: Manages environment variables securely and efficiently.ngrok(for development): A tool to expose local development servers to the internet for webhook testing.
System Architecture:
graph LR
subgraph ""User's Phone""
U(User)
end
subgraph ""Twilio Cloud""
T_API(Twilio API)
T_NUM(Twilio Phone Number)
T_WH(Twilio Webhook)
end
subgraph ""Your Infrastructure""
subgraph ""Development (Local Machine)""
NG(ngrok Tunnel)
APP_DEV(Fastify App - Dev)
end
subgraph ""Production (Cloud/Server)""
APP_PROD(Fastify App - Prod)
LB(Load Balancer/Firewall)
end
end
subgraph ""API Client""
API_C(Your Service/App)
end
%% Inbound Flow
U -- Sends SMS --> T_NUM
T_NUM -- Triggers Webhook --> T_WH
%% Development Inbound
T_WH -- Forwards Request --> NG
NG -- Relays Request --> APP_DEV
%% Production Inbound
T_WH -- Sends Request --> LB
LB -- Forwards Request --> APP_PROD
%% Reply Flow (Common)
subgraph ""Fastify Application (Dev or Prod)""
FASTIFY(Fastify Instance)
IN_ROUTE(Inbound Route /webhooks/sms/twilio)
OUT_ROUTE(Outbound Route /api/send-sms)
TW_CLIENT(Twilio Client)
end
APP_DEV --> IN_ROUTE
APP_PROD --> IN_ROUTE
IN_ROUTE -- Generates TwiML --> T_WH
T_WH -- Sends Reply SMS --> U
%% Outbound Flow
API_C -- POST Request --> OUT_ROUTE
OUT_ROUTE -- Uses Twilio Client --> TW_CLIENT
TW_CLIENT -- Calls API --> T_API
T_API -- Sends SMS --> U
%% Link components within Fastify App
IN_ROUTE -- Processes --> FASTIFY
OUT_ROUTE -- Processes --> FASTIFY
FASTIFY -- Loads Config --> TW_CLIENTPrerequisites:
- Node.js and npm (or yarn): Installed on your system. Node.js 20.x (Active LTS) or Node.js 22.x (Active LTS until October 2025) recommended for production. Node.js 18.x reached end-of-life on April 30, 2025 and should not be used for new projects. Download Node.js
- Twilio Account: A free or paid Twilio account (Sign up for Twilio here).
- Twilio Phone Number: An SMS-capable phone number purchased within your Twilio account.
- Twilio Account SID and Auth Token: Found on your main Twilio Console dashboard.
ngrok(Required for local webhook testing): Installed and authenticated (ngrok website).- Basic Terminal/Command Line Knowledge.
- Text Editor or IDE: Such as VS Code.
Expected Outcome:
A functional Fastify application running locally (exposed via ngrok) or deployed, capable of receiving SMS messages, replying automatically, and sending messages via an API endpoint, complete with basic security and error handling.
Setting Up Your Twilio Fastify Project
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.
bashmkdir fastify-twilio-sms cd fastify-twilio-sms -
Initialize Node.js Project: This creates a
package.jsonfile.bashnpm init -y -
Install Dependencies: We need Fastify, its environment variable handler, the Twilio helper library,
dotenv(as a fallback and for clarity),raw-bodyfor request validation, andpino-prettyfor development logging.bashnpm install fastify @fastify/env twilio dotenv raw-body pino-prettyfastify: The core web framework.@fastify/env: Loads and validates environment variables based on a schema.twilio: Official Node.js library for the Twilio API.dotenv: Loads environment variables from a.envfile intoprocess.env.@fastify/envcan use this too.raw-body: Needed to capture the raw request body for Twilio signature validation.pino-pretty: Development dependency to format Fastify's default Pino logs nicely.
-
Set up Environment Variables: Create a file named
.envin the root of your project. Never commit this file to version control. Populate it with your Twilio credentials and application settings:dotenv# .env # Twilio Credentials - Get from https://www.twilio.com/console TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWILIO_AUTH_TOKEN=your_auth_token_xxxxxxxxxxxxxx TWILIO_PHONE_NUMBER=+15551234567 # Your purchased Twilio number # Application Settings PORT=3000 HOST=0.0.0.0 # Listen on all available network interfaces NODE_ENV=development # Set to 'production' when deployed LOG_LEVEL=info # Pino log level (trace, debug, info, warn, error, fatal) # Add this later when using ngrok for webhook validation (Section 7) # Make sure this matches EXACTLY the URL configured in Twilio Console # TWILIO_WEBHOOK_URL=https://<your-ngrok-subdomain>.ngrok.io/webhooks/sms/twilio- Replace placeholders with your actual Account SID, Auth Token, and Twilio phone number.
HOST=0.0.0.0is important for running inside containers or VMs.
-
Configure
.gitignore: Create a.gitignorefile in the project root to prevent sensitive information and unnecessary files from being committed to Git.text# .gitignore # Dependencies node_modules/ # Environment Variables .env .env.* !.env.example # Logs logs/ *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Build output dist/ build/ # OS generated files .DS_Store Thumbs.db -
Create Server File (
server.js): This file will define how to build the Fastify app and optionally start it.javascript// server.js 'use strict'; const Fastify = require('fastify'); const fastifyEnv = require('@fastify/env'); const twilio = require('twilio'); const getRawBody = require('raw-body'); const { validateRequest } = require('twilio'); // Define schema for environment variables const envSchema = { type: 'object', required: [ 'PORT', 'HOST', 'TWILIO_ACCOUNT_SID', 'TWILIO_AUTH_TOKEN', 'TWILIO_PHONE_NUMBER', ], properties: { PORT: { type: 'string', default: 3000 }, HOST: { type: 'string', default: '0.0.0.0' }, NODE_ENV: { type: 'string', default: 'development' }, // Default is sufficient LOG_LEVEL: { type: 'string', default: 'info' }, TWILIO_ACCOUNT_SID: { type: 'string' }, TWILIO_AUTH_TOKEN: { type: 'string' }, TWILIO_PHONE_NUMBER: { type: 'string' }, TWILIO_WEBHOOK_URL: { type: 'string' } // Required for reliable webhook validation }, }; // Function to build the Fastify app instance async function buildApp (opts = {}) { // Configure Fastify logger based on environment const loggerConfig = process.env.NODE_ENV === 'development' ? { transport: { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss Z', ignore: 'pid,hostname', }, }, level: process.env.LOG_LEVEL || 'info', } : { level: process.env.LOG_LEVEL || 'info' }; // Basic JSON logs in production const fastify = Fastify({ logger: loggerConfig, ...opts // Pass any additional options for testing, etc. }); // Register @fastify/env to load and validate environment variables await fastify.register(fastifyEnv, { confKey: 'config', // Access environment variables via `fastify.config` schema: envSchema, dotenv: true, // Load .env file using dotenv }); // Add hook to capture raw body *before* Fastify parses it for the webhook fastify.addHook('preParsing', async (request, reply, payload) => { if (request.routerPath === '/webhooks/sms/twilio') { const contentType = request.headers['content-type']; if (contentType) { try { // Capture raw body as a string for validation request.rawBodyString = await getRawBody(payload, { length: request.headers['content-length'], limit: '1mb', // Adjust limit as needed encoding: true // Let raw-body detect encoding (should be utf-8 from Twilio) }); } catch (err) { fastify.log.error({ err }, 'Failed to capture raw body'); // Let Fastify handle the error downstream, maybe return error here? // For now, just log it. Validation will likely fail. } } } // IMPORTANT: Return the original payload stream for Fastify to continue parsing return payload; }); // Decorate Fastify instance with Twilio client (initialized onReady) let twilioClient; fastify.decorate('twilioClient', null); fastify.addHook('onReady', async () => { twilioClient = twilio(fastify.config.TWILIO_ACCOUNT_SID, fastify.config.TWILIO_AUTH_TOKEN); fastify.twilioClient = twilioClient; // Make client available via fastify.twilioClient fastify.log.info('Twilio client initialized.'); }); // --- Register Routes --- registerRoutes(fastify); return fastify; } // --- Route Definitions --- function registerRoutes(fastify) { // === Inbound SMS Webhook === fastify.post('/webhooks/sms/twilio', async (request, reply) => { fastify.log.info( { body: request.body, headers: request.headers }, 'Received inbound SMS webhook from Twilio' ); // --- Security Validation (See Section 7) --- const twilioSignature = request.headers['x-twilio-signature']; const webhookUrl = fastify.config.TWILIO_WEBHOOK_URL; // Must be set in config for reliable validation if (!webhookUrl) { fastify.log.error('TWILIO_WEBHOOK_URL environment variable is not set. Cannot validate request.'); return reply.status(500).send('Webhook URL configuration error.'); } if (!twilioSignature) { fastify.log.warn('Request received without X-Twilio-Signature.'); return reply.status(400).send('Missing Twilio Signature'); } if (!request.rawBodyString) { fastify.log.error('Raw request body was not captured. Cannot validate request.'); return reply.status(500).send('Internal server error processing request body.'); } const isValid = validateRequest( fastify.config.TWILIO_AUTH_TOKEN, twilioSignature, webhookUrl, request.rawBodyString // Pass the raw body STRING to the validator ); if (!isValid) { fastify.log.warn({ signature: twilioSignature, url: webhookUrl }, 'Invalid Twilio signature received.'); return reply.status(403).send('Invalid Twilio Signature'); } fastify.log.info('Twilio signature validated successfully.'); // --- End Security Validation --- // Extract details from Twilio's request payload const sender = request.body.From; const recipient = request.body.To; // Your Twilio Number const messageBody = request.body.Body; const messageSid = request.body.MessageSid; fastify.log.info( `Message SID ${messageSid} from ${sender}: ""${messageBody}""` ); // Create a TwiML response using the Twilio helper library const twiml = new twilio.twiml.MessagingResponse(); // Simple auto-reply logic const replyText = `Thanks for messaging! You said: ""${messageBody}""`; twiml.message(replyText); // Send the TwiML response back to Twilio reply.type('text/xml').send(twiml.toString()); // Fastify handles sending the response when reply.send() is called. No explicit return needed. }); // === Outbound SMS API === const sendSmsSchema = { body: { type: 'object', required: ['to', 'body'], properties: { to: { type: 'string', description: 'E.164 formatted phone number', pattern: '^\\+[1-9]\\d{1,14}$' }, // ITU-T E.164: max 15 digits (1-3 country code + max 12 subscriber number) body: { type: 'string', minLength: 1, maxLength: 1600 }, }, }, response: { // Optional: Define expected response structure 200: { type: 'object', properties: { success: { type: 'boolean' }, messageSid: { type: 'string' }, status: { type: 'string' } } }, // Add error responses if needed (e.g., 4xx, 5xx) } }; // SMS Character Limits (Twilio Official Documentation - 2025): // - Maximum message body: 1600 characters (enforced by Twilio API) // - Single segment limits: // * GSM-7 encoding: 160 characters per segment // * UCS-2 encoding (Unicode): 70 characters per segment // - Concatenated message segments (multi-part): // * GSM-7: 153 characters per segment (7 chars reserved for concatenation header) // * UCS-2: 67 characters per segment (3 chars reserved for concatenation header) // - Billing: Each segment is billed individually // - Recommendation: Keep messages ≤320 characters for best deliverability // See: https://www.twilio.com/docs/glossary/what-sms-character-limit // Endpoint to send an outbound SMS fastify.post('/api/send-sms', { schema: sendSmsSchema }, async (request, reply) => { const { to, body } = request.body; fastify.log.info(`Attempting to send SMS to ${to}: ""${body}""`); try { // Use the Twilio client decorated onto the fastify instance const message = await fastify.twilioClient.messages.create({ body: body, from: fastify.config.TWILIO_PHONE_NUMBER, // Your Twilio number to: to, // Recipient's number }); fastify.log.info( `SMS sent successfully! SID: ${message.sid}, Status: ${message.status}` ); return reply.status(200).send({ success: true, messageSid: message.sid, status: message.status, // e.g., 'queued', 'sending', 'sent' }); } catch (error) { fastify.log.error( { err: { message: error.message, code: error.code, status: error.status } }, // Log Twilio error details `Failed to send SMS to ${to}` ); // Customize error response based on Twilio error codes if needed return reply.status(error.status || 500).send({ success: false, message: error.message || 'Failed to send SMS', code: error.code || null, // Twilio specific error code }); } }); // === Health Check Endpoint === fastify.get('/health', async (request, reply) => { // Add checks for DB connection, Twilio client status, etc. if needed return { status: 'ok', timestamp: new Date().toISOString() }; }); } // End registerRoutes // --- Server Start Logic --- // Only run the server start if this script is executed directly if (require.main === module) { buildApp().then(fastify => { fastify.listen({ port: fastify.config.PORT, host: fastify.config.HOST, }, (err, address) => { if (err) { fastify.log.error(err); process.exit(1); } // Logger already logs listening info if configured correctly fastify.log.info( `Twilio configured with SID: ${fastify.config.TWILIO_ACCOUNT_SID.substring(0, 5)}... and Phone: ${fastify.config.TWILIO_PHONE_NUMBER}` ); }); }).catch(err => { console.error('Error building or starting the application:', err); process.exit(1); }); } // Export the build function for testing or programmatic use module.exports = buildApp;- Refactoring: The code is now wrapped in
buildAppwhich returns thefastifyinstance. The server only starts if the file is run directly (node server.js).buildAppis exported. envSchema:NODE_ENVremoved fromrequired.- Webhook Validation: Uses
request.rawBodyStringcaptured viapreParsingandgetRawBody. Removed fallback forwebhookUrland added checks for its presence and the signature/raw body. - Twilio Client: Now decorated onto
fastifyinstance inonReadyhook for better access. - Health Check: Added
/healthroute (from Section 10) here for completeness. - Regex Fix: Corrected regex pattern in
sendSmsSchemato include$anchor:^\\+[1-9]\\d{1,14}$. - Logging Fix: Replaced triple quotes
""""""with standard double quotes""in log messages.
- Refactoring: The code is now wrapped in
-
Add Run Script to
package.json: Modify yourpackage.jsonto include convenient scripts for running the server:json// package.json (add/update scripts section) ""scripts"": { ""start"": ""node server.js"", ""dev"": ""node server.js"" }, -
Initial Run: You should now be able to start your basic server:
bashnpm run devIf successful, you'll see log output indicating the server is listening and confirming your Twilio SID/Number were loaded. Press
Ctrl+Cto stop it.
Handling Inbound SMS Messages with Twilio Webhooks
The webhook endpoint /webhooks/sms/twilio is now defined within server.js (in the registerRoutes function). It includes:
- Listening for POST requests.
- Logging incoming data.
- Security Validation: Implemented directly within the route handler (using
validateRequestwith the raw body string). - Parsing sender, recipient, and message body.
- Generating a TwiML response using
twilio.twiml.MessagingResponse. - Sending the
text/xmlresponse back to Twilio.
Testing with ngrok and Twilio Console:
-
Start
ngrok: Open a new terminal window and run:bashngrok http 3000 # Use the PORT defined in your .envCopy the
httpsforwarding URL (e.g.,https://<unique-subdomain>.ngrok.io). -
Set
TWILIO_WEBHOOK_URL: Open your.envfile, uncomment theTWILIO_WEBHOOK_URLline, and paste your fullngrokhttpsURL including the path:dotenv# .env (update this line) TWILIO_WEBHOOK_URL=https://<unique-subdomain>.ngrok.io/webhooks/sms/twilioImportant: Restart your Fastify server after changing
.envfor the new value to be loaded. -
Start your Fastify server: In your original terminal:
bashnpm run dev -
Configure Twilio Webhook:
- Go to your Twilio Console.
- Navigate to Phone Numbers > Manage > Active Numbers.
- Click on your Twilio phone number.
- Scroll to ""Messaging"".
- Under ""A MESSAGE COMES IN"", select ""Webhook"".
- Paste your exact
ngrokhttpsURL (including/webhooks/sms/twilio) into the box. - Ensure the method is
HTTP POST. - Click ""Save"".
-
Send a Test SMS: Send an SMS from your phone to your Twilio number.
-
Check Logs & Reply: Observe your Fastify server logs. You should see the incoming request logged, the ""Twilio signature validated successfully"" message, and details of the message. You should receive the auto-reply on your phone. If validation fails, check the logs for errors (e.g., URL mismatch, missing signature, incorrect Auth Token).
Sending Outbound SMS Messages via API
The API endpoint /api/send-sms is also defined in server.js (within registerRoutes). It includes:
- Listening for POST requests.
- Schema Validation: Uses Fastify's schema (
sendSmsSchema) to validate the request body (to,body). Invalid requests get a 400 response automatically. - Using the initialized
fastify.twilioClientto callmessages.create. - Sending the SMS via the Twilio API.
- Handling success and errors, returning JSON responses with appropriate status codes and details (including Twilio error codes on failure).
- SMS Length Clarification: A comment near the schema explains the implications of the 1600 character limit (concatenation and billing).
Testing the Outbound API:
Use curl or Postman (ensure your server is running):
curl -X POST http://localhost:3000/api/send-sms \
-H ""Content-Type: application/json"" \
-d '{
""to"": ""+15559876543"",
""body"": ""Hello from Fastify and Twilio!""
}'Expected Output (Success):
{
""success"": true,
""messageSid"": ""SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"",
""status"": ""queued""
}Check the target phone for the SMS and your server logs for details.
Configuring Your Twilio Phone Number and Webhooks
- API Keys (Account SID & Auth Token): Used for authenticating requests to Twilio (sending SMS). Stored in
.env. - Twilio Phone Number: Used as
Fromfor outbound and target for inbound. Stored in.env. - Webhook URL Configuration: Tells Twilio where to send inbound SMS events. Configured in Twilio Console. Must match
TWILIO_WEBHOOK_URLin your.envfor validation to work. UsengrokURL for development, public deployed URL for production. Method:HTTP POST.
Implementing Error Handling and Logging for SMS
- Error Handling:
- Fastify handles schema validation errors (400).
try...catchblocks handle Twilio API errors (outbound). Logerror.code,error.status,error.message.- Webhook security validation returns 403/500 on failure.
- Graceful handling of TwiML generation/sending errors (ensure valid XML or error response).
- Consider a Fastify global error handler (
fastify.setErrorHandler) for unhandled exceptions.
- Logging:
pino-prettyin development, JSON in production.- Controlled by
LOG_LEVEL. - Log relevant context (SIDs, numbers, errors).
- Retries:
- Twilio retries failed webhooks. Ensure your endpoint is idempotent if actions aren't safe to repeat.
- Implement custom retries for outbound API calls only if necessary (e.g., for critical messages and transient errors), potentially using a queue.
Storing SMS Messages: Database Schema Examples
While this basic guide doesn't implement a database, real-world applications often need to store message history, user data, or conversation state.
-
When Needed: Storing logs, tracking conversations, linking to users.
-
Schema Ideas (Example using Prisma):
prisma// schema.prisma datasource db { provider = ""postgresql"" url = env(""DATABASE_URL"") } generator client { provider = ""prisma-client-js"" } model MessageLog { id String @id @default(cuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt direction String // ""inbound"" or ""outbound"" twilioSid String @unique // Twilio's Message SID accountSid String fromNumber String toNumber String body String? @db.Text status String? // Twilio status errorCode Int? // Twilio error code errorMessage String? // userId String? // user User? @relation(fields: [userId], references: [id]) } -
Implementation: Choose DB, use ORM (Prisma, Sequelize), install drivers, define schema, run migrations, integrate client into Fastify (e.g., via
fastify-plugin).
Securing Twilio Webhooks with Request Validation
Security is critical.
-
Twilio Request Validation (Implemented in Section 1/2):
- Uses
raw-bodyviapreParsinghook to capture the raw request body string. - Requires
TWILIO_WEBHOOK_URLto be correctly set in.envand loaded via@fastify/env. Reminder: Ensure this variable is uncommented and set to your correctngrok(dev) or public (prod) URL. - Uses
twilio.validateRequestwith Auth Token, signature header, exact URL, and the raw body string. - Returns 403 Forbidden if validation fails. Returns 500 if configuration (URL) or body capture fails.
- Uses
-
API Key / Token Authentication (for
/api/send-sms):- Recommendation: Protect the outbound API.
- Options: Simple static API key (check in
preHandler), JWT (@fastify/jwt), OAuth 2.0 (fastify-oauth2). Implementation not shown in this guide.
-
Rate Limiting:
-
Recommendation: Prevent abuse of the outbound API.
-
Implementation: Use
@fastify/rate-limit.bashnpm install @fastify/rate-limitjavascript// server.js (register the plugin within buildApp) await fastify.register(require('@fastify/rate-limit'), { max: 100, // Max requests per time window per IP timeWindow: '1 minute' });
-
-
Input Validation:
- Handled for
/api/send-smsvia Fastify schema. Ensure schemas are strict.
- Handled for
-
Helmet (Security Headers):
-
Recommendation: Add standard security headers.
-
Implementation: Use
@fastify/helmet.bashnpm install @fastify/helmetjavascript// server.js (register the plugin within buildApp) await fastify.register(require('@fastify/helmet'));
-
Understanding SMS Character Limits and International Messaging
- Message Concatenation: Messages exceeding single-segment limits are automatically split by Twilio into multiple segments. Single segment: 160 GSM-7 chars or 70 UCS-2 chars. Concatenated segments: 153 GSM-7 chars or 67 UCS-2 chars per segment due to header overhead. Each segment is billed individually. Inbound concatenated messages arrive as one payload with the full message body reassembled.
- Character Encoding: Use UTF-8 in your application. Twilio automatically detects and applies GSM-7 or UCS-2 encoding based on message content. Non-GSM-7 characters (emojis, accented characters) trigger UCS-2 encoding with reduced character limits.
- International Numbers: Always use E.164 format (
+1...,+44..., etc.). Country code is required for international delivery. - Opt-Outs (STOP/HELP): Twilio automatically handles standard English opt-out keywords (STOP, UNSTOP, START, HELP, INFO) on long codes and Toll-Free numbers by default. Respect opt-outs to maintain compliance. For custom keyword handling or non-English keywords, implement logic in your webhook handler. See: Twilio Opt-Out Documentation
- Non-SMS Channels: TwiML structure and webhook payloads differ for WhatsApp, Facebook Messenger, and other channels. Consult channel-specific Twilio documentation for integration.
Optimizing Node.js Performance for SMS Applications
- Asynchronous Operations: Use
async/awaitfor all I/O (Twilio calls, DB). - Payload Size: Keep TwiML/API responses concise.
- Caching: Consider Redis (
@fastify/redis) for frequent lookups if needed. - Load Testing: Use
k6,autocannon. - Node.js Version: Use Node.js 20.x (Active LTS) or 22.x (Active LTS) for production. Avoid Node.js 18.x (EOL April 30, 2025).
- Profiling: Use Node.js profiler or
0x.
Monitoring Your Twilio SMS Integration
- Health Checks:
/healthendpoint implemented (seeserver.js). - Twilio Debugger & Logs: Check Twilio Console Debugger and Message Logs.
- Application Logging: Aggregate production JSON logs (Datadog, ELK). Set up alerts.
- Metrics: Track message counts, latency, errors using
prom-client/fastify-metrics(for Prometheus) or push to Datadog. - Error Tracking: Use Sentry (
@sentry/node) or Bugsnag.
Troubleshooting Twilio SMS Webhooks and API Errors
- Webhook Failures (Twilio Debugger):
11200(unreachable/timeout),12100/12200(bad TwiML),12300(bad Content-Type),11205(SSL issue). ngrokIssues: Tunnel expired, HTTP vs HTTPS mismatch, URL typo.- Request Validation Failure (
403 Invalid Twilio Signature): Incorrect Auth Token, incorrectTWILIO_WEBHOOK_URL(must match exactly), body modified before validation (check raw body capture), validating non-Twilio request. - Outbound Sending Failures (Check
error.code):21211(bad 'To'),21610(STOP),21614(not SMS capable),3xxxx(carrier/delivery issues),20003(low balance). Full List. - Environment Variables: Ensure
.envloaded or variables set in production. - Firewalls: Allow traffic from Twilio IPs if restricting.
Deploying Your Fastify SMS Application to Production
- Platform: PaaS (Heroku, Render), IaaS (EC2, GCE), Containers (Docker, Kubernetes).
- Production Config:
NODE_ENV=production, secure environment variables (use platform's secrets management),HOST=0.0.0.0, update Twilio webhook URL. - Build Step: If needed (
tsc, Babel). - Process Manager:
pm2recommended (pm2 start server.js -i max). - CI/CD: GitHub Actions, GitLab CI, etc. (Install -> Lint -> Test -> Build -> Deploy).
- Rollback: Have a plan.
Twilio SMS with Fastify: Frequently Asked Questions
What Node.js version should I use for Twilio SMS with Fastify?
Use Node.js 20.x (Active LTS) or Node.js 22.x (Active LTS until October 2025) for production applications. Node.js 18.x reached end-of-life on April 30, 2025 and should not be used for new projects.
How many characters can I send in a Twilio SMS message?
Twilio supports up to 1,600 characters per message through automatic concatenation. Single segments support 160 characters (GSM-7) or 70 characters (UCS-2/Unicode). Concatenated messages use 153 characters (GSM-7) or 67 characters (UCS-2) per segment due to header overhead. Each segment is billed individually. For best deliverability, keep messages under 320 characters.
What is TwiML and why do I need it for inbound SMS?
TwiML (Twilio Markup Language) is Twilio's XML-based instruction language. When your webhook receives an inbound SMS, you respond with TwiML XML to tell Twilio what action to take – such as sending an auto-reply using the <Message> verb. The twilio.twiml.MessagingResponse() helper generates valid TwiML automatically.
How do I validate that webhooks are actually from Twilio?
Twilio signs all webhook requests with the X-Twilio-Signature header using HMAC-SHA1. Use twilio.validateRequest() with your Auth Token, the signature header, the exact webhook URL, and the raw request body to verify authenticity. This prevents spoofed requests from malicious actors.
Why do I need ngrok for local development?
Twilio's servers need to reach your local development machine to deliver inbound SMS webhooks. ngrok creates a secure tunnel that exposes your localhost:3000 to the internet with a public HTTPS URL that Twilio can access. In production, you'll use your deployed application's public URL instead.
What's the difference between GSM-7 and UCS-2 encoding?
GSM-7 supports basic Latin characters, numbers, and common symbols – allowing 160 characters per SMS segment. UCS-2 (Unicode) supports all characters including emojis, accented characters, and non-Latin alphabets – but reduces capacity to 70 characters per segment. Twilio automatically selects the encoding based on your message content.
How do I handle SMS opt-outs (STOP messages)?
Twilio automatically handles standard English opt-out keywords (STOP, UNSTOP, START, HELP, INFO) on long codes and Toll-Free numbers. When someone texts STOP, Twilio blocks future messages to that number automatically. Check error code 21610 when sending to detect opted-out recipients. For custom keyword handling, implement logic in your webhook handler.
Can I use Fastify instead of Express for Twilio webhooks?
Yes! Fastify offers superior performance and developer experience compared to Express. This guide demonstrates production-ready Twilio integration with Fastify, including proper webhook validation using the preParsing hook to capture raw request bodies required for signature verification.
What Twilio error codes should I handle in my application?
Key error codes to handle:
- 21211: Invalid 'To' phone number format
- 21610: Recipient has opted out (STOP)
- 21614: 'To' number is not SMS-capable (landline)
- 20003: Authentication failed (check credentials)
- 11200: Webhook unreachable/timeout
- 12300: Invalid Content-Type in webhook response
See the official Twilio error dictionary for the complete list.
How much does Twilio SMS cost?
Twilio pricing varies by country and message type. US SMS typically costs $0.0079 per segment sent. Remember that concatenated messages count as multiple segments – a 200-character message uses 2 segments and costs 2× the base price. Check Twilio's pricing page for current rates in your target countries.
Testing Your Twilio SMS Integration
Ensure correctness.
-
Manual Verification Checklist:
-
- Server starts dev/prod.
-
-
ngrokactive,TWILIO_WEBHOOK_URLset, Twilio Console URL matches.
-
-
- Inbound SMS -> Check logs (valid request, signature OK), check reply received.
-
- Outbound API (
/api/send-sms) -> Check success response, check SMS received.
- Outbound API (
-
- Outbound API (invalid body) -> Check 400 error.
-
- Outbound API (invalid number) -> Check Twilio error logged/returned.
-
- Check Twilio Debugger.
-
- Check
/healthendpoint.
- Check
-
-
Automated Testing:
-
Unit Tests: Test functions in isolation. Mock
twilioclient (jest.fn(),sinon). -
Integration Tests: Test route handlers interacting with services (mock externals like Twilio). Use
fastify.inject(). Example (Conceptual Jest with refactoredserver.js):javascript// tests/outbound.test.js const buildApp = require('../server'); // Import the app builder const twilio = require('twilio'); // Mock the Twilio client constructor and methods const mockMessagesCreate = jest.fn(); jest.mock('twilio', () => { // Mock the constructor to return an object with a messages mock return jest.fn().mockImplementation(() => ({ messages: { create: mockMessagesCreate } })); }); let app; // Build a new app instance for tests beforeAll(async () => { // Optionally override config for tests if needed // process.env.SOME_VAR = 'test_value'; app = await buildApp(); await app.ready(); // Ensure plugins, hooks (like onReady) are loaded }); afterAll(async () => { await app.close(); }); beforeEach(() => { // Reset mocks before each test mockMessagesCreate.mockClear(); // Reset Twilio constructor mock if needed twilio.mockClear(); }); test('POST /api/send-sms should send SMS successfully', async () => { const mockResponse = { sid: 'SMtest', status: 'queued' }; mockMessagesCreate.mockResolvedValue(mockResponse); const response = await app.inject({ method: 'POST', url: '/api/send-sms', payload: { to: '+15551112222', body: 'Test message' } }); expect(response.statusCode).toBe(200); expect(JSON.parse(response.payload)).toEqual({ success: true, messageSid: mockResponse.sid, status: mockResponse.status }); expect(twilio).toHaveBeenCalledTimes(1); // Check constructor called expect(mockMessagesCreate).toHaveBeenCalledTimes(1); expect(mockMessagesCreate).toHaveBeenCalledWith({ to: '+15551112222', body: 'Test message', from: app.config.TWILIO_PHONE_NUMBER // Check correct 'from' number used }); }); test('POST /api/send-sms should handle Twilio API errors', async () => { const mockError = new Error('Twilio Test Error'); mockError.code = 21211; // Example error code mockError.status = 400; mockMessagesCreate.mockRejectedValue(mockError); const response = await app.inject({ method: 'POST', url: '/api/send-sms', payload: { to: '+15551112222', // Invalid number for this test case body: 'Test message causing error' } }); expect(response.statusCode).toBe(400); expect(JSON.parse(response.payload)).toEqual({ success: false, message: 'Twilio Test Error', code: 21211 }); expect(mockMessagesCreate).toHaveBeenCalledTimes(1); }); test('POST /api/send-sms should return 400 for invalid payload', async () => { const response = await app.inject({ method: 'POST', url: '/api/send-sms', payload: { // Missing 'to' field body: 'Test message' } }); expect(response.statusCode).toBe(400); // Check for Fastify's validation error structure const payload = JSON.parse(response.payload); expect(payload.message).toContain(""body must have required property 'to'""); expect(mockMessagesCreate).not.toHaveBeenCalled(); }); // Add tests for the inbound webhook (/webhooks/sms/twilio) // - Mock validateRequest // - Inject POST request with valid/invalid signature // - Check TwiML response
-
Frequently Asked Questions
How to send SMS messages with Twilio and Node.js?
Use the Twilio Programmable Messaging API with a Node.js library like `twilio`. This allows you to send outbound SMS messages via a REST API by providing the recipient's number and message body in your API request. The provided code example demonstrates setting up a secure API endpoint ('/api/send-sms') using Fastify to programmatically send messages via Twilio.
What is Fastify and why use it with Twilio?
Fastify is a high-performance Node.js web framework known for its speed and developer-friendly experience. Its efficiency makes it ideal for handling the real-time, event-driven nature of SMS communication with the Twilio API. The provided code example uses Fastify to create both inbound and outbound SMS routes and leverages hooks for initialization.
How to receive inbound SMS messages with Twilio?
Set up a webhook URL in your Twilio Console that points to your application's endpoint (e.g., '/webhooks/sms/twilio'). Twilio will send an HTTP POST request to this URL whenever a message is sent to your Twilio number. The example code provides a comprehensive route handler to securely process these requests and respond with TwiML.
What is a TwiML response and how is it used?
TwiML (Twilio Markup Language) is an XML-based language used to instruct Twilio on how to handle incoming messages or calls. In the provided example, TwiML is used to generate automated replies to inbound SMS messages. The `twilio` Node.js helper library simplifies creating TwiML responses.
How to validate Twilio webhook requests for security?
Use the `twilio.validateRequest` function with your Auth Token, the request signature, webhook URL, and the raw request body. This ensures the request originated from Twilio. The example code demonstrates how to use a 'preParsing' hook with `raw-body` to capture the request body before Fastify processes it, allowing validation of the signature before handling the request content.
How to set up ngrok for local Twilio webhook testing?
Run 'ngrok http <your-port>' to create a public tunnel to your local server. Use the generated HTTPS URL as your webhook URL in the Twilio console and your .env file's 'TWILIO_WEBHOOK_URL' variable. This ensures that Twilio's requests are routed correctly to your local development server. Remember to restart your server after updating .env.
Why does the code use @fastify/env and dotenv?
@fastify/env handles environment variables securely using a schema. dotenv loads environment variables from a .env file which is useful in development for clarity, but never commit your .env to source control. The example code combines these two using @fastify/env's dotenv option.
What is the recommended database schema for storing SMS messages?
While not strictly required for basic functionality, the guide suggests a relational model with fields such as `id`, `direction`, `twilioSid`, `fromNumber`, `toNumber`, `body`, `status`, `errorCode`, and `errorMessage`. This facilitates storing message history and tracking conversations, and optionally linking to other data such as users.
How to improve the security of my Twilio SMS application?
Beyond validating webhook signatures, implement API key/token authentication for your outbound SMS endpoint, use rate limiting to prevent abuse, validate all user inputs strictly, and use a security header package like `@fastify/helmet` to add appropriate headers for added protection against common web vulnerabilities.
When should I implement retries for sending outbound SMS messages?
Twilio handles retries for webhook requests. Implement custom retries for outbound API calls only for essential messages or transient errors. Consider using a message queue for reliable retry mechanisms in cases of more persistent issues. Avoid retries for errors like invalid recipient numbers.
How to troubleshoot Twilio webhook request validation failures?
Common causes include an incorrect Auth Token, mismatched webhook URLs, or modifying the request body before validation. Double-check your .env file's `TWILIO_WEBHOOK_URL` against the URL set in your Twilio Console. Make sure that the URL used in your webhook handler and in the Twilio Console are identical, including the path. Ensure you are using the raw request body string for validation.
What are common Twilio error codes and what do they mean?
Error codes like '21211' (invalid 'To' number), '21610' (user opted out with STOP), and '20003' (insufficient funds) indicate specific issues with your requests to Twilio. Refer to the Twilio error code documentation for comprehensive explanations and resolution steps.
How to handle long SMS messages with Twilio?
Twilio automatically concatenates messages longer than 160 GSM-7 characters (or 70 UCS-2). While inbound messages arrive as a single combined message, outbound messages are split and billed as multiple segments. This should be factored into cost considerations, especially for international messaging.
Can I use a different Node.js framework besides Fastify?
Yes, although the provided example leverages Fastify's performance and features, you can adapt the principles and core logic to other frameworks like Express.js or NestJS. You'll need to implement similar routing, webhook handling, and Twilio API integration within your chosen framework.
What are some performance optimization strategies for Twilio SMS applications?
Use `async/await` for all I/O operations, minimize payload sizes, consider caching for frequent lookups if applicable, and perform load testing to identify bottlenecks. Using a recent LTS version of Node.js and profiling your application can also help optimize performance.