Horizon

Test Plivo SMS webhooks locally

Receive Plivo incoming SMS webhooks on localhost with a Horizon tunnel, and verify the X-Plivo-Signature-V2 header with the Plivo Node SDK.

Receive incoming Plivo text messages on your laptop while you build, with a public HTTPS URL Plivo can reach.

Horizon has no Plivo integration. Plivo calls a public URL, and Horizon provides that URL.

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A reserved subdomain for -s. Reserve one on the Subdomains page.
  • A Plivo account with an SMS-capable phone number
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

Install the plivo package. It includes the signature helper.

npm install plivo

Plivo sends an incoming SMS as a POST with a form-encoded body (application/x-www-form-urlencoded). The body has From, To and Text. Plivo signs the request in the X-Plivo-Signature-V2 header. It also sends X-Plivo-Signature-V2-Nonce. The signature is an HMAC-SHA256 of the URL plus the nonce, keyed with your Auth Token. It doesn't cover the body.

validateSignature needs the URL Plivo called. Set it in an environment variable, so it is the Horizon URL and not localhost.

app/api/webhooks/plivo/route.ts
import { validateSignature } from "plivo";

export async function POST(request: Request) {
  const authToken = process.env.PLIVO_AUTH_TOKEN;
  const webhookUrl = process.env.PLIVO_WEBHOOK_URL;
  if (!authToken || !webhookUrl) {
    return new Response("Missing PLIVO_AUTH_TOKEN or PLIVO_WEBHOOK_URL", { status: 500 });
  }

  const signature = request.headers.get("x-plivo-signature-v2") ?? "";
  const nonce = request.headers.get("x-plivo-signature-v2-nonce") ?? "";

  if (!validateSignature(webhookUrl, nonce, signature, authToken)) {
    return new Response("Invalid signature", { status: 403 });
  }

  const form = new URLSearchParams(await request.text());
  console.log(`Message from ${form.get("From")} to ${form.get("To")}: ${form.get("Text")}`);

  return new Response("Message received", { status: 200 });
}

Find your Auth Token in the Plivo console. Add both variables to .env.local:

.env.local
PLIVO_AUTH_TOKEN=replace-with-your-auth-token
PLIVO_WEBHOOK_URL=https://my-app.hrzn.run/api/webhooks/plivo

Start the app.

npm run dev

Start a tunnel

In a second terminal, open a tunnel on a subdomain you reserved.

hrzn tunnel http://localhost:3000 -s my-app
Output
HORIZON: Tunnel connected
  URL          https://my-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-app

Your public URL is https://my-app.hrzn.run. Keep this terminal open.

Use -s. Without it, the subdomain is random and changes every run, so your Plivo Message URL would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.

Set the Message URL in Plivo

Plivo calls the Message URL of the application assigned to your number.

  1. In the Plivo console, go to Messaging, then Applications.
  2. Select Add New Application.
  3. Enter a name, for example Receive SMS.
  4. Set Message URL to https://my-app.hrzn.run/api/webhooks/plivo and the method to POST.
  5. Select Create Application.
  6. Open the Numbers page and select your phone number.
  7. Set Application Type to XML Application.
  8. In the Plivo Application dropdown, select the application you created.
  9. Select Update Number.

Send a test message

Plivo has no resend button for incoming messages. Text your Plivo number from your phone.

Check it works

The Horizon terminal prints one line for the request:

Output
  POST    200  /api/webhooks/plivo

Your app terminal prints Message from <your number> to <your Plivo number>: <your text>.

Troubleshooting

The route returns 403

The signature check failed. Plivo signs the URL it called, so PLIVO_WEBHOOK_URL must match the Message URL in the console: protocol, subdomain and path.

  • Check that PLIVO_WEBHOOK_URL uses https://my-app.hrzn.run, not http://localhost:3000.
  • Check that PLIVO_AUTH_TOKEN belongs to the account or subaccount that owns the application. X-Plivo-Signature-V2 uses that token. X-Plivo-Signature-Ma-V2 always uses the main account token.
  • Restart npm run dev after you edit .env.local.

Text is empty

Plivo sends a form-encoded body, not JSON. Read it with URLSearchParams or request.formData(), not request.json().

The Message URL stopped working after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.

Next steps

On this page