Receive Twilio Webhooks Locally: Test Twilio Webhooks on localhost
Test Twilio webhooks locally without deploying. Forward incoming SMS and voice callbacks to localhost, handle Twilio's form-encoded body, and verify X-Twilio-Signature.
Building a Twilio integration means your code has to answer an HTTP request the moment someone texts or calls your number. The problem is obvious the first time you try it: Twilio needs a public URL, and your handler is running on localhost. You don't want to deploy to a staging server, redeploy on every change, and dig through remote logs just to see one inbound SMS.
This guide shows how to receive Twilio webhooks locally — forwarding live incoming SMS and voice callbacks straight to your machine so you can build and debug your handler entirely on localhost.
Why testing Twilio webhooks is tricky
Twilio webhooks are inbound HTTP requests. When a message hits your number, Twilio POSTs to whatever URL you configured and expects a response (usually TwiML) back within seconds. Two things make this awkward in development:
- Twilio needs a stable, public, HTTPS endpoint — it can't reach
localhost:3000on your laptop. - The payload isn't JSON. Incoming SMS and voice webhooks are sent as
application/x-www-form-urlencoded, so if you assume JSON you'll get an empty body and a confusing bug.
Webhook Relay solves the connectivity half: it gives you a stable public URL and forwards every request to your local server over an outbound connection — no firewall ports, no public IP.
Step 1: Inspect the payload with a Webhook Bin
Before you write a single line of handler code, look at what Twilio actually sends. Open the free Webhook Bin — you get an instant URL with no signup. Paste that URL into a Twilio number's Messaging webhook, text the number, and watch the request appear in your browser.
You'll see the request is a POST with Content-Type: application/x-www-form-urlencoded and form fields like:
MessageSid=SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
From=%2B15551234567
To=%2B15557654321
Body=Hello+from+Twilio
NumMedia=0
You'll also see the X-Twilio-Signature header. Now you know exactly what to parse — no guessing.
Step 2: Forward webhooks to localhost with the relay agent
Once you're ready to hit real code, install the relay agent (CLI or Docker), sign in, and forward to your local port. Say your handler listens on http://localhost:3000/sms:
relay forward --bucket twilio http://localhost:3000/sms
The agent prints a stable public URL like https://hook.relay.sh/v1/webhooks/.... Because the connection is outbound from your machine, you don't open any ports, and the URL stays the same across restarts — so you only configure Twilio once.
Step 3: Configure the webhook in the Twilio Console
Twilio webhook URLs are configured per resource, not globally:
- A phone number: Console → Phone Numbers → Manage → Active Numbers → select the number → set the A message comes in (Messaging) or A call comes in (Voice) webhook to your Webhook Relay URL, method
HTTP POST. - A Messaging Service: Console → Messaging → Services → your service → Integration → point it at the same URL.
- A TwiML App: Console → Voice → TwiML Apps, used for voice apps and clients.
Save, then text or call your number. The request travels Twilio → Webhook Relay → your localhost:3000/sms handler.
Step 4: Handle the form body and reply with TwiML
This is where most first attempts break. Read the body as form data, not JSON. A minimal Express handler:
const express = require("express");
const app = express();
// Twilio posts application/x-www-form-urlencoded
app.use(express.urlencoded({ extended: false }));
app.post("/sms", (req, res) => {
const from = req.body.From;
const text = req.body.Body;
console.log(`SMS from ${from}: ${text}`);
// Respond with TwiML (XML), not JSON
res.type("text/xml");
res.send(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Message>Got your message: ${text}</Message>
</Response>`);
});
app.listen(3000, () => console.log("listening on :3000"));
The response is TwiML — XML with a Content-Type of text/xml — telling Twilio what to do next (reply, forward, hang up). Returning JSON here is a common mistake.
Step 5: Verify the X-Twilio-Signature
In production you should confirm each request really came from Twilio. Twilio signs every webhook with HMAC-SHA1 using your account Auth Token as the key. The signed string is:
the full request URL + every POST parameter, sorted alphabetically by name and concatenated as
name``valuewith no delimiters
…then HMAC-SHA1'd and base64-encoded into the X-Twilio-Signature header.
The critical gotcha when developing locally: sign against the public URL Twilio actually called — your Webhook Relay URL — not the internal http://localhost:3000/sms URL. If you compute the signature over the localhost URL, it will never match. Either configure your validation with the public URL or set your framework to trust the forwarded host.
Twilio's official helper libraries (twilio for Node, Python, etc.) provide a RequestValidator that does the HMAC-SHA1 + base64 work for you — just pass the public URL, the parsed POST params, and the header. To understand what's happening under the hood, see our webhook signature verification guide, and use the free HMAC verifier to check a signature by hand while debugging.
Step 6: Replay and iterate
You don't want to text your number a hundred times while fixing a parser. Every request that flows through Webhook Relay is captured, so you can resend the exact same form-encoded payload to your handler as many times as you like. Tweak your code, replay, repeat — all on localhost, with no source-side event needed. For the broader workflow, see how to test webhooks.
Wrapping up
Receiving Twilio webhooks on localhost comes down to three things: a stable public URL that forwards to your machine, parsing the form-encoded body (not JSON) and replying with TwiML, and validating X-Twilio-Signature against the public URL. With Webhook Relay you get all of that without deploying or opening a single firewall port.
Sign up for free to start forwarding to localhost, or grab an instant Webhook Bin URL to inspect your first Twilio request right now.
