DEV Community

How to Test Twilio Webhooks Locally

The Problem: Twilio Webhooks Won't Hit Your Localhost

When you're building a Twilio integration-whether handling incoming SMS, voice callbacks, or call status updates-you need to test those webhooks against your local development environment. But Twilio's servers can't reach http://localhost:3000. Your machine isn't publicly routable on the internet.

You need a way to expose your local webhook handler to Twilio's infrastructure, capture the payloads, inspect them, and debug failures without deploying to staging every time. This is where most developers hit a wall: they either spin up a staging environment (slow feedback loop) or struggle with outdated tunneling solutions that break on restart.

Testing Twilio webhooks locally requires both a stable public endpoint and a reliable way to forward those requests back to your machine. Let's walk through the real approaches.

Prerequisites

  • Node.js (v14+) and npm installed locally
  • Twilio account with an active phone number (free trial account works)
  • Basic Express or Fastify knowledge - you'll be writing a simple webhook handler
  • Postman or curl - for manual testing before Twilio integration
  • Understanding of HMAC-SHA1 - Twilio signs requests; you'll verify them

Setting Up and Testing Twilio Webhooks Locally

Step 1: Create a Local Webhook Handler

Start by building a minimal Express server that accepts Twilio's webhook POST requests. Twilio sends form-encoded data, so you'll need the appropriate middleware.

const express = require('express');
const twilio = require('twilio');
const app = express();

app.use(express.urlencoded({ extended: false }));

// Twilio auth token from your account
const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN;

// Middleware to verify Twilio request signature
const validateTwilioRequest = (req, res, next) => {
  const twilioSignature = req.headers['x-twilio-signature'];
  const url = `https://${req.get('host')}${req.originalUrl}`;
  
  // Reconstruct the request body as Twilio does
  const params = req.body;
  const isValid = twilio.validateRequest(
    TWILIO_AUTH_TOKEN,
    twilioSignature,
    url,
    params
  );

  if (!isValid) {
    return res.status(403).send('Twilio request signature validation failed');
  }
  next();
};

// SMS webhook endpoint
app.post('/sms', validateTwilioRequest, (req, res) => {
  const { From, Body, MessageSid } = req.body;
  console.log(`SMS from ${From}: ${Body} (SID: ${MessageSid})`);
  
  // Your business logic here
  const twiml = new twilio.twiml.MessagingResponse();
  twiml.message('Thanks for your message!');
  res.type('application/xml').send(twiml.toString());
});

// Call status webhook endpoint
app.post('/call-status', validateTwilioRequest, (req, res) => {
  const { CallSid, CallStatus, From } = req.body;
  console.log(`Call ${CallSid} from ${From} is now ${CallStatus}`);
  res.send('OK');
});

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000');
});

This handler:

  • Validates every incoming request using Twilio's signature verification
  • Logs SMS and call status events
  • Returns appropriate TwiML or status responses

Step 2: Expose Your Localhost to the Internet

You need a stable public URL that Twilio can POST to. This is where local development gets tricky. Several options exist:

Option A: Traditional HTTP Tunnel (ngrok, LocalTunnel)

# Using ngrok
ngrok http 3000

# Output:
# Forwarding https://abc123.ngrok.io -> http://localhost:3000

The problem: ngrok's free tier generates a new URL every time you restart. You'd have to update Twilio's webhook URL manually each session.

Option B: Stable Endpoint with Anonymily

Anonymily vs ngrok - Anonymily provides a stable named endpoint that survives restarts:

npx @anonymilyhq/cli listen 3000

This command:

  • Creates a persistent URL: https://api.anonymily.com/h/<endpoint-name>
  • Forwards all incoming webhooks to your local server over Server-Sent Events
  • Captures requests even if localhost is temporarily down
  • Provides a web UI to inspect payloads

Step 3: Configure Twilio Webhook URLs

Once your local handler is running and exposed, configure Twilio to send webhooks to your public endpoint. In the Twilio Console:

  • Navigate to Phone Numbers โ†’ your active number
  • Under Messaging, set Webhook URL to your public endpoint: https://api.anonymily.com/h/my-twilio-sms (or your ngrok URL)
  • Set HTTP Method to POST
  • Under Voice, set Webhook URL for call status callbacks similarly
# Example: if using Anonymily
# SMS Webhook URL: https://api.anonymily.com/h/twilio-sms-handler
# Call Status URL: https://api.anonymily.com/h/twilio-call-status

Step 4: Test with Real Twilio Events

Send yourself an SMS to your Twilio number, or make a test call. Your local handler will receive the webhook.

# Terminal 1: Start your local handler
node webhook-handler.js

# Terminal 2: Start the tunnel (Anonymily example)
npx @anonymilyhq/cli listen 3000

# Terminal 3: Send a test SMS from any phone to your Twilio number
# Watch the logs in Terminal 1 for incoming webhook

You should see output like:

SMS from +1234567890: Hello World (SID: SM1234567890abcdef)

Step 5: Inspect and Replay Payloads

If you're using Anonymily, open the web dashboard to see all captured webhooks. You can inspect the raw payload, headers, and signature. The free tier includes unlimited single replay, so you can fix a bug and re-trigger the webhook without sending another SMS.

For mastering webhook signature verification, ensure your validation logic matches Twilio's expectations: the signature is HMAC-SHA1 of the URL + form-encoded body, using your auth token as the key.

Common Errors and Fixes

Error 1: "Twilio request signature validation failed"

Exact error message: 403 Forbidden: Twilio request signature validation failed

Root cause: The signature validation is failing because:

  • The URL used in validation doesn't match the URL Twilio sent the request to (protocol mismatch, missing query params, etc.)
  • The auth token is wrong or not loaded from the environment
  • The request body was parsed incorrectly (form encoding issues)

Fix:

// Ensure you're using the FULL URL, including scheme and host
const url = `https://${req.get('host')}${req.originalUrl}`;

// Log the URL and signature for debugging
console.log('Validating URL:', url);
console.log('Twilio signature:', req.headers['x-twilio-signature']);

// Verify auth token is loaded
if (!TWILIO_AUTH_TOKEN) {
  throw new Error('TWILIO_AUTH_TOKEN env var not set');
}

// If using a tunnel, ensure the URL matches what Twilio sees
// (e.g., https://api.anonymily.com/h/endpoint, not localhost)

Error 2: "Cannot read property 'validateRequest' of undefined"

Exact error message: TypeError: Cannot read property 'validateRequest' of undefined

Root cause: The twilio package wasn't imported, or an older version is installed that doesn't include validateRequest.

Fix:

npm install twilio@latest

Then verify the import:

const twilio = require('twilio');
// Confirm validateRequest is available
console.log(typeof twilio.validateRequest); // Should be 'function'

Frequently Asked Questions

Q: How do I test Twilio SMS webhooks without sending real SMS?

A: Use Twilio's TwiML Bins or a tool like Anonymily with synthetic events (Pro tier) to generate provider-signed test payloads. For manual testing, use curl with a manually constructed form-encoded body and manually calculated HMAC signature, though this is tedious. The easiest path: send a real SMS to your Twilio number (free tier includes credits).

Q: What's the difference between testing locally with ngrok vs. a stable endpoint?

A: ngrok's free tier generates a new URL on every restart, requiring manual Twilio configuration updates. A stable endpoint like Anonymily persists across restarts, reducing friction during development. See the detailed comparison for trade-offs.

Q: How do I handle Twilio webhook retries if my local server crashes?

A: Twilio will retry failed webhooks for a limited time. With Anonymily, webhooks are captured in the cloud even if localhost is down, then replayed when your server restarts. With ngrok alone, retries are lost. For production, use a proper webhook gateway like Hookdeck that guarantees delivery.

Getting Twilio webhooks working locally doesn't have to mean constant staging deploys or manual URL shuffling. Try npx @anonymilyhq/cli listen 3000 to expose your localhost with a stable endpoint, inspect payloads in real time, and replay events instantly. Head to anonymily.com to get started.

Comments

No comments yet. Start the discussion.