Test Twilio SMS webhooks locally
Receive Twilio SMS webhooks on localhost with a Horizon tunnel, verify X-Twilio-Signature, and reply with TwiML.
Receive incoming Twilio text messages on your laptop while you build, with a URL Twilio can reach.
Before you begin
- Node.js 18 or later
- A Horizon account and the CLI (see Getting started)
- A Twilio account with an SMS-capable phone number
- A Next.js app that uses the App Router
Start your app
Install the twilio package.
npm install twilioTwilio sends an incoming SMS as an HTTP POST with a form-encoded body (application/x-www-form-urlencoded). It expects TwiML back. TwiML is the XML that tells Twilio how to respond.
Create the route handler. It reads the form body, validates the X-Twilio-Signature header, and replies with TwiML.
import twilio from "twilio";
const PUBLIC_URL = "https://my-app.hrzn.run/api/twilio/sms";
export async function POST(request: Request) {
const signature = request.headers.get("x-twilio-signature") ?? "";
const formData = await request.formData();
const params = Object.fromEntries(
[...formData.entries()].map(([key, value]) => [key, String(value)]),
);
const isValid = twilio.validateRequest(
process.env.TWILIO_AUTH_TOKEN ?? "",
signature,
PUBLIC_URL,
params,
);
if (!isValid) {
return new Response("Invalid signature", { status: 403 });
}
const response = new twilio.twiml.MessagingResponse();
response.message(`You said: ${params.Body}`);
return new Response(response.toString(), {
headers: { "Content-Type": "text/xml" },
});
}Twilio signs the full URL it called, so PUBLIC_URL must be the Horizon URL, not localhost. Pick your own subdomain in the next step, then update the constant to match.
Set your Twilio auth token and start the app on port 3000.
TWILIO_AUTH_TOKEN=your_auth_token npm run devStart a tunnel
Open a second terminal. Use -s to pick a subdomain, so the URL stays the same when you restart.
hrzn tunnel http://localhost:3000 -s my-appHorizon serves your app at https://my-app.hrzn.run.
HORIZON: Tunnel connectedWithout -s the subdomain is random and changes every run. Your Twilio webhook URL then breaks on each restart. To keep a subdomain reserved for you, see Pricing.
Set the webhook in Twilio
- Log in to Twilio and open the Console's Numbers page.
- Select the phone number you want to use.
- Find the Messaging section and the A MESSAGE COMES IN option.
- Select Webhook and paste in
https://my-app.hrzn.run/api/twilio/sms. - Set the method to HTTP POST.
- Save the number.
Check it works
Text your Twilio number from your phone. Horizon prints one line for the request.
HORIZON: Tunnel connected
POST | [200] | /api/twilio/smsYour phone receives the reply You said: <your text>.
Troubleshooting
The signature check fails and the route returns 403
Twilio signs the exact URL it called. The URL in your code must match the webhook URL in the Console, character for character: protocol, subdomain, path, and any query string. Twilio's docs warn that if you decode or re-encode the URL, validation fails.
Check that PUBLIC_URL uses https://my-app.hrzn.run, not http://localhost:3000. Check that the auth token in TWILIO_AUTH_TOKEN belongs to the same Twilio account as the number.
request.formData() is empty or Body is undefined
Twilio sends a form-encoded body, not JSON. Read it with request.formData(), not request.json(). In an Express app, add the urlencoded body parser instead.
The webhook stopped working after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app, or paste the new URL into the number's webhook setting and into PUBLIC_URL. Use -s to avoid both.
Next steps
- Read about subdomains to keep the same URL across restarts.
- See Pricing to reserve a subdomain.
Test Slack events locally
Receive Slack Events API requests on localhost with a Horizon tunnel, answer the URL verification challenge, and verify request signatures.
Test Clerk webhooks locally
Receive Clerk webhook events on localhost with a Horizon tunnel, and verify them with verifyWebhook in a Next.js route.