Agent skill

Twilio Webhooks Skill

Receive and verify Twilio webhooks. Use when setting up Twilio webhook handlers, debugging X-Twilio-Signature verification, or handling communications events like incoming SMS, voice calls, message status callbacks (delivered, failed), or recording status callbacks.

Install this skill

npx skills add hookdeck/webhook-skills --skill twilio-webhooks


When to Use This Skill

  • How do I receive Twilio webhooks?
  • How do I verify Twilio webhook signatures (X-Twilio-Signature)?
  • How do I handle incoming SMS or voice calls with Twilio?
  • How do I process message status callbacks (queued, sent, delivered, failed)?
  • Why is my Twilio webhook signature verification failing?
  • Setting up Twilio webhook handlers for SMS, voice, WhatsApp, or recordings
  • Debugging Twilio signature verification with form-encoded or JSON bodies

Essential Code (USE THIS)

Twilio signs every webhook with X-Twilio-Signature using HMAC-SHA1 (base64). The signing key is your Twilio Auth Token. Twilio sends most webhooks as application/x-www-form-urlencoded, so the SDK is the recommended way to verify — it handles both form and JSON variants.

Express Webhook Handler (Twilio Node SDK)

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

const app = express();
const authToken = process.env.TWILIO_AUTH_TOKEN;

// Twilio sends form-encoded bodies for SMS/voice webhooks
app.post('/webhooks/twilio',
  express.urlencoded({ extended: false }),
  (req, res) => {
    const signature = req.headers['x-twilio-signature'];
    const url = `https://${req.headers.host}${req.originalUrl}`;

    // Verify signature using Twilio SDK
    const isValid = twilio.validateRequest(authToken, signature, url, req.body);
    if (!isValid) {
      return res.status(403).send('Invalid signature');
    }

    // Handle different webhook types based on parameters
    if (req.body.MessageSid && req.body.MessageStatus) {
      // Message status callback (queued, sent, delivered, failed, ...)
      console.log(`Message ${req.body.MessageSid}: ${req.body.MessageStatus}`);
      return res.status(204).send();
    }

    if (req.body.MessageSid && req.body.Body !== undefined) {
      // Incoming SMS - respond with TwiML
      res.type('text/xml');
      return res.send('<Response><Message>Got it!</Message></Response>');
    }

    if (req.body.CallSid) {
      // Incoming voice call - respond with TwiML
      res.type('text/xml');
      return res.send('<Response><Say>Hello from Twilio webhooks!</Say></Response>');
    }

    res.status(204).send();
  }
);

FastAPI Webhook Handler (Twilio Python SDK)

import os
from fastapi import FastAPI, Request, Response, HTTPException
from twilio.request_validator import RequestValidator

app = FastAPI()
validator = RequestValidator(os.environ["TWILIO_AUTH_TOKEN"])

@app.post("/webhooks/twilio")
async def twilio_webhook(request: Request):
    form = await request.form()
    params = dict(form)

    # Reconstruct the full URL Twilio called
    url = str(request.url)
    signature = request.headers.get("X-Twilio-Signature", "")

    if not validator.validate(url, params, signature):
        raise HTTPException(status_code=403, detail="Invalid signature")

    # Incoming SMS → return TwiML
    if params.get("MessageSid") and "Body" in params:
        return Response(
            content="<Response><Message>Got it!</Message></Response>",
            media_type="text/xml",
        )

    # Message status callback
    if params.get("MessageSid") and params.get("MessageStatus"):
        return Response(status_code=204)

    return Response(status_code=204)

For complete working examples with tests, see:

Common Event Types

Twilio doesn't use a single event field — the webhook type is inferred from the parameters Twilio sends and from the URL you configured (Messaging webhook URL, Voice URL, Status Callback URL, etc.).

WebhookIdentifying ParamsNotes
Incoming SMS / MMSMessageSid, From, To, Body, NumMediaRespond with TwiML <Response><Message>...</Message></Response>
Incoming voice callCallSid, From, To, CallStatusRespond with TwiML <Response><Say>...</Say></Response>
Message status callbackMessageSid, MessageStatusReturn 204; status is queued, sending, sent, delivered, undelivered, or failed
Call status callbackCallSid, CallStatusStatus is queued, ringing, in-progress, completed, busy, failed, no-answer, or canceled
Recording status callbackRecordingSid, RecordingStatus, RecordingUrlStatus is in-progress, completed, absent

For full payload reference, see Twilio Messaging webhooks and Voice TwiML reference.

Environment Variables

TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx  # From Twilio Console
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx    # Signing key for webhooks

The Auth Token is the signing key — do not use the Account SID for signature verification.

Local Development

# Tunnel public traffic to your local webhook endpoint
npx hookdeck-cli listen 3000 twilio --path /webhooks/twilio

Use the public URL printed by the CLI as your Twilio Messaging/Voice/Status Callback webhook URL.

Important: Twilio computes the signature over the exact URL you configured. If you're tunneling, configure Twilio with the tunnel URL — not localhost — or signature verification will fail.

Reference Materials