48-hour launch: 40% off with code LAUNCH40, Guide $5.40 / Kit $11.40

Twilio webhook signature validation failed (X-Twilio-Signature): causes and fixes

Twilio signs every webhook. It takes the exact URL it called (scheme, host, port, path and query string). For a form POST, it then adds every form parameter, sorted alphabetically by name, with each name and value appended and no delimiters. It runs HMAC-SHA1 over that string with your Auth Token as the key, then base64-encodes the result into X-Twilio-Signature. Use the SDK's validator, and make sure you give it the same URL and parameters Twilio used.

Common causes

  1. Your app sees a different URL. Behind a load balancer, PaaS router or ngrok, TLS often ends upstream. Your app then sees http:// and an internal host, while Twilio signed https://your-domain/.... Rebuild the public URL, for example from X-Forwarded-Proto and X-Forwarded-Host if you trust your proxy, or hard-code it.
  2. Re-encoded URL. Twilio's docs say to pass the URL exactly as Twilio requested it, including any URL-encoded characters. Decoding or re-encoding it breaks validation.
  3. Query params passed twice. Query parameters are already part of the URL. Don't also pass them in the params argument. Pass only the POST form fields there.
  4. Wrong Auth Token. Use the Auth Token of the account (or subaccount) that owns the number or app. It's case-sensitive. API Key secrets won't work.
  5. JSON bodies. For requests that aren't form-encoded, Twilio adds a bodySHA256 query parameter, and the SDKs have a separate method that validates the raw body against it. Don't pass parsed JSON as form params.
  6. Missing form fields. Body-parser limits, or frameworks that drop empty values, change the parameter set. Pass every field Twilio sent.

Node (Express)

const twilio = require('twilio');
app.post('/sms', express.urlencoded({ extended: false }), (req, res) => {
  const proto = req.get('x-forwarded-proto') || req.protocol;          // only trust headers from your own proxy
  const host = req.get('x-forwarded-host') || req.get('host');
  const url = `${proto}://${host}${req.originalUrl}`;
  const ok = twilio.validateRequest(process.env.TWILIO_AUTH_TOKEN, req.get('X-Twilio-Signature') || '', url, req.body);
  if (!ok) return res.sendStatus(403);
  res.type('text/xml').send('<Response/>');
});

In Express you can also use the twilio.webhook() middleware. With app.set('trust proxy', true), req.protocol reflects X-Forwarded-Proto.

Python (Flask)

from twilio.request_validator import RequestValidator
v = RequestValidator(os.environ['TWILIO_AUTH_TOKEN'])
url = 'https://your-domain.com' + request.full_path.rstrip('?')   # the public URL Twilio called
if not v.validate(url, request.form, request.headers.get('X-Twilio-Signature', '')): abort(403)

The guide is Stripe-only. The kit is a self-hosted Cloudflare Worker relay that adds retries, replay and a dead-letter list in front of your endpoint. It verifies X-Twilio-Signature out of the box, for both form and JSON (bodySHA256) webhooks. Set SCHEME_<ID>=twilio. Use it for Twilio (status callbacks). Voice and SMS webhooks that need a TwiML reply can't go through the relay. It works the same way for Stripe, Lemon Squeezy, Shopify, Paddle Billing, Square and GitHub.

Fix Your Stripe Webhooks guide · Self-Hosted Webhook Relay Kit. Code LAUNCH40 = 40% off until Sun Oct 11, 6:40 AM MT.

Free download: Webhook debugging cheat sheet.

Fighting webhooks on more than one platform? The Fix Your Stripe Webhooks guide ($9) covers raw-body and signature problems in depth, and the Self-Hosted Webhook Relay Kit ($19) adds retries, replay and a dead-letter list in front of any endpoint.