· 6 min read

How to Test Twilio Webhooks with HookCap

By HookCap Team

How to Test Twilio Webhooks with HookCap

Twilio sends webhooks for every significant event in its platform: incoming SMS messages, voice call status changes, delivery receipts, WhatsApp messages, and more. If your app responds to any of these, you need a reliable way to capture and inspect real payloads during development.

The core problem is the same as every webhook integration: Twilio needs a public HTTPS URL, but your handler is on localhost. This guide covers using HookCap to solve that during development.

What Twilio Webhooks Are Used For

Twilio webhooks let your server react to events from the Twilio platform:

Webhook typeWhen it fires
Incoming SMSA message arrives on your Twilio number
SMS Status CallbackDelivery status changes (sent, delivered, failed)
Incoming voice callSomeone calls your Twilio number
Call Status CallbackA call’s state changes (initiated, ringing, answered, completed)
WhatsApp messagesIncoming WhatsApp messages on your number
Verify service eventsOTP code sent, check attempts, etc.

Each webhook is an HTTP request Twilio sends to a URL you configure, either in the Twilio Console or via the API.

Step 1: Create a HookCap Endpoint

Go to hookcap.dev, sign up, and create an endpoint. You get a persistent HTTPS URL like:

https://hookcap.dev/e/your-endpoint-id

This URL works immediately — no local server required. Twilio can reach it from anywhere.

Step 2: Configure Twilio to Send to HookCap

For SMS (Messaging Service or Phone Number)

In the Twilio Console:

  1. Go to Phone NumbersActive Numbers
  2. Select the number you want to configure
  3. Under Messaging Configuration, set the Incoming Message webhook URL to your HookCap endpoint
  4. Set the method to HTTP POST
  5. Save

Alternatively, configure via the Twilio API:

const twilio = require('twilio');
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);

await client.incomingPhoneNumbers(process.env.TWILIO_PHONE_NUMBER_SID)
  .update({
    smsUrl: 'https://hookcap.dev/e/your-endpoint-id',
    smsMethod: 'POST',
  });

For Status Callbacks

Status callbacks are configured per-message when you send:

const message = await client.messages.create({
  body: 'Hello from Twilio',
  from: process.env.TWILIO_PHONE_NUMBER,
  to: '+1234567890',
  statusCallback: 'https://hookcap.dev/e/your-endpoint-id',
});

For Voice

In the Twilio Console, go to Phone NumbersActive Numbers → select your number, then configure the Voice webhook URL.

Step 3: Trigger Events and Inspect Payloads

Send a message to your Twilio number (or call it). HookCap captures the webhook delivery and displays it in real time.

A typical incoming SMS webhook from Twilio looks like:

POST https://hookcap.dev/e/your-endpoint-id

Headers:
  Content-Type: application/x-www-form-urlencoded
  I-Twilio-Signature: AbCdEfGhIjKlMnOpQrStUvWxYz=
  X-Forwarded-For: 3.88.0.0

Body (form-encoded):
  ToCountry=US
  ToState=CA
  SmsMessageSid=SM1234567890abcdef1234567890abcdef
  NumMedia=0
  ToCity=SAN FRANCISCO
  FromZip=10001
  SmsSid=SM1234567890abcdef1234567890abcdef
  FromState=NY
  SmsStatus=received
  FromCity=NEW YORK
  Body=Hello world
  FromCountry=US
  To=+14155551234
  ToZip=94102
  NumSegments=1
  MessageSid=SM1234567890abcdef1234567890abcdef
  AccountSid=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  From=+12125551234
  ApiVersion=2010-04-01

Note that Twilio webhooks are form-encoded, not JSON. This matters for signature verification.

A status callback looks different — it includes the delivery status:

MessageSid=SM1234567890abcdef
MessageStatus=delivered
ErrorCode=
AccountSid=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Step 4: Verify the Twilio Signature

Twilio uses a different signature scheme from Stripe and GitHub. Instead of signing just the body, Twilio signs the full request URL plus all POST parameters.

The algorithm:

  1. Take the full URL (including query string)
  2. Append each POST parameter (key + value) in alphabetical order
  3. Sign the result with your Auth Token using HMAC-SHA1
  4. Base64-encode the result
const crypto = require('crypto');

function validateTwilioSignature(authToken, signature, url, params) {
  // Sort params alphabetically and concatenate key+value
  const sortedParams = Object.keys(params)
    .sort()
    .map(key => `${key}${params[key]}`)
    .join('');

  const stringToSign = url + sortedParams;

  const expectedSig = crypto
    .createHmac('sha1', authToken)
    .update(Buffer.from(stringToSign, 'utf-8'))
    .digest('base64');

  // Constant-time comparison
  return crypto.timingSafeEqual(
    Buffer.from(expectedSig),
    Buffer.from(signature)
  );
}

// In your Express handler
app.post('/webhook/twilio/sms', express.urlencoded({ extended: false }), (req, res) => {
  const signature = req.headers['x-twilio-signature'];
  const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;

  if (!validateTwilioSignature(
    process.env.TWILIO_AUTH_TOKEN,
    signature,
    url,
    req.body
  )) {
    return res.status(403).send('Forbidden');
  }

  const incomingSms = req.body;
  console.log(`SMS from ${incomingSms.From}: ${incomingSms.Body}`);

  // Respond with TwiML
  const twiml = new twilio.twiml.MessagingResponse();
  twiml.message('Got it!');
  res.type('text/xml');
  res.send(twiml.toString());
});

Or use the official Twilio helper library:

const twilio = require('twilio');

app.post('/webhook/twilio', express.urlencoded({ extended: false }), (req, res) => {
  const isValid = twilio.validateRequest(
    process.env.TWILIO_AUTH_TOKEN,
    req.headers['x-twilio-signature'],
    `https://yourdomain.com${req.path}`, // must be the exact URL Twilio used
    req.body
  );

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

  // Handle the webhook...
});

Important: The URL Must Match Exactly

Twilio signature verification is sensitive to the URL. If Twilio called https://yourdomain.com/webhook/sms but you reconstruct it as http://yourdomain.com/webhook/sms (wrong protocol) or https://yourdomain.com/webhook/sms?foo=bar (extra query param), verification will fail.

Step 5: Use HookCap Auto-Forward to Test Locally (Pro)

With HookCap’s Auto-Forward feature, you can forward captured webhooks to your local server — no tunnel setup needed.

  1. In your HookCap dashboard, enable Auto-Forward on your endpoint
  2. Set the forward URL to http://localhost:3000/webhook/twilio
  3. HookCap proxies incoming webhooks from Twilio to your local server in real time

This is more stable than ngrok or localtunnel for Twilio development because:

  • The HookCap URL stays constant (no need to reconfigure Twilio each time)
  • You can see both the raw Twilio payload AND your server’s response in the HookCap dashboard
  • If your local server is down, HookCap still captures the webhook for later replay

Common Twilio Webhook Issues

Signature Verification Fails Behind a Proxy

If your server sits behind a load balancer or reverse proxy, the URL your app sees may not match the URL Twilio used. The host, protocol, or port might differ. Fix this by explicitly reconstructing the URL:

// In Express with a trusted proxy
app.set('trust proxy', 1);
const url = `${req.protocol}://${req.hostname}${req.path}`;

Or just hardcode the production webhook URL:

const WEBHOOK_URL = 'https://yourdomain.com/webhook/twilio';
const isValid = twilio.validateRequest(authToken, sig, WEBHOOK_URL, req.body);

Form Encoding vs JSON

Twilio webhooks are application/x-www-form-urlencoded, not JSON. Make sure your framework parses them correctly:

// Correct: use urlencoded parser
app.post('/webhook/twilio', express.urlencoded({ extended: false }), handler);

// Wrong: json parser won't parse Twilio's form-encoded body
app.post('/webhook/twilio', express.json(), handler);

No Response TwiML

For incoming SMS and voice webhooks, Twilio expects a TwiML response. If you return JSON or plain text, Twilio will log an error (though your handler still “worked”). Return valid TwiML:

const twiml = new twilio.twiml.MessagingResponse();
twiml.message('Thank you for your message');
res.type('text/xml').send(twiml.toString());

For status callbacks, a plain 200 OK with no body is fine.

Debugging Workflow

  1. Capture the raw payload in HookCap — See exactly what Twilio sent, including all headers and the form-encoded body
  2. Check the SmsStatus or MessageStatus field — Know what state Twilio thinks the message is in
  3. Replay to your local handler — Use HookCap replay to send the exact captured payload to localhost
  4. Check the Twilio Console error logs — Under MonitorLogsErrors, Twilio shows delivery failures with reason codes

HookCap captures the full request including the X-Twilio-Signature header, which you can use to understand what URL Twilio is signing and debug verification failures.

Summary

Testing Twilio webhooks with HookCap:

  1. Create a HookCap endpoint and set it as your Twilio webhook URL
  2. Trigger real events (send SMS, make calls, or use the Twilio Console “Test” feature)
  3. Inspect the form-encoded payload and headers in HookCap
  4. Note: Twilio signs URL + sorted params (not just body) — use the Twilio SDK’s validation helper
  5. Use Auto-Forward to proxy live Twilio webhooks to your local server for integrated testing