Horizon

Test Pusher Channels webhooks locally

Receive Pusher Channels webhooks on localhost with a Horizon tunnel, and verify the X-Pusher-Signature header with the Pusher Node library.

Receive Pusher Channels events such as channel_occupied on your laptop while you build, with a public HTTPS URL Pusher can reach.

Horizon has no Pusher integration. Pusher posts webhooks to 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 Pusher Channels app
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

Pusher sends two headers with each webhook. X-Pusher-Key names the app key. X-Pusher-Signature is the HMAC SHA256 hex digest of the POST body, signed with that key's secret. The body is JSON, with time_ms and an events array.

Install the pusher package. Its webhook helper checks the key and the signature.

npm install pusher

The helper wants the lower-case headers and the raw body.

app/api/webhooks/pusher/route.ts
import Pusher from "pusher";

const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID ?? "",
  key: process.env.PUSHER_KEY ?? "",
  secret: process.env.PUSHER_SECRET ?? "",
  cluster: process.env.PUSHER_CLUSTER ?? "",
});

export async function POST(request: Request) {
  const rawBody = await request.text();
  const webhook = pusher.webhook({
    headers: Object.fromEntries(request.headers),
    rawBody,
  });

  if (!webhook.isValid()) {
    return new Response("Invalid signature", { status: 401 });
  }

  for (const event of webhook.getEvents()) {
    console.log(`Received Pusher event: ${event.name} on ${event.channel}`);
  }

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

Pusher expects a 2xx response. For any other code, it retries with exponential backoff for 5 minutes.

Find the app ID, key, secret and cluster in your app in the Pusher dashboard. Add them to .env.local:

.env.local
PUSHER_APP_ID=replace-with-your-app-id
PUSHER_KEY=replace-with-your-key
PUSHER_SECRET=replace-with-your-secret
PUSHER_CLUSTER=replace-with-your-cluster

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 Pusher webhook would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.

Add the webhook in Pusher

Webhooks are set per app.

  1. Sign in to the Pusher dashboard and open your Channels app.
  2. Select Webhooks, then Add webhook.
  3. Set Webhook URL to https://my-app.hrzn.run/api/webhooks/pusher.
  4. Set Event Type to Channel existence.
  5. Save the webhook.

Trigger an event

Pusher has no resend button. Cause a real event instead. A channel_occupied event fires when a channel gets its first subscriber. Subscribe from any client, for example with pusher-js:

import Pusher from "pusher-js";

const client = new Pusher("your-key", { cluster: "your-cluster" });
client.subscribe("my-channel");

Check it works

The Horizon terminal prints one line for the request:

Output
  POST    200  /api/webhooks/pusher

Your app terminal prints:

Output
Received Pusher event: channel_occupied on my-channel

Pusher sends channel_vacated up to three seconds after the last client leaves.

Troubleshooting

The route returns 401

The Horizon line shows [401]. The key or signature check failed.

  • Check that PUSHER_KEY and PUSHER_SECRET belong to the app that owns the webhook. The helper compares X-Pusher-Key with your key.
  • Restart npm run dev after you edit .env.local.
  • Read the body with request.text(). Pusher signs the exact bytes.
  • The helper also checks that the content type is JSON.

Nothing reaches your app

Check that the Webhook URL ends with /api/webhooks/pusher and that the event type matches what you trigger. Check that the Horizon terminal is still running.

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.

Next steps

On this page