NewLocalCan 3.0 Beta with CLI, Multi-region and more ⟶

Test Twilio Webhooks Locally

Twilio signs the full public URL it called, not just the body — so behind a tunnel or proxy your app rebuilds http://localhost:3000/... while Twilio signed https://your-domain/... and validation silently returns false. Get one stable HTTPS URL Twilio reaches every time, see the exact X-Twilio-Signature it sent, and replay the request until your validator agrees.

Start Free Trial →DownloadFree trial. No credit card required.

30-second setup

From a stuck handler to a live Twilio delivery you can read and replay.

  1. 1Start LocalCan and point it at your local server: localcan http 3000. It opens the tunnel and prints your .local domains plus a public HTTPS URL.
  2. 2Grab your persistent URL — something like your-app-12.localcan.dev. It is saved to your project file and comes back identical after every restart, so you wire it up once.
  3. 3Point your number at the LocalCan URL: in the Twilio Console go to Phone Numbers → Manage → Active Numbers → your number, and set the Messaging "A message comes in" and/or Voice "A call comes in" webhook to your URL with your path, e.g. https://your-app-12.localcan.dev/sms and /voice (method POST). For StatusCallbacks, pass that same base URL as the StatusCallback param when you send a message or call.
  4. 4Easiest: text or call the number — a real inbound trigger hits your webhook. From the CLI you can repoint with twilio phone-numbers:update +1XXXXXXXXXX --sms-url=https://your-app-12.localcan.dev/sms, then send. To replay without spending a message, the twilio webhook:event plugin (@twilio-labs/plugin-webhook) emulates an SMS/voice request to your URL with a valid X-Twilio-Signature (or --no-signature to skip it).
  5. 5Open Inspect Traffic in LocalCan. The delivery lands with full headers and body — expand it, fix your handler, and hit Replay to send the exact same request again. No new test event needed.

Why localhost can't receive Twilio webhooks

Twilio delivers webhooks from its servers to the public HTTPS URL configured on your phone number. localhost:3000 only resolves on your machine, so Twilio has nothing to POST to — an inbound text or call to the number returns an application error (Twilio plays "an application error has occurred") and the failure lands in the Console Debugger as an 11200/11205.

You need a public URL that forwards to your local handler. With Twilio there is a second reason it must be stable: the signature is computed over that exact URL, so a URL that changes on restart does not just need re-pasting into the Console — every changed character (scheme, host, port, path, even a trailing slash) changes the signature your code has to match.

What makes the Twilio loop fast

One URL the signature can match

Twilio bakes the exact public URL into X-Twilio-Signature. A …localcan.dev URL survives restarts and sleeps, so the webhook saved on your number keeps resolving to the same host and path you validate against — set it once in the Console with twilio phone-numbers:update.

With a free-tier tunnel whose URL rotates, you are not only re-running phone-numbers:update every morning; the new host silently breaks signature validation until you also update what your validator reconstructs the URL from.

See the X-Twilio-Signature and the form body

The inspector shows the raw X-Twilio-Signature header plus the application/x-www-form-urlencoded params (From, To, Body, MessageSid, …) exactly as Twilio sent them. That is what you need to debug a false RequestValidator: the signature is over the URL + those params concatenated in case-sensitive sorted order with no delimiters, so seeing the literal bytes lets you tell a URL-scheme mismatch from a re-encoded param from a wrong Auth Token.

Replay instead of re-texting the number

Hit Replay to re-fire the identical captured request — same URL, same headers, same params — without sending another real SMS or placing another call and burning trial credit. For JSON callbacks that carry ?bodySHA256=…, Replay preserves the untouched raw body so the hash still matches while you fix validateRequestWithBody.

Inspect & replay Twilio events

Common events

  • Inbound SMS / MMSincoming Messaging webhook
  • Inbound voice callVoice webhook, expects TwiML
  • MessageStatusqueued → sent → delivered / undelivered / failed
  • CallStatusinitiated → ringing → completed / busy / no-answer
  • Inbound WhatsApp / ConversationsConversations webhook
  • Error / Debugger alertsTwiML & app error webhooks

These are the webhooks most Twilio apps wire up first. Text or call the number, watch the inbound SMS or voice request land in Inspect Traffic with its full headers and form params, and expand it to read From, Body, or CallStatus. When your TwiML handler 500s or your RequestValidator returns false, fix the code and hit Replay — LocalCan re-sends the exact same request, so you iterate against the real payload instead of re-texting the number each time.

Twilio-specific caveats: there is no per-message "redeliver this exact webhook" button on Twilio's side, so Replay is how you re-fire a captured delivery; and status callbacks arrive as a sequence (e.g. queued then sent then delivered), so capture each one — replaying a single status will not regenerate the others. Note that Twilio expects a timely 200 with valid TwiML for voice/SMS; an inbound call that waits on a slow handler can time out independently of replay.

Verify the Twilio signature

Header
X-Twilio-Signature
Algorithm
HMAC-SHA1, Base64-encoded
What's signed
Twilio signs the full request URL (scheme through the end of the query string), and for application/x-www-form-urlencoded POSTs it then appends every param as name immediately followed by value — params sorted by case-sensitive byte order (uppercase before lowercase, i.e. Object.keys().sort()), with NO delimiters between the URL or between params — then HMAC-SHA1 with your Auth Token and Base64-encodes the result. For application/json bodies there are no form params; Twilio instead appends ?bodySHA256=<hex sha256 of the raw body> to the URL, and you validate both the signature over that URL and that hex-SHA-256 of the raw body equals bodySHA256. If the URL your code reconstructs differs by one byte — scheme, host, port, trailing slash, or param encoding — the signature can never match.
Secret
The signing secret is your account's primary Auth Token from the Twilio Console (shown beside your Account SID) — an opaque 32-char hex-style string, no prefix, case-sensitive. Subaccounts have their own. Twilio supports a secondary Auth Token for zero-downtime rotation: deploy the secondary everywhere, then Promote it — promotion is instant and deletes the old primary immediately, so validate with the new primary right after promoting, not a still-secondary token.
Node.js
import express from 'express'
import twilio from 'twilio'

const authToken = process.env.TWILIO_AUTH_TOKEN // primary Auth Token, no prefix
const app = express()

// Form-encoded webhooks (inbound SMS / voice / StatusCallback).
// Parse the urlencoded body so the validator can re-sort + concatenate the params.
app.post('/sms', express.urlencoded({ extended: false }), (req, res) => {
  const signature = req.headers['x-twilio-signature']

  // Rebuild the EXACT public URL Twilio signed. Behind a tunnel/proxy your app
  // sees http + an internal host, but Twilio signed https + your public domain.
  const proto = req.headers['x-forwarded-proto'] || req.protocol
  const host = req.headers['x-forwarded-host'] || req.headers.host
  const url = proto + '://' + host + req.originalUrl

  // validateRequest recomputes HMAC-SHA1(url + sorted-params), Base64, constant-time compare.
  const valid = twilio.validateRequest(authToken, signature, url, req.body)
  if (!valid) {
    return res.status(403).send('Invalid Twilio signature')
  }

  res.type('text/xml').send('<Response><Message>Got it</Message></Response>')
})

// JSON callbacks carry ?bodySHA256=...; you MUST pass the RAW body string,
// not a re-serialized object (key order / whitespace would change the hash).
app.post('/callback', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-twilio-signature']
  const proto = req.headers['x-forwarded-proto'] || req.protocol
  const host = req.headers['x-forwarded-host'] || req.headers.host
  const url = proto + '://' + host + req.originalUrl
  const rawBody = req.body.toString('utf8')

  // Checks the signature over the URL AND that sha256(rawBody) === bodySHA256.
  const valid = twilio.validateRequestWithBody(authToken, signature, url, rawBody)
  res.sendStatus(valid ? 200 : 403)
})

LocalCan does not validate the signature for you — validateRequest / validateRequestWithBody does — but it hands you both halves so a false result is debuggable instead of silent. The inspector shows the real X-Twilio-Signature header, the original Host, and the untouched body/params, so you can tell a wrong scheme or host (the proxy reconstructing http://localhost instead of https://your-domain) from a re-encoded param from a wrong Auth Token in seconds.

Then Replay re-fires the same request — identical URL, headers, and raw body, including any ?bodySHA256= — so once you fix the URL reconstruction you can confirm the validator passes against the exact bytes Twilio sent.

LocalCan vs Twilio CLI

Twilio is honest about its local story: the twilio CLI does not tunnel and refuses localhost webhook URLs — it only repoints your number at an already-public URL. The genuinely first-party local helper is the Twilio-Labs plugin-webhook plugin, which emulates an SMS/voice request to your URL with a valid signature. The split: use those for a Twilio-only loop; reach for LocalCan when one stable HTTPS URL must serve Twilio and everything else, when you want a GUI inspector + replay, or when you need the real network/TLS path that local emulation skips.

LocalCanTwilio CLI
Stable public URLPersistent …localcan.dev, survives restartsNo tunnel; refuses localhost URLs
Set the number webhookPaste the URL once in the Console / phone-numbers:updatetwilio phone-numbers:update --sms-url=… (needs a public URL)
Emulate a webhook locallyNo emulator — forwards real requeststwilio webhook:event emulates SMS/voice with a valid signature
Real network / TLS / proxy pathReal Twilio request over real TLS through the tunnelLocal emulation skips carrier, TLS, and the SSL-termination layer
Works across providersOne URL for Twilio, Stripe, GitHub…Twilio only
Inspect full payloadsGUI inspector, headers + form/JSON body, on by defaultConsole Debugger / Monitor (web)
Replay a deliveryOne-click Replay of the exact request + raw bodyNo redeliver button; re-emulate or re-send
Share with a teammateShareable URL + basic authLocal only

Twilio webhook FAQ

Stop guessing why the webhook 401'd. Get a stable Twilio endpoint, read every delivery, and replay it until your handler is right.

Free trial. No credit card required.