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.
30-second setup
From a stuck handler to a live Twilio delivery you can read and replay.
- 1Start LocalCan and point it at your local server:
localcan http 3000. It opens the tunnel and prints your.localdomains plus a public HTTPS URL. - 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. - 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/smsand/voice(method POST). For StatusCallbacks, pass that same base URL as theStatusCallbackparam when you send a message or call. - 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, thetwilio webhook:eventplugin (@twilio-labs/plugin-webhook) emulates an SMS/voice request to your URL with a validX-Twilio-Signature(or--no-signatureto skip it). - 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 webhookInbound voice callVoice webhook, expects TwiMLMessageStatusqueued → sent → delivered / undelivered / failedCallStatusinitiated → ringing → completed / busy / no-answerInbound WhatsApp / ConversationsConversations webhookError / 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-urlencodedPOSTs 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. Forapplication/jsonbodies 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 equalsbodySHA256. 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.
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.
| LocalCan | Twilio CLI | |
|---|---|---|
| Stable public URL | Persistent …localcan.dev, survives restarts | No tunnel; refuses localhost URLs |
| Set the number webhook | Paste the URL once in the Console / phone-numbers:update | twilio phone-numbers:update --sms-url=… (needs a public URL) |
| Emulate a webhook locally | No emulator — forwards real requests | twilio webhook:event emulates SMS/voice with a valid signature |
| Real network / TLS / proxy path | Real Twilio request over real TLS through the tunnel | Local emulation skips carrier, TLS, and the SSL-termination layer |
| Works across providers | One URL for Twilio, Stripe, GitHub… | Twilio only |
| Inspect full payloads | GUI inspector, headers + form/JSON body, on by default | Console Debugger / Monitor (web) |
| Replay a delivery | One-click Replay of the exact request + raw body | No redeliver button; re-emulate or re-send |
| Share with a teammate | Shareable URL + basic auth | Local 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.