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.
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.
HUBSPOT_CLIENT_SECRET=replace-with-your-app-client-secret
PUBLIC_BASE_URL=https://my-app.hrzn.runStart the app on port 3000.
npm run devStart 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-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 webhook in HubSpot
These steps are for a legacy public app.
- In your developer account, open Development, then Legacy apps.
- Select the name of your app.
- In the left sidebar, select Webhooks.
- Enter
https://my-app.hrzn.run/api/webhooks/hubspotas the Target URL. - Select Create subscription.
- In the right panel, select the object type and the events you want, then select Subscribe.
- Hover over the object type and select View subscriptions.
- Hover over your subscription and select Activate.
HubSpot subscriptions don't start active. Until you select Activate, no events arrive.
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:
POST 200 /api/webhooks/hubspotYour app terminal prints:
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_SECRETis the client secret of the same app that owns the subscription. - Check that
PUBLIC_BASE_URLis the exact public URL, withhttps://and no trailing slash. HubSpot signs the public URL, nothttp://localhost:3000. - Hash 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 "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 forReconnected.
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
- Read HubSpot's guide to validating requests.