Skip to main content

Twilio Webhooks Example: Handle SMS & Voice Events

Twilio webhooks notify your application when communication events occur — incoming SMS messages, voice calls, delivery status updates, and more. Instead of polling the Twilio API, webhooks push real-time event data to your endpoint.

The problem? Setting up Twilio webhooks requires signature verification, TwiML response formatting, handling the application/x-www-form-urlencoded content type, and responding within 15 seconds.

The solution? Deploy a production-ready Twilio webhook handler with Codehooks.io in under 5 minutes.

New to webhooks?

If you're new to webhooks, check out our guide on What are webhooks? to understand the fundamentals before diving into this Twilio-specific implementation.

Prerequisites

Before you begin, sign up for a free Codehooks.io account and install the CLI:

npm install -g codehooks

You'll also need:

Quick Start: Incoming SMS Handler

Create a new Codehooks project:

coho create my-twilio-handler
cd my-twilio-handler
npm install twilio

Replace index.js with:

import { app, Datastore } from 'codehooks-js';
import twilio from 'twilio';

const { validateRequest } = twilio;

// Bypass JWT auth for Twilio webhook endpoints
app.auth('/twilio/*', (req, res, next) => {
next();
});

// Handle incoming SMS messages
app.post('/twilio/sms', async (req, res) => {
// Validate the request is from Twilio
const authToken = process.env.TWILIO_AUTH_TOKEN;
const signature = req.headers['x-twilio-signature'];
const url = `https://${req.headers.host}${req.originalUrl}`;

const isValid = validateRequest(authToken, signature, url, req.body);

if (!isValid) {
console.error('Invalid Twilio signature');
return res.status(403).send('Forbidden');
}

// Extract message details from the webhook
const { From, To, Body, MessageSid } = req.body;

console.log(`SMS from ${From}: ${Body}`);

// Store the message in the database
const conn = await Datastore.open();
await conn.insertOne('sms_messages', {
messageSid: MessageSid,
from: From,
to: To,
body: Body,
direction: 'inbound',
receivedAt: new Date().toISOString(),
});

// Respond with TwiML
res.set('Content-Type', 'text/xml');
res.send(`
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Message>Thanks for your message! We received: "${Body}"</Message>
</Response>
`);
});

export default app.init();

Deploy and configure:

coho deploy
coho set-env TWILIO_AUTH_TOKEN your_auth_token_here --encrypted

Your webhook URL will be: https://my-twilio-handler-xxxx.api.codehooks.io/dev/twilio/sms

Configure Twilio Console

  1. Go to Twilio Console → Phone Numbers
  2. Click on your phone number
  3. Under Messaging, set:
    • A MESSAGE COMES IN: Webhook
    • URL: https://your-app.api.codehooks.io/dev/twilio/sms
    • HTTP Method: POST
  4. Click Save

Common Twilio Webhook Events

Incoming SMS Parameters

When Twilio receives an SMS to your number, it sends these parameters:

ParameterDescriptionExample
MessageSidUnique message identifierSM1234567890abcdef
FromSender's phone number+15551234567
ToYour Twilio number+15559876543
BodyMessage text contentHello world
NumMediaNumber of attached media files0
FromCitySender's city (if available)San Francisco
FromStateSender's stateCA
FromCountrySender's countryUS

SMS Status Callback Parameters

When you send an SMS via Twilio API with a statusCallback URL, you receive updates:

ParameterDescription
MessageSidUnique message identifier
MessageStatusCurrent status: queued, sent, delivered, failed, undelivered
ErrorCodeError code if failed (e.g., 30003 for unreachable)
ErrorMessageHuman-readable error description

Voice Call Parameters

When someone calls your Twilio number:

ParameterDescription
CallSidUnique call identifier
FromCaller's phone number
ToYour Twilio number
CallStatusStatus: ringing, in-progress, completed, failed
Directioninbound or outbound-api

SMS Status Callback Handler

Track delivery status of outbound messages:

import { app, Datastore } from 'codehooks-js';
import twilio from 'twilio';

const { validateRequest } = twilio;

app.auth('/twilio/*', (req, res, next) => {
next();
});

// Handle SMS delivery status updates
app.post('/twilio/status', async (req, res) => {
const authToken = process.env.TWILIO_AUTH_TOKEN;
const signature = req.headers['x-twilio-signature'];
const url = `https://${req.headers.host}${req.originalUrl}`;

const isValid = validateRequest(authToken, signature, url, req.body);

if (!isValid) {
return res.status(403).send('Forbidden');
}

const { MessageSid, MessageStatus, ErrorCode, ErrorMessage } = req.body;

console.log(`Message ${MessageSid} status: ${MessageStatus}`);

// Update message status in database
const conn = await Datastore.open();
await conn.updateOne('sms_messages', { messageSid: MessageSid }, {
$set: {
status: MessageStatus,
errorCode: ErrorCode || null,
errorMessage: ErrorMessage || null,
updatedAt: new Date().toISOString(),
}
});

// Handle specific statuses
if (MessageStatus === 'failed' || MessageStatus === 'undelivered') {
console.error(`Message ${MessageSid} failed: ${ErrorCode} - ${ErrorMessage}`);
// Trigger retry logic or alert
}

res.status(200).send('OK');
});

export default app.init();

Voice Call Handler with TwiML

Handle incoming voice calls:

import { app, Datastore } from 'codehooks-js';
import twilio from 'twilio';

const { validateRequest } = twilio;

app.auth('/twilio/*', (req, res, next) => {
next();
});

// Handle incoming voice calls
app.post('/twilio/voice', async (req, res) => {
const authToken = process.env.TWILIO_AUTH_TOKEN;
const signature = req.headers['x-twilio-signature'];
const url = `https://${req.headers.host}${req.originalUrl}`;

const isValid = validateRequest(authToken, signature, url, req.body);

if (!isValid) {
return res.status(403).send('Forbidden');
}

const { CallSid, From, To, CallStatus } = req.body;

console.log(`Incoming call from ${From}`);

// Log the call
const conn = await Datastore.open();
await conn.insertOne('calls', {
callSid: CallSid,
from: From,
to: To,
status: CallStatus,
direction: 'inbound',
receivedAt: new Date().toISOString(),
});

// Respond with TwiML for voice
res.set('Content-Type', 'text/xml');
res.send(`
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="alice">Hello! Thank you for calling. Please leave a message after the beep.</Say>
<Record maxLength="60" transcribe="true" />
<Say>We did not receive a recording. Goodbye.</Say>
</Response>
`);
});

export default app.init();

Complete Multi-Event Handler

Handle all Twilio webhook types in one deployment:

import { app, Datastore } from 'codehooks-js';
import twilio from 'twilio';

const { validateRequest } = twilio;

// Middleware to validate all Twilio requests
function validateTwilioRequest(req, res, next) {
const authToken = process.env.TWILIO_AUTH_TOKEN;
const signature = req.headers['x-twilio-signature'];
const url = `https://${req.headers.host}${req.originalUrl}`;

const isValid = validateRequest(authToken, signature, url, req.body);

if (!isValid) {
console.error('Invalid Twilio signature');
return res.status(403).send('Forbidden');
}

next();
}

// Bypass JWT for all Twilio endpoints
app.auth('/twilio/*', (req, res, next) => {
next();
});

// Incoming SMS
app.post('/twilio/sms', validateTwilioRequest, async (req, res) => {
const { From, Body, MessageSid } = req.body;

const conn = await Datastore.open();
await conn.insertOne('events', {
type: 'sms.received',
messageSid: MessageSid,
from: From,
body: Body,
timestamp: new Date().toISOString(),
});

// Auto-reply
res.set('Content-Type', 'text/xml');
res.send(`
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Message>Thanks! We got your message.</Message>
</Response>
`);
});

// SMS Status Callback
app.post('/twilio/sms/status', validateTwilioRequest, async (req, res) => {
const { MessageSid, MessageStatus, ErrorCode } = req.body;

const conn = await Datastore.open();
await conn.insertOne('events', {
type: 'sms.status',
messageSid: MessageSid,
status: MessageStatus,
errorCode: ErrorCode,
timestamp: new Date().toISOString(),
});

res.status(200).send('OK');
});

// Incoming Voice Call
app.post('/twilio/voice', validateTwilioRequest, async (req, res) => {
const { CallSid, From, To } = req.body;

const conn = await Datastore.open();
await conn.insertOne('events', {
type: 'call.received',
callSid: CallSid,
from: From,
to: To,
timestamp: new Date().toISOString(),
});

res.set('Content-Type', 'text/xml');
res.send(`
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="alice">Hello, thank you for calling!</Say>
<Hangup />
</Response>
`);
});

// Voice Status Callback
app.post('/twilio/voice/status', validateTwilioRequest, async (req, res) => {
const { CallSid, CallStatus, CallDuration } = req.body;

const conn = await Datastore.open();
await conn.insertOne('events', {
type: 'call.status',
callSid: CallSid,
status: CallStatus,
duration: CallDuration,
timestamp: new Date().toISOString(),
});

res.status(200).send('OK');
});

export default app.init();

Twilio Signature Verification

Twilio signs all webhook requests with the X-Twilio-Signature header using HMAC-SHA1 and your Auth Token. Always verify this signature to ensure requests are legitimately from Twilio.

import twilio from 'twilio';

const { validateRequest } = twilio;

function verifyTwilioWebhook(req) {
const authToken = process.env.TWILIO_AUTH_TOKEN;
const signature = req.headers['x-twilio-signature'];

// IMPORTANT: Use the exact URL Twilio is calling
// Include the full URL with https:// and any query parameters
const url = `https://${req.headers.host}${req.originalUrl}`;

return validateRequest(authToken, signature, url, req.body);
}

Important: Always use Twilio's official SDK for signature validation rather than implementing your own. The SDK handles edge cases like URL encoding and parameter ordering.

Twilio Webhook Timeouts

Twilio enforces a 15-second timeout. If your endpoint doesn't respond in time, Twilio marks the delivery as failed and may retry.

Best practice: Acknowledge immediately, process asynchronously:

import { app, Datastore } from 'codehooks-js';
import twilio from 'twilio';

const { validateRequest } = twilio;

// Define a worker for async processing
app.worker('processSms', async (req, res) => {
const { from, body, messageSid } = req.body.payload;

// Do heavy processing here (AI analysis, external API calls, etc.)
console.log(`Processing message ${messageSid} from ${from}`);

// Update database with results
const conn = await Datastore.open();
await conn.updateOne('sms_messages', { messageSid }, {
$set: {
processed: true,
processedAt: new Date().toISOString(),
}
});

res.end();
});

app.auth('/twilio/*', (req, res, next) => {
next();
});

app.post('/twilio/sms', async (req, res) => {
const authToken = process.env.TWILIO_AUTH_TOKEN;
const signature = req.headers['x-twilio-signature'];
const url = `https://${req.headers.host}${req.originalUrl}`;

if (!validateRequest(authToken, signature, url, req.body)) {
return res.status(403).send('Forbidden');
}

const { From, Body, MessageSid } = req.body;

// Store immediately
const conn = await Datastore.open();
await conn.insertOne('sms_messages', {
messageSid: MessageSid,
from: From,
body: Body,
processed: false,
receivedAt: new Date().toISOString(),
});

// Queue for async processing
await conn.enqueue('processSms', {
from: From,
body: Body,
messageSid: MessageSid,
});

// Respond immediately with TwiML (within milliseconds)
res.set('Content-Type', 'text/xml');
res.send(`
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Message>Got it! Processing your request...</Message>
</Response>
`);
});

export default app.init();

Best Practices for Twilio Webhooks

1. Always Validate Signatures

Never skip signature validation in production. Attackers can send fake webhooks to your endpoint.

2. Use Environment Variables

coho set-env TWILIO_AUTH_TOKEN ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx --encrypted
coho set-env TWILIO_ACCOUNT_SID your_account_sid --encrypted

3. Handle Idempotency

Twilio may retry failed webhooks. Use MessageSid or CallSid as idempotency keys:

const conn = await Datastore.open();
const existing = await conn.getOne('sms_messages', { messageSid: MessageSid });

if (existing) {
console.log('Already processed:', MessageSid);
res.status(200).send('OK');
return;
}

4. Return Proper Status Codes

  • 200-299: Success, Twilio won't retry
  • 4xx/5xx: Twilio will retry with exponential backoff

5. Log Everything

console.log('Twilio webhook received:', {
type: 'sms',
from: From,
messageSid: MessageSid,
timestamp: new Date().toISOString(),
});

Check logs with: coho logs --follow

Why Use Codehooks.io for Twilio Webhooks?

FeatureDIY SetupCodehooks.io
Setup timeHours5 minutes
SSL/HTTPSConfigure certificatesBuilt-in
Signature validationManual implementationUse Twilio SDK
Database for logsSet up separatelyBuilt-in
Async processingConfigure queuesBuilt-in workers
MonitoringSet up loggingBuilt-in logs
CostServer costsFree tier available

Twilio Webhooks FAQ

Common questions about handling Twilio webhooks

How do I verify Twilio webhook signatures?
Use the validateRequest function from the official Twilio SDK. It takes your Auth Token, the X-Twilio-Signature header, the full webhook URL, and the request body. Always use the SDK rather than implementing your own validation to handle edge cases correctly.
Why is my Twilio signature validation failing?
Common causes: (1) Wrong Auth Token (check test vs live), (2) URL mismatch - ensure the URL you validate against exactly matches what's configured in Twilio, including https:// and any query parameters, (3) Request body was modified before validation. Use the exact URL from req.headers.host and req.originalUrl.
How long do I have to respond to Twilio webhooks?
Twilio enforces a 15-second timeout. If your endpoint doesn't respond in time, Twilio marks it as failed and may retry. Best practice: respond within 100-500ms by storing the data immediately and processing complex logic asynchronously using queues.
What's the difference between webhook and status callback?
Webhooks for incoming messages/calls expect a TwiML response with instructions (like or ) because Twilio needs to know how to handle the interaction. Status callbacks are informational - they report what happened (delivered, failed, etc.) and just need a 200 OK response.
How do I test Twilio webhooks locally?
With Codehooks.io, you don't need local testing or ngrok. Just run coho deploy and you have a live HTTPS URL in seconds. Configure that URL in the Twilio Console and test with real messages. Check logs with coho logs --follow.
Does Twilio retry failed webhooks?
Yes, Twilio retries failed webhook deliveries with exponential backoff. Return a 2xx status code to indicate success. If you return 4xx or 5xx, or if your endpoint times out, Twilio will retry the request.
How do I handle MMS messages with media?
MMS webhooks include NumMedia (count of attachments) and MediaUrl0, MediaUrl1, etc. for each attachment. Download and store media files as needed. The MediaContentType0 parameter tells you the MIME type of each file.
What format does Twilio send webhook data in?
Twilio sends webhooks as application/x-www-form-urlencoded (not JSON). Most frameworks parse this automatically into req.body. The Codehooks.io runtime handles this parsing for you.