Horizon

Test HubSpot webhooks locally

Receive HubSpot app webhook events on localhost with a Horizon tunnel, and verify the X-HubSpot-Signature-v3 signature.

Receive HubSpot events on your laptop while you build, with a URL HubSpot 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 HubSpot developer account with a public app, and its client secret

Start your app

HubSpot signs each request with the X-HubSpot-Signature-v3 header. The signature is a Base64-encoded HMAC SHA-256 (a keyed hash) of the request method, the request URI, the raw body and the X-HubSpot-Request-Timestamp header, joined in that order. The key is your app's client secret. HubSpot says to reject requests with a timestamp older than 5 minutes.

The URI is the full public URL, so the handler builds it from PUBLIC_BASE_URL. Before hashing, HubSpot also wants a fixed set of percent-encoded characters in the URI decoded. HubSpot recommends a constant-time comparison.

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

const MAX_AGE_MILLISECONDS = 5 * 60 * 1000;

const DECODED_CHARACTERS: Record<string, string> = {
  "%3A": ":",
  "%2F": "/",
  "%3F": "?",
  "%40": "@",
  "%21": "!",
  "%24": "$",
  "%27": "'",
  "%28": "(",
  "%29": ")",
  "%2A": "*",
  "%2C": ",",
  "%3B": ";",
};

function decodeUri(uri: string) {
  return uri.replace(
    /%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)/gi,
    (match) => DECODED_CHARACTERS[match.toUpperCase()],
  );
}

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

  const body = await request.text();
  const received = request.headers.get("x-hubspot-signature-v3") ?? "";
  const timestamp = request.headers.get("x-hubspot-request-timestamp") ?? "";

  if (Date.now() - Number(timestamp) > MAX_AGE_MILLISECONDS) {
    return new Response("Request too old", { status: 401 });
  }

  const { pathname, search } = new URL(request.url);
  const uri = decodeUri(`${baseUrl}${pathname}${search}`);
  const expected = createHmac("sha256", secret)
    .update(`${request.method}${uri}${body}${timestamp}`)
    .digest("base64");

  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 events = JSON.parse(body);
  console.log(`Received ${events.length} HubSpot event(s)`);

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

Store the secret and your public URL in environment variables. Find the client secret on your app's Auth tab.

.env.local
HUBSPOT_CLIENT_SECRET=replace-with-your-app-client-secret
PUBLIC_BASE_URL=https://my-app.hrzn.run

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 HubSpot target URL would point at a dead address 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 HubSpot

These steps are for a legacy public app.

  1. In your developer account, open Development, then Legacy apps.
  2. Select the name of your app.
  3. In the left sidebar, select Webhooks.
  4. Enter https://my-app.hrzn.run/api/webhooks/hubspot as the Target URL.
  5. Select Create subscription.
  6. In the right panel, select the object type and the events you want, then select Subscribe.
  7. Hover over the object type and select View subscriptions.
  8. Hover over your subscription and select Activate.

Trigger an event

HubSpot's webhook docs describe no test button for app webhooks. Trigger a real event instead: change the object you subscribed to in a HubSpot account where your app is installed. If you subscribed to contact creation, create a contact.

Check it works

Trigger an event as above. Your Horizon terminal prints one line for it:

Output
  POST    200  /api/webhooks/hubspot

Your app terminal prints:

Output
Received 1 HubSpot event(s)

HubSpot sends events as a JSON array, so the count can be higher than 1. If the line shows [401], see Troubleshooting.

Troubleshooting

The signature doesn't match

  • Check that HUBSPOT_CLIENT_SECRET is the client secret of the same app that owns the subscription.
  • Check that PUBLIC_BASE_URL is the exact public URL, with https:// and no trailing slash. HubSpot signs the public URL, not http://localhost:3000.
  • Hash 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 "Request too old"

The timestamp is older than 5 minutes. Check your computer's clock.

No events arrive

  • Check that you selected Activate on the subscription.
  • Check that the Target URL ends with /api/webhooks/hubspot.
  • Check that the Horizon terminal is still running. If its last line is Connection lost. Reconnecting…, wait for Reconnected.

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