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.
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.
HOSTEDHOOKS_SIGNING_SECRET=replace-with-your-signing-secretStart the app on port 3000.
npm run devHostedHooks 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-appHORIZON: Tunnel connected
URL https://my-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-appYour public URL is https://my-app.hrzn.run. Keep this terminal open.
Add the endpoint in HostedHooks
- Open the HostedHooks dashboard and go to the subscriber that receives the webhooks.
- Select Setup New Endpoint.
- Enter
https://my-app.hrzn.run/api/webhooks/hostedhooksas the URL. - Add a Description, leave Status active, and pick the Version you want.
- Save the endpoint.
- On the endpoint page, under Subscribed Events, pick an event and select Add Event.
- Copy the endpoint's signing secret into
HOSTEDHOOKS_SIGNING_SECRET, then restartnpm 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:
POST 200 /api/webhooks/hostedhooksYour 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_SECRETis the signing secret of this endpoint. - Sign the raw body. Don't run
JSON.parseandJSON.stringifyfirst, because that can change the bytes. - Restart
npm run devafter 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 forReconnected. - Check that the endpoint URL ends with
/api/webhooks/hostedhooks.
Next steps
- Read HostedHooks' guide to webhook signatures.
- Read HostedHooks' guide to managing replays.
Test Amazon SNS HTTPS subscriptions locally
Receive Amazon SNS messages on localhost with a Horizon tunnel, confirm the subscription, and verify the message signature in Next.js.
Test MongoDB Atlas webhooks locally
Receive MongoDB Atlas alert webhooks on localhost with a Horizon tunnel, and verify the X-MMS-Signature header.