Horizon

Test HostedHooks webhooks locally

Receive HostedHooks webhook events on localhost with a Horizon tunnel, and verify the HostedHooks signature in a Next.js route.

Receive HostedHooks events on your laptop while you build, with a URL HostedHooks 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 HostedHooks account, or access to the HostedHooks subscriber view of an app that sends you webhooks
  • A Next.js App Router app

Start your app

HostedHooks sends a signature header with each event. The header holds a timestamp t= and a payload signature s=. To check it, build the signed payload from the timestamp, a . and the raw request body. Then compute an HMAC (a keyed hash) with SHA-256, using your endpoint's signing secret as the key. Compare the result with s in constant time.

HostedHooks documents the header as HTTP_HOSTEDHOOKS_SIGNATURE. That is the Rack and CGI spelling of the HostedHooks-Signature header, which Node reads as hostedhooks-signature.

app/api/webhooks/hostedhooks/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_IN_SECONDS = 300;

export async function POST(request: Request) {
  const secret = process.env.HOSTEDHOOKS_SIGNING_SECRET;
  if (!secret) {
    return new Response("Missing HOSTEDHOOKS_SIGNING_SECRET", { status: 500 });
  }

  const body = await request.text();
  const header = request.headers.get("hostedhooks-signature") ?? "";

  const parts = Object.fromEntries(
    header.split(",").map((part) => part.trim().split("=") as [string, string]),
  );
  const timestamp = parts.t ?? "";
  const received = parts.s ?? "";

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  const receivedBuffer = Buffer.from(received);
  const expectedBuffer = Buffer.from(expected);
  const isValid =
    receivedBuffer.length === expectedBuffer.length &&
    timingSafeEqual(receivedBuffer, expectedBuffer);

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

  const ageInSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(ageInSeconds <= TOLERANCE_IN_SECONDS)) {
    return new Response("Timestamp too old", { status: 401 });
  }

  const event = JSON.parse(body);
  console.log("Received HostedHooks event:", event);

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

HostedHooks leaves the tolerance to you. This handler accepts five minutes. HostedHooks creates a new timestamp and signature for every retry.

Store the signing secret from your endpoint page in an environment variable.

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

Start the app on port 3000.

npm run dev

HostedHooks retries until the URL returns a 200. Return 200 once you have stored the event.

Start a tunnel

Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so your HostedHooks endpoint would point at a dead URL after a restart. Reserve it first on the Subdomains page. 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 endpoint in HostedHooks

  1. Open the HostedHooks dashboard and go to the subscriber that receives the webhooks.
  2. Select Setup New Endpoint.
  3. Enter https://my-app.hrzn.run/api/webhooks/hostedhooks as the URL.
  4. Add a Description, leave Status active, and pick the Version you want.
  5. Save the endpoint.
  6. On the endpoint page, under Subscribed Events, pick an event and select Add Event.
  7. Copy the endpoint's signing secret into HOSTEDHOOKS_SIGNING_SECRET, then restart npm run dev.

Trigger and resend events

The endpoint page can send a sample payload for a subscribed event. The sample comes from the Data Payload that the app owner saved with the event.

If you own the HostedHooks app, you can also post a message through the HostedHooks API. HostedHooks shows a ready-made curl command on its Getting Started page.

To resend a failed attempt, open the subscription or endpoint page and select the replay button under the payload. On the endpoint page, Replay Failed Attempts replays several at once.

Check it works

Send a sample payload from the endpoint page. Your Horizon terminal prints one line for it:

Output
  POST    200  /api/webhooks/hostedhooks

Your app terminal prints Received HostedHooks event: followed by the payload. The webhook logs on the endpoint page show the attempt as succeeded.

Troubleshooting

The signature doesn't match

  • Check that HOSTEDHOOKS_SIGNING_SECRET is the signing secret of this endpoint.
  • Sign the raw body. 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 handler returns 401 for a valid signature

The timestamp is outside the five-minute window in the handler. Check your computer's clock. A replayed old attempt also gets a fresh timestamp from HostedHooks, so an old timestamp points at a clock problem.

The endpoint turned inactive

HostedHooks retries until the URL returns a 200. When every retry fails, it moves the endpoint to inactive and sends an email. Fix the handler, set Status back to active, and replay the failed attempts.

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 endpoint URL ends with /api/webhooks/hostedhooks.

Next steps

On this page