---
title: "Receive Twilio Webhooks Locally | WebhookRelay"
meta:
  "og:description": "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."
  "og:title": "Receive Twilio Webhooks Locally"
  description: "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."
---

![Stripes](https://webhookrelay.com/blog/receive-twilio-webhooks-locally/images/stripes.svg)

# **Receive Twilio Webhooks Locally**

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.

![Receive Twilio Webhooks Locally: Test Twilio Webhooks on localhost](https://webhookrelay.com/blog/receive-twilio-webhooks-locally/images/blog/heroes/localhost.jpg)

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](#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](https://www.twilio.com/docs/messaging/twiml)) back within seconds. Two things make this awkward in development:

1. **Twilio needs a stable, public, HTTPS endpoint** — it can't reach `localhost:3000` on your laptop.
2. **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](#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](https://webhookrelay.com/blog/receive-twilio-webhooks-locally/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](#step-2-forward-webhooks-to-localhost-with-the-relay-agent)

Once you're ready to hit real code, install the [relay agent](https://webhookrelay.com/blog/receive-twilio-webhooks-locally/docs/webhooks/internal/localhost/) (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](#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](#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](#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\`\`value` with 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](https://webhookrelay.com/blog/receive-twilio-webhooks-locally/blog/verify-webhook-signature/), and use the free [HMAC verifier](https://webhookrelay.com/blog/receive-twilio-webhooks-locally/hmac-verification/) to check a signature by hand while debugging.

## [Step 6: Replay and iterate](#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](https://webhookrelay.com/blog/receive-twilio-webhooks-locally/blog/how-to-test-webhooks/).

## [Wrapping up](#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](https://my.webhookrelay.com/register) to start forwarding to localhost, or grab an instant [Webhook Bin](https://webhookrelay.com/blog/receive-twilio-webhooks-locally/webhook-bin/) URL to inspect your first Twilio request right now.