Twilio webhooks: testing & signature validation

Twilio calls your webhook for inbound SMS, voice calls and status callbacks and signs it with X-Twilio-Signature: a base64 HMAC-SHA1 of the exact URL plus sorted POST params, keyed by your Auth Token. Most failures: the URL changed or a proxy rewrote it.

Twilio's webhooks drive phone calls and text messages, which makes them uniquely painful to debug: you can't just re-send an event from a dashboard, you have to make a phone call or burn SMS credit. And the signature scheme is unlike Stripe, GitHub, or Shopify — so the middleware you copy-pasted from another integration will fail silently. This guide covers both halves: where Twilio sends webhooks, and how X-Twilio-Signature validation actually works.

Where Twilio calls your webhooks

  • Voice calls. When someone calls your Twilio number, Twilio makes an HTTP request to the number's configured Voice URL and expects TwiML (XML instructions like <Say> or <Dial>) in the response. It's a webhook with a synchronous contract: your response body is the program Twilio executes. Separate status-callback URLs fire on call progress events (initiated, ringing, answered, completed).
  • Inbound SMS. A message to your number (or Messaging Service) hits the configured webhook URL with form fields like From, To, Body, and MessageSid. Respond with TwiML to reply, or with an empty <Response> to just log it.
  • Outbound SMS status callbacks. When you send a message via the API, pass a StatusCallback URL (or set one on the Messaging Service). Twilio POSTs delivery events there as the message moves through queued → sent → delivered, or undelivered/failed.
  • TwiML Bins. Twilio-hosted, static TwiML documents. Zero server required, but also zero logic — a Bin returns the same XML every time. Great for a fixed <Say> greeting; useless the moment you need per-request data. Anything dynamic needs your own endpoint.

The signature scheme nobody else uses

Every Twilio webhook request carries an X-Twilio-Signature header. It is a base64-encoded HMAC-SHA1 — not SHA-256 — keyed by your account's Auth Token (from the Twilio Console), not a per-endpoint signing secret.

The signed data is just as unusual. It is not the request body. It is the full request URL, exactly as Twilio called it (query string included), with every POST parameter appended to it — sorted alphabetically by parameter name, concatenated as name + value with no separators, no delimiters, no URL-encoding:

url  = "https://api.example.com/twilio/voice"
data = url + "CallSidCA123…" + "Caller+1555…" + "From+1555…" + "To+1800…"
       // params sorted by name: CallSid, Caller, From, To
sig  = base64( HMAC_SHA1(auth_token, data) )

Three details trip everyone up:

  • Query parameters live in the URL, period. If your webhook URL is https://example.com/hook?foo=1, foo is signed as part of the URL. Never pull query params out and pass them to the validator as POST params — and never decode or re-encode the URL. Any re-encoding changes the bytes and the signature breaks.
  • The parameter set evolves. Twilio explicitly reserves the right to add new parameters without notice. Validate against everything you received, not a hardcoded list of fields you saw today.
  • JSON bodies validate differently. For application/json webhooks, Twilio appends a bodySHA256 query parameter to your webhook URL — a SHA-256 hash of the raw body — and signs the URL including that parameter. Don't extract JSON properties as if they were form params; validate the raw body string instead (the SDKs have a dedicated method, below).

Side by side with the providers you're probably used to:

TwilioStripe / GitHub / Shopify
AlgorithmHMAC-SHA1HMAC-SHA256
KeyAccount Auth TokenPer-endpoint / app secret
Signed dataURL + sorted form paramsRaw request body
Header encodingbase64hex

Different input, different digest, different encoding — a validator written for one scheme will never accidentally pass for the other. See the Stripe and GitHub playbooks for their schemes.

Verify it with the official SDK

Twilio's own docs say it plainly: use their validation library, don't hand-roll. In Node, that's twilio.validateRequest for form-encoded requests:

import express from "express";
import twilio from "twilio";

const app = express();

// Twilio POSTs application/x-www-form-urlencoded, not JSON.
app.post("/webhooks/twilio/sms-status", express.urlencoded({ extended: false }), (req, res) => {
  const signature = req.get("X-Twilio-Signature") ?? "";

  // The URL Twilio is configured to call — exactly, query string included.
  const url = "https://api.example.com/webhooks/twilio/sms-status";

  const valid = twilio.validateRequest(
    process.env.TWILIO_AUTH_TOKEN!,
    signature,
    url,
    req.body, // every POST param received, not a cherry-picked subset
  );
  if (!valid) return res.sendStatus(403);

  const { MessageSid, MessageStatus } = req.body;
  // …record delivery state for MessageSid…
  res.sendStatus(200);
});

For JSON-bodied webhooks, use twilio.validateRequestWithBody(authToken, signature, url, rawBody) — with the URL including the bodySHA256 query parameter. It verifies the body's SHA-256 against that parameter and the signature over the URL, so a tampered body fails even if the attacker recomputed the outer hash.

Why validation fails in production (but worked locally)

  • The URL changed. The signature covers the exact URL Twilio dialed. Configured https://abc123.ngrok-free.app/hook in dev, deployed behind https://api.example.com/hook in prod? Different URL, different signature. Hardcoding the ngrok URL in your validator is the classic self-own.
  • A proxy rewrote the request. Reverse proxies and frameworks love "helpfully" normalizing URLs: stripping a trailing slash, dropping the port, rewriting http to https after a redirect, or swallowing the query string in routing. Every one of those changes the signed input. Validate against the URL Twilio has configured, not the one your framework reconstructed from the request.
  • The form body got re-encoded. If you parse application/x-www-form-urlencoded and then re-serialize the params (different encoding, different order), the HMAC input no longer matches. Pass the parsed params straight to the SDK; it sorts them itself.
  • You hardcoded the param list. Works until Twilio adds a field — then every request 403s and your SMS status updates silently stop. Always validate the complete received set.

Also note: Twilio publishes no fixed IP ranges for webhook egress (cloud architecture), so IP allow-listing isn't an option. Signature validation is your authentication — treat a missing or invalid signature as a 403, every time.

Testing status callbacks with a bin

The fastest way to see real Twilio traffic without standing up a server:

  1. Create a free bin at view.webhooks.page — no account, requests land live.
  2. Send yourself an SMS via the API with StatusCallback set to the bin URL (or set the bin as the callback on a Messaging Service).
  3. Watch queued, sent, delivered arrive as separate requests, each with the full form body and the X-Twilio-Signature header intact.

Because the signature is computed against the bin URL, what you capture is a real, correctly-signed request — perfect raw material for replaying against your local handler later (swap the URL, and you must recompute; the local testing guide walks that loop). One catch: Twilio refuses self-signed certificates and requires valid HTTPS, which is exactly why a hosted bin or tunnel is mandatory — http://localhost never receives a byte.

Once your handler validates real signatures and processes real status callbacks, the remaining Twilio work is business logic. The wire part is boring again — which is the goal.

Last updated

Try it in 10 seconds.

Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.