Horizon

Test Mux webhooks locally

Receive Mux Video webhook events on localhost with a Horizon tunnel, and verify the mux-signature header with the Mux SDK.

Receive Mux events on your laptop while you build, with a URL Mux can reach.

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 Next.js app that uses the App Router and runs on port 3000
  • A Mux account

Start your app

Install the Mux TypeScript SDK:

npm install @mux/ts

Create a route handler. Mux sends a mux-signature header that looks like t=<timestamp>,v1=<signature>. Mux builds v1 with HMAC-SHA256 (a keyed hash) over the timestamp, a dot and the raw request body. The SDK's webhooks.unwrap method checks this for you. It takes the raw body string, the headers and your signing secret, and throws when the signature is wrong.

app/api/webhooks/mux/route.ts
import Mux from "@mux/ts";

const mux = new Mux({
  webhookSecret: process.env.MUX_WEBHOOK_SECRET,
});

export async function POST(request: Request) {
  const body = await request.text();

  let event: Awaited<ReturnType<typeof mux.webhooks.unwrap>>;
  try {
    event = await mux.webhooks.unwrap(body, request.headers);
  } catch (error) {
    const message = error instanceof Error ? error.message : "Unknown error";
    console.log(`Mux signature verification failed: ${message}`);
    return new Response("Invalid signature", { status: 400 });
  }

  console.log(`Received Mux event: ${event.type}`);

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

Mux waits 5 seconds for a response. It retries for 24 hours when it doesn't get a 2xx. It can also send the same event twice, so make the handler safe to run twice.

Add the signing secret to .env.local. You copy it in a later step.

.env.local
MUX_WEBHOOK_SECRET=replace-with-your-signing-secret

Start the app on port 3000.

npm run dev

Start a tunnel

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

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.

Add the webhook in Mux

Your webhook URL is the tunnel URL plus the route path: https://my-app.hrzn.run/api/webhooks/mux.

  1. Open the webhooks settings in the Mux Dashboard. They sit under Settings.
  2. Switch to the environment you build in. Webhooks are scoped to one environment.
  3. Add a webhook and enter your URL.
  4. Copy the signing secret from the same page into MUX_WEBHOOK_SECRET in .env.local.
  5. Restart npm run dev so Next.js loads the variable.

Every webhook endpoint has its own signing secret. If you create the webhook with the Webhooks API, the secret is the signing_secret value in the create response. Mux returns it once.

Send a test event

Mux has a CLI that sends synthetic events and re-sends stored ones. Install it from the Mux CLI docs.

Send a synthetic event to your app:

mux webhooks trigger video.asset.ready --forward-to http://localhost:3000/api/webhooks/mux

Mux's CLI replay command re-sends events it stored during a mux webhooks listen session:

mux webhooks events list
mux webhooks events replay <event-id> --forward-to http://localhost:3000/api/webhooks/mux

Check it works

Create an asset in the environment you registered the webhook for. When it finishes processing, Mux sends video.asset.ready to your Horizon URL. Your Horizon terminal prints one line per event:

Output
  POST    200  /api/webhooks/mux

Your app terminal prints:

Output
Received Mux event: video.asset.ready

If the line shows [400], see Troubleshooting.

Troubleshooting

The signature doesn't match

  • Check that MUX_WEBHOOK_SECRET is the signing secret of the endpoint you registered. Each endpoint has its own.
  • Pass unwrap the raw body from request.text(). Don't run JSON.parse and JSON.stringify first, because that can change the bytes.
  • Restart npm run dev after you edit .env.local.
  • The Mux CLI signs with a different secret. Events from mux webhooks trigger fail against your Dashboard endpoint's secret. Set MUX_WEBHOOK_SECRET to the secret that mux webhooks listen --forward-to prints when you test with the CLI.

Verification fails on an old event

Mux's SDKs allow 5 minutes between the timestamp in the header and the current time by default. A stored event that you send again later can fall outside it.

The request returns 404

The Horizon line shows [404]. The webhook URL doesn't match your route. The file app/api/webhooks/mux/route.ts serves /api/webhooks/mux, and Mux sends POST requests.

The URL changed 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.

Nothing reaches your app

  • Check that the Horizon terminal is still running. If its last line is Connection lost. Reconnecting…, wait for Reconnected.
  • Check that the webhook is registered for the environment where the event happens.

Next steps

On this page