Test Twilio webhooks without deploying
Capture SMS, voice and status callbacks, and verify the X-Twilio-Signature built from the URL rather than the body.
Get a URL that accepts the webhook#
A capture URL takes any method, any body, and records exactly what arrived: method, headers, query string, body and source IP. You do not need an account to make one, and an anonymous URL is deleted after 24 hours.
curl -X POST https://mocklane.com/api/v1/anon/endpoints \
-H "Content-Type: application/json" -d '{}'
# The response carries a capture URL of the form:
# https://mocklane.com/h/<endpoint-id>Paste that URL into Twilio at Console phone number settings, or the StatusCallback URL on an API call, then trigger something. Events worth starting with:
incoming SMSmessage status callbackvoice call statusrecording ready
Verify the X-Twilio-Signature header#
Twilio signs each request so you can tell a real delivery from anyone who has guessed your URL. The digest is HMAC-SHA1 over the full request URL with its POST parameters sorted alphabetically and appended, not the body on its own, base64-encoded, keyed with your account auth token.
- Header
X-Twilio-Signature- Algorithm
- HMAC-SHA1, base64
- Signed content
- the full request URL with its POST parameters sorted alphabetically and appended, not the body on its own
const { validateRequest } = require("twilio");
// Twilio is the odd one out: the signature covers the URL plus the sorted
// POST parameters, and it is SHA-1, not SHA-256. Use the helper rather than
// rebuilding the string by hand.
const ok = validateRequest(
process.env.TWILIO_AUTH_TOKEN,
req.headers["x-twilio-signature"],
"https://your-app.example.com/webhooks/twilio", // the exact URL Twilio called
req.body, // the parsed form parameters
);
// JSON bodies are different again: the signed URL carries a bodySHA256 query
// parameter, and you validate with validateRequestWithBody.crypto.timingSafeEqual. A plain === returns as soon as two bytes differ, and that timing difference is enough to recover a valid signature one byte at a time.What catches people out#
- The URL is part of the signature, so it must be the exact public URL Twilio called, including scheme, port and query string, with no decoding or re-encoding.
- This is the only provider here still on SHA-1, and the digest is base64.
- For JSON payloads Twilio appends a bodySHA256 query parameter to the URL and you must use the body-aware validator instead.
- Twilio may add new parameters without notice, so sign every parameter received rather than a list you maintain.
- A proxy or load balancer that rewrites the host or scheme will break validation, because the URL you reconstruct no longer matches the one signed.
Replay it until your handler is right#
A captured request can be re-sent to your own endpoint, with the body or headers edited first. That turns a bug that needed a real Twilio event into something you can reproduce on demand, which matters most for events that are awkward to trigger twice.
Assert on it in CI#
A scenario chains the steps into one run that passes or fails, so a change that breaks your Twilio handling fails the build rather than reaching production.
steps:
- type: send_webhook
body: { event: "incoming SMS" }
- type: wait_for_webhook
timeout_seconds: 30
assert:
- status_code equals 200Triggered from CI with an API token. See the scenarios documentation for the full step list.
Signature details checked against Twilio’s documentation on 22 October 2026. Schemes change: if this page and their docs disagree, believe theirs and tell us.