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.
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.
twilio Node.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:
Text
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_CLIENT
Prerequisites:
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 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.
Bash
mkdir fastify-twilio-sms
cd fastify-twilio-sms
Initialize Node.js Project:
This creates a package.json file.
Bash
npm init -y
Install Dependencies:
We need Fastify, its environment variable handler, the Twilio helper library, dotenv (as a fallback and for clarity), raw-body for request validation, and pino-pretty for development logging.
@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 .env file into process.env. @fastify/env can 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 .env in the root of your project. Never commit this file to version control. Populate it with your Twilio credentials and application settings:
.env
# .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.0 is important for running inside containers or VMs.
Configure .gitignore:
Create a .gitignore file in the project root to prevent sensitive information and unnecessary files from being committed to Git.
Create Server File (server.js):
This file will define how to build the Fastify app and optionally start it.
JavaScript
// server.js'use strict';
constFastify = require('fastify');
const fastifyEnv = require('@fastify/env');
const twilio = require('twilio');
const getRawBody = require('raw-body');
const { validateRequest } = require('twilio');
// Define schema for environment variablesconst 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 sufficientLOG_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 instanceasyncfunctionbuildApp (opts = {}) {
// Configure Fastify logger based on environmentconst 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 productionconst fastify = Fastify({
logger: loggerConfig,
...opts // Pass any additional options for testing, etc.
});
// Register @fastify/env to load and validate environment variablesawait 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 = awaitgetRawBody(payload, {
length: request.headers['content-length'],
limit: '1mb', // Adjust limit as neededencoding: 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 parsingreturn 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 ---functionregisterRoutes(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 validationif (!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 payloadconst sender = request.body.From;
const recipient = request.body.To; // Your Twilio Numberconst 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 libraryconst twiml = new twilio.twiml.MessagingResponse();
// Simple auto-reply logicconst 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 structure200: {
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 instanceconst message = await fastify.twilioClient.messages.create({
body: body,
from: fastify.config.TWILIO_PHONE_NUMBER, // Your Twilio numberto: 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 neededreturn 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 neededreturn { status: 'ok', timestamp: newDate().toISOString() };
});
} // End registerRoutes// --- Server Start Logic ---// Only run the server start if this script is executed directlyif (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 usemodule.exports = buildApp;
Refactoring: The code is now wrapped in buildApp which returns the fastify instance. The server only starts if the file is run directly (node server.js). buildApp is exported.
envSchema: NODE_ENV removed from required.
Webhook Validation: Uses request.rawBodyString captured via preParsing and getRawBody. Removed fallback for webhookUrl and added checks for its presence and the signature/raw body.
Twilio Client: Now decorated onto fastify instance in onReady hook for better access.
Health Check: Added /health route (from Section 10) here for completeness.
Regex Fix: Corrected regex pattern in sendSmsSchema to include $ anchor: ^\\+[1-9]\\d{1,14}$.
Logging Fix: Replaced triple quotes """""" with standard double quotes "" in log messages.
Add Run Script to package.json:
Modify your package.json to include convenient scripts for running the server:
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 exactngrokhttps URL (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.twilioClient to call messages.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):
Bash
curl -X POST http://localhost:3000/api/send-sms \
-H ""Content-Type: application/json"" \
-d '{
""to"": ""+15559876543"",
""body"": ""Hello from Fastify and Twilio!""
}'
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 From for 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_URL in your .env for validation to work. Use ngrok URL for development, public deployed URL for production. Method: HTTP POST.
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-pretty in 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-body via preParsing hook to capture the raw request body string.
Requires TWILIO_WEBHOOK_URL to be correctly set in .env and loaded via @fastify/env. Reminder: Ensure this variable is uncommented and set to your correct ngrok (dev) or public (prod) URL.
Uses twilio.validateRequest with 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.
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.
Bash
npm install @fastify/rate-limit
JavaScript
// server.js (register the plugin within buildApp)await fastify.register(require('@fastify/rate-limit'), {
max: 100, // Max requests per time window per IPtimeWindow: '1 minute'
});
Input Validation:
Handled for /api/send-sms via Fastify schema. Ensure schemas are strict.
Helmet (Security Headers):
Recommendation: Add standard security headers.
Implementation: Use @fastify/helmet.
Bash
npm install @fastify/helmet
JavaScript
// 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/await for 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:/health endpoint implemented (see server.js).
Process Manager:pm2 recommended (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?
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.
Outbound API (/api/send-sms) -> Check success response, check SMS received.
Outbound API (invalid body) -> Check 400 error.
Outbound API (invalid number) -> Check Twilio error logged/returned.
Check Twilio Debugger.
Check /health endpoint.
Automated Testing:
Unit Tests: Test functions in isolation. Mock twilio client (jest.fn(), sinon).
Integration Tests: Test route handlers interacting with services (mock externals like Twilio). Use fastify.inject().
Example (Conceptual Jest with refactored server.js):
JavaScript
// tests/outbound.test.jsconst buildApp = require('../server'); // Import the app builderconst twilio = require('twilio');
// Mock the Twilio client constructor and methodsconst mockMessagesCreate = jest.fn();
jest.mock('twilio', () => {
// Mock the constructor to return an object with a messages mockreturn jest.fn().mockImplementation(() => ({
messages: { create: mockMessagesCreate }
}));
});
let app;
// Build a new app instance for testsbeforeAll(async () => {
// Optionally override config for tests if needed// process.env.SOME_VAR = 'test_value';
app = awaitbuildApp();
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 calledexpect(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 = newError('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 casebody: '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' fieldbody: 'Test message'
}
});
expect(response.statusCode).toBe(400);
// Check for Fastify's validation error structureconst 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
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.
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.
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.
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.
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.
Run 'ngrok http ' 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.
@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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
Problem Solved:
This application provides the core infrastructure needed for various SMS-based features, such as:
Technologies Used:
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:
Prerequisites:
ngrok(Required for local webhook testing): Installed and authenticated (ngrok website).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.
mkdir fastify-twilio-sms cd fastify-twilio-smsInitialize Node.js Project: This creates a
package.jsonfile.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.fastify: 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: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.Create Server File (
server.js): This file will define how to build the Fastify app and optionally start it.// 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;buildAppwhich returns thefastifyinstance. The server only starts if the file is run directly (node server.js).buildAppis exported.envSchema:NODE_ENVremoved fromrequired.request.rawBodyStringcaptured viapreParsingandgetRawBody. Removed fallback forwebhookUrland added checks for its presence and the signature/raw body.fastifyinstance inonReadyhook for better access./healthroute (from Section 10) here for completeness.sendSmsSchemato include$anchor:^\\+[1-9]\\d{1,14}$.""""""with standard double quotes""in log messages.Add Run Script to
package.json: Modify yourpackage.jsonto include convenient scripts for running the server:// 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:
If 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/twiliois now defined withinserver.js(in theregisterRoutesfunction). It includes:validateRequestwith the raw body string).twilio.twiml.MessagingResponse.text/xmlresponse back to Twilio.Testing with
ngrokand Twilio Console:Start
ngrok: Open a new terminal window and run:ngrok 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:Important: Restart your Fastify server after changing
.envfor the new value to be loaded.Start your Fastify server: In your original terminal:
Configure Twilio Webhook:
ngrokhttpsURL (including/webhooks/sms/twilio) into the box.HTTP POST.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-smsis also defined inserver.js(withinregisterRoutes). It includes:sendSmsSchema) to validate the request body (to,body). Invalid requests get a 400 response automatically.fastify.twilioClientto callmessages.create.Testing the Outbound API:
Use
curlor 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
.env.Fromfor outbound and target for inbound. Stored in.env.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
try...catchblocks handle Twilio API errors (outbound). Logerror.code,error.status,error.message.fastify.setErrorHandler) for unhandled exceptions.pino-prettyin development, JSON in production.LOG_LEVEL.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):
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):
raw-bodyviapreParsinghook to capture the raw request body string.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.twilio.validateRequestwith Auth Token, signature header, exact URL, and the raw body string.API Key / Token Authentication (for
/api/send-sms):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.// 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:
/api/send-smsvia Fastify schema. Ensure schemas are strict.Helmet (Security Headers):
Recommendation: Add standard security headers.
Implementation: Use
@fastify/helmet.// server.js (register the plugin within buildApp) await fastify.register(require('@fastify/helmet'));Understanding SMS Character Limits and International Messaging
+1...,+44..., etc.). Country code is required for international delivery.Optimizing Node.js Performance for SMS Applications
async/awaitfor all I/O (Twilio calls, DB).@fastify/redis) for frequent lookups if needed.k6,autocannon.0x.Monitoring Your Twilio SMS Integration
/healthendpoint implemented (seeserver.js).prom-client/fastify-metrics(for Prometheus) or push to Datadog.@sentry/node) or Bugsnag.Troubleshooting Twilio SMS Webhooks and API Errors
11200(unreachable/timeout),12100/12200(bad TwiML),12300(bad Content-Type),11205(SSL issue).ngrokIssues: Tunnel expired, HTTP vs HTTPS mismatch, URL typo.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.error.code):21211(bad 'To'),21610(STOP),21614(not SMS capable),3xxxx(carrier/delivery issues),20003(low balance). Full List..envloaded or variables set in production.Deploying Your Fastify SMS Application to Production
NODE_ENV=production, secure environment variables (use platform's secrets management),HOST=0.0.0.0, update Twilio webhook URL.tsc, Babel).pm2recommended (pm2 start server.js -i max).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. Thetwilio.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-Signatureheader using HMAC-SHA1. Usetwilio.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:3000to 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
21610when 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
preParsinghook to capture raw request bodies required for signature verification.What Twilio error codes should I handle in my application?
Key error codes to handle:
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:
ngrokactive,TWILIO_WEBHOOK_URLset, Twilio Console URL matches./api/send-sms) -> Check success response, check SMS received./healthendpoint.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):// 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