$npx -y skills add hookdeck/webhook-skills --skill twilio-webhooksReceive 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.
| 1 | # Twilio Webhooks |
| 2 | |
| 3 | ## When to Use This Skill |
| 4 | |
| 5 | - How do I receive Twilio webhooks? |
| 6 | - How do I verify Twilio webhook signatures (X-Twilio-Signature)? |
| 7 | - How do I handle incoming SMS or voice calls with Twilio? |
| 8 | - How do I process message status callbacks (queued, sent, delivered, failed)? |
| 9 | - Why is my Twilio webhook signature verification failing? |
| 10 | - Setting up Twilio webhook handlers for SMS, voice, WhatsApp, or recordings |
| 11 | - Debugging Twilio signature verification with form-encoded or JSON bodies |
| 12 | |
| 13 | ## Essential Code (USE THIS) |
| 14 | |
| 15 | 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. |
| 16 | |
| 17 | ### Express Webhook Handler (Twilio Node SDK) |
| 18 | |
| 19 | ```javascript |
| 20 | const express = require('express'); |
| 21 | const twilio = require('twilio'); |
| 22 | |
| 23 | const app = express(); |
| 24 | const authToken = process.env.TWILIO_AUTH_TOKEN; |
| 25 | |
| 26 | // Twilio sends form-encoded bodies for SMS/voice webhooks |
| 27 | app.post('/webhooks/twilio', |
| 28 | express.urlencoded({ extended: false }), |
| 29 | (req, res) => { |
| 30 | const signature = req.headers['x-twilio-signature']; |
| 31 | const url = `https://${req.headers.host}${req.originalUrl}`; |
| 32 | |
| 33 | // Verify signature using Twilio SDK |
| 34 | const isValid = twilio.validateRequest(authToken, signature, url, req.body); |
| 35 | if (!isValid) { |
| 36 | return res.status(403).send('Invalid signature'); |
| 37 | } |
| 38 | |
| 39 | // Handle different webhook types based on parameters |
| 40 | if (req.body.MessageSid && req.body.MessageStatus) { |
| 41 | // Message status callback (queued, sent, delivered, failed, ...) |
| 42 | console.log(`Message ${req.body.MessageSid}: ${req.body.MessageStatus}`); |
| 43 | return res.status(204).send(); |
| 44 | } |
| 45 | |
| 46 | if (req.body.MessageSid && req.body.Body !== undefined) { |
| 47 | // Incoming SMS - respond with TwiML |
| 48 | res.type('text/xml'); |
| 49 | return res.send('<Response><Message>Got it!</Message></Response>'); |
| 50 | } |
| 51 | |
| 52 | if (req.body.CallSid) { |
| 53 | // Incoming voice call - respond with TwiML |
| 54 | res.type('text/xml'); |
| 55 | return res.send('<Response><Say>Hello from Twilio webhooks!</Say></Response>'); |
| 56 | } |
| 57 | |
| 58 | res.status(204).send(); |
| 59 | } |
| 60 | ); |
| 61 | ``` |
| 62 | |
| 63 | ### FastAPI Webhook Handler (Twilio Python SDK) |
| 64 | |
| 65 | ```python |
| 66 | import os |
| 67 | from fastapi import FastAPI, Request, Response, HTTPException |
| 68 | from twilio.request_validator import RequestValidator |
| 69 | |
| 70 | app = FastAPI() |
| 71 | validator = RequestValidator(os.environ["TWILIO_AUTH_TOKEN"]) |
| 72 | |
| 73 | @app.post("/webhooks/twilio") |
| 74 | async def twilio_webhook(request: Request): |
| 75 | form = await request.form() |
| 76 | params = dict(form) |
| 77 | |
| 78 | # Reconstruct the full URL Twilio called |
| 79 | url = str(request.url) |
| 80 | signature = request.headers.get("X-Twilio-Signature", "") |
| 81 | |
| 82 | if not validator.validate(url, params, signature): |
| 83 | raise HTTPException(status_code=403, detail="Invalid signature") |
| 84 | |
| 85 | # Incoming SMS → return TwiML |
| 86 | if params.get("MessageSid") and "Body" in params: |
| 87 | return Response( |
| 88 | content="<Response><Message>Got it!</Message></Response>", |
| 89 | media_type="text/xml", |
| 90 | ) |
| 91 | |
| 92 | # Message status callback |
| 93 | if params.get("MessageSid") and params.get("MessageStatus"): |
| 94 | return Response(status_code=204) |
| 95 | |
| 96 | return Response(status_code=204) |
| 97 | ``` |
| 98 | |
| 99 | > **For complete working examples with tests**, see: |
| 100 | > - [examples/express/](examples/express/) — Full Express implementation using the Twilio Node SDK |
| 101 | > - [examples/nextjs/](examples/nextjs/) — Next.js App Router with manual HMAC-SHA1 verification |
| 102 | > - [examples/fastapi/](examples/fastapi/) — Python FastAPI using `twilio.request_validator.RequestValidator` |
| 103 | |
| 104 | ## Common Event Types |
| 105 | |
| 106 | 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.). |
| 107 | |
| 108 | | Webhook | Identifying Params | Notes | |
| 109 | |---------|--------------------|-------| |
| 110 | | Incoming SMS / MMS | `MessageSid`, `From`, `To`, `Body`, `NumMedia` | Respond with TwiML `<Response><Message>...</Message></Response>` | |
| 111 | | Incoming voice call | `CallSid`, `From`, `To`, `CallStatus` | Respond with TwiML `<Response><Say>...</Say></Response>` | |
| 112 | | Message status callback | `MessageSid`, `MessageStatus` | Return 204; status is `queued`, `sending`, `sent`, `delivered`, `undelivered`, or `failed` | |
| 113 | | Call status callback | `CallSid`, `CallStatus` | Status is `queued`, `ringing`, `in-progress`, `completed`, `busy`, `failed`, `no-answer`, or `cancele |