Twilio's webhook signature scheme is fundamentally different from Stripe / GitHub / Shopify — it does not sign the request body. Instead, it signs the full request URL plus the sorted form parameters. This breaks more verification code than any other webhook authentication scheme.
How Twilio Signs Requests
Twilio's signature is computed from:
- The full URL the webhook was sent to (including any query string).
- For each form parameter, sorted alphabetically by name: append
name + valueto the URL string. - HMAC-SHA1 the result with your auth token.
- Base64-encode the digest. Send as
X-Twilio-Signature.
This means: changing any part of the URL or any parameter changes the expected signature. Most failures come from mismatches in what the receiver thinks the URL is vs what Twilio thinks it is.
Root Causes
1. URL mismatch from reverse proxy
Twilio sends to https://api.example.com/webhook. Your nginx rewrites to https://internal.example.com/webhook before reaching your handler. Your handler reconstructs the URL using req.protocol + req.host + req.url = internal.example.com. Twilio signed api.example.com. Mismatch.
2. HTTP vs HTTPS
Twilio signed https://... but your reverse proxy strips TLS and your handler sees http://. The protocol is part of the signed URL. Use X-Forwarded-Proto to reconstruct the original protocol.
3. Query string ordering
If your URL has query parameters (?account=abc&event=ring), Twilio's signature includes them in the URL string. Reordering them changes the signed input. Don't normalize the query string.
4. Form parameter handling
Twilio sends application/x-www-form-urlencoded, not JSON. Your handler must parse form params. The signature math iterates over them sorted by key. Use Twilio's official SDK for verification — manually implementing the algorithm is error-prone.
Fix It — Use Twilio's SDK
import express from 'express'
import twilio from 'twilio'
const app = express()
const authToken = process.env.TWILIO_AUTH_TOKEN
app.post('/webhooks/twilio',
express.urlencoded({ extended: false }),
(req, res) => {
const sig = req.headers['x-twilio-signature']
// Reconstruct the original URL Twilio signed
const proto = req.headers['x-forwarded-proto'] || req.protocol
const host = req.headers['x-forwarded-host'] || req.get('host')
const url = \`\${proto}://\${host}\${req.originalUrl}\`
const valid = twilio.validateRequest(authToken, sig, url, req.body)
if (!valid) return res.status(401).send('invalid signature')
res.set('Content-Type', 'text/xml').send('<Response/>')
}
)
Twilio-Specific Gotchas
- Twilio webhooks expect a TwiML response (
<Response>...</Response>) for voice/SMS callbacks, not just 200. An empty<Response/>tells Twilio "no further action." - Twilio's signature uses HMAC-SHA1, not SHA256. SHA1 is otherwise deprecated; Twilio's docs explain why they kept it (legacy compatibility).
- The auth token rotates: your account auth token (in Twilio Console) is the secret for all webhooks across the account. Rotating it requires updating every receiver simultaneously.
- Twilio retries on 5xx; 4xx responses (including your 401) stop retries. Make sure 401 is what you want — it usually is, but verify before deploying.
How to Reproduce
In Twilio Console: Phone Numbers → click your number → click the webhook URL → "Test" button (some Console pages have this). The test fires a real signed webhook. If your handler rejects it, capture the request and reconstruct the URL Twilio actually sent vs what your handler thinks. The mismatch is the bug.
Frequently Asked Questions
Why does Twilio sign the URL instead of the body?
Historical: Twilio's signing scheme predates JSON webhooks. The form-encoded body could be any subset of voice/SMS params; signing the URL+params was the simplest scheme that covered all cases.
Can I use Twilio webhooks behind a load balancer?
Yes, but you must reconstruct the original URL using X-Forwarded-Proto and X-Forwarded-Host, not the internal URL your handler sees. Twilio's SDK handles this if you pass the right URL.
My Twilio webhook works for SMS but not voice. Why?
Voice webhooks include additional form parameters (CallSid, CallerName, etc.) that change the signed input. The verification math is identical — the SDK handles it. If verification fails for voice only, double-check you're using the right auth token (account-level, same for both).