Gareth Wilson Gareth Wilson

How to Secure and Verify HubSpot Webhooks with Hookdeck

Published


HubSpot is one of the most widely adopted CRM and marketing platforms, used by over 200,000 businesses to manage contacts, deals, tickets, and marketing automation. HubSpot's webhooks API delivers real-time notifications when CRM data changes, enabling integrations with external databases, automation platforms, and notification systems.

However, accepting webhooks without proper verification leaves your application vulnerable to spoofed requests from malicious actors. In this guide, we'll show you how to secure and verify HubSpot webhooks manually and with Hookdeck, ensuring that every webhook your application processes genuinely originated from HubSpot. For a comprehensive overview of HubSpot webhook capabilities, see our guide to HubSpot webhooks.

Why webhook security matters

Webhooks are HTTP callbacks that notify your application when events occur. Without verification, an attacker could send fake webhook payloads to your endpoint, potentially triggering unauthorized actions like:

  • Creating or modifying CRM records based on fabricated events
  • Triggering automated workflows with spoofed data
  • Corrupting your customer database with fake contact or deal changes
  • Gaining unauthorized access to systems that trust webhook-triggered updates

HubSpot addresses this with multiple signature versions, with v3 (HMAC-SHA256) being the recommended approach. By verifying signatures, you can be confident that webhooks are authentic and haven't been tampered with in transit.

How HubSpot webhook signatures work

HubSpot supports three signature versions, each with increasing security:

VersionAlgorithmReplay protectionRecommended
v1SHA-256 (no HMAC key)NoNo
v2SHA-256 (no HMAC key)NoNo
v3HMAC-SHA256Yes (timestamp)Yes

v3 is the most secure option and the only version that uses HMAC and includes replay attack protection.

Every v3 webhook request includes two headers:

  • X-HubSpot-Signature-v3 — Base64-encoded HMAC-SHA256 signature
  • X-HubSpot-Request-Timestamp — Millisecond-precision Unix timestamp

The signature is computed by:

  1. Concatenating: {request_method}{request_uri}{request_body}{timestamp}
    • For example: POSThttps://your-endpoint.com/webhooks/hubspot[{"objectId":123,...}]1671552777
  2. Computing HMAC-SHA256 using the app's client secret as the key
  3. Base64-encoding the result

Signature v1 and v2 (legacy)

  • v1: SHA-256(client_secret + request_body) — simple concatenation, no HMAC key, no replay protection
  • v2: SHA-256(client_secret + http_method + URI + request_body) — adds method and URI but still no HMAC key

v1 and v2 do not use HMAC — they simply prepend the secret to the message before hashing. This makes them weaker than v3 and they should be considered legacy.

Getting your HubSpot client secret

The signing key for HubSpot webhooks is your app's client secret (not a separate webhook-specific secret):

For public apps

  1. Go to your HubSpot Developer Account
  2. Select your app
  3. Navigate to Auth
  4. Find the Client secret field

For private apps

  1. In your HubSpot account, go to Settings > Integrations > Private Apps
  2. Select your private app
  3. Find the Client secret in the app settings

Verifying webhook signatures manually (v3)

Verification process overview

  1. Extract the X-HubSpot-Request-Timestamp header and reject requests older than 5 minutes (prevents replay attacks)
  2. Concatenate: POST + full request URI + raw request body + timestamp
  3. Compute HMAC-SHA256 using the client secret as the key
  4. Base64-encode the result
  5. Compare with the X-HubSpot-Signature-v3 header using a timing-safe comparison

Node.js verification example

const express = require("express");
const crypto = require("crypto");

const app = express();
const HUBSPOT_CLIENT_SECRET = process.env.HUBSPOT_CLIENT_SECRET;

function verifyHubSpotWebhook(rawBody, req) {
  const timestamp = req.headers["x-hubspot-request-timestamp"];
  const signature = req.headers["x-hubspot-signature-v3"];

  if (!timestamp || !signature) {
    console.error("Missing HubSpot signature headers");
    return false;
  }

  // Reject requests older than 5 minutes (timestamp is in milliseconds)
  const currentTime = Date.now();
  if (Math.abs(currentTime - parseInt(timestamp, 10)) > 300000) {
    console.error("Webhook timestamp is outside tolerance window");
    return false;
  }

  // Build the signed string
  // Use the full request URL as HubSpot sees it
  const requestUri = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
  const signedString = `POST${requestUri}${rawBody}${timestamp}`;

  // Compute HMAC-SHA256
  const computedSignature = crypto
    .createHmac("sha256", HUBSPOT_CLIENT_SECRET)
    .update(signedString, "utf8")
    .digest("base64");

  // Use timing-safe comparison
  const receivedBuffer = Buffer.from(signature, "utf8");
  const expectedBuffer = Buffer.from(computedSignature, "utf8");

  if (receivedBuffer.length !== expectedBuffer.length) {
    return false;
  }

  return crypto.timingSafeEqual(receivedBuffer, expectedBuffer);
}

app.post(
  "/webhooks/hubspot",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString();

    if (!verifyHubSpotWebhook(rawBody, req)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const events = JSON.parse(rawBody);

    // HubSpot sends events in batches (up to 100 per request)
    for (const event of events) {
      switch (event.subscriptionType) {
        case "contact.creation":
          handleContactCreated(event);
          break;
        case "deal.propertyChange":
          handleDealPropertyChange(event);
          break;
        default:
          console.log(`Received event: ${event.subscriptionType}`);
      }
    }

    res.status(200).json({ received: true });
  }
);

app.listen(3000, () => {
  console.log("Webhook server listening on port 3000");
});

Python verification example

import hmac
import hashlib
import base64
import time
import os
from flask import Flask, request, jsonify, abort

app = Flask(__name__)
HUBSPOT_CLIENT_SECRET = os.environ.get("HUBSPOT_CLIENT_SECRET")

def verify_hubspot_webhook(raw_body, req):
    """Verify a HubSpot webhook v3 signature."""
    timestamp = req.headers.get("X-HubSpot-Request-Timestamp", "")
    signature = req.headers.get("X-HubSpot-Signature-v3", "")

    if not timestamp or not signature:
        return False

    # Reject requests older than 5 minutes (timestamp in milliseconds)
    current_time = int(time.time() * 1000)
    if abs(current_time - int(timestamp)) > 300000:
        return False

    # Build the signed string
    request_uri = request.url
    signed_string = f"POST{request_uri}{raw_body}{timestamp}"

    # Compute HMAC-SHA256
    computed_signature = base64.b64encode(
        hmac.new(
            HUBSPOT_CLIENT_SECRET.encode("utf-8"),
            signed_string.encode("utf-8"),
            hashlib.sha256
        ).digest()
    ).decode("utf-8")

    # Use timing-safe comparison
    return hmac.compare_digest(computed_signature, signature)

@app.route("/webhooks/hubspot", methods=["POST"])
def handle_webhook():
    raw_body = request.get_data(as_text=True)

    if not verify_hubspot_webhook(raw_body, request):
        abort(401, "Invalid signature")

    events = request.get_json()
    for event in events:
        print(f"Received verified {event.get('subscriptionType')} event")

    return jsonify({"received": True}), 200

if __name__ == "__main__":
    app.run(port=3000)

Critical security best practices

Use v3 signatures

Always verify using v3 (X-HubSpot-Signature-v3). v1 and v2 lack HMAC keying and replay protection, making them significantly weaker.

Use timing-safe comparisons

Never use standard equality operators (== or ===) to compare signatures. These can leak timing information that attackers exploit. Always use:

  • Node.js: crypto.timingSafeEqual()
  • Python: hmac.compare_digest()
  • Go: subtle.ConstantTimeCompare()

Implement replay attack prevention

Check the X-HubSpot-Request-Timestamp header and reject webhooks where the timestamp differs from your server time by more than 5 minutes. Note that HubSpot timestamps are in milliseconds, not seconds.

Match the full request URI

The v3 signature includes the full request URI as HubSpot sees it. If your application sits behind a reverse proxy, load balancer, or CDN that modifies the URL, the URI you reconstruct may not match what HubSpot used to compute the signature. Ensure your application has access to the original request URL.

Handle batched events

HubSpot sends up to 100 events per request. Each event in the batch may relate to a different CRM object or subscription type. Process each event individually and idempotently.

Preserve the raw request body

The HMAC signature is computed on the exact bytes HubSpot sends. Any parsing, reformatting, or middleware that modifies the body before verification will produce a different signature.

Simplifying verification with Hookdeck

Manually implementing and maintaining webhook verification across multiple providers can be complex and error-prone. Hookdeck provides a webhook gateway that handles verification automatically, allowing you to focus on processing events.

What is Hookdeck?

Hookdeck provides an event gateway that sits between webhook providers (like HubSpot) and your application. It provides:

  • Automatic signature verification
  • Event queuing and retry logic
  • Request logging and debugging tools
  • Local development tunneling

Setting up HubSpot webhooks with Hookdeck

Step 1: Install the Hookdeck CLI

  npm install hookdeck-cli -g
  
  
  yarn global add hookdeck-cli
  
  
    brew install hookdeck/hookdeck/hookdeck
    
    
  1.     scoop bucket add hookdeck https://github.com/hookdeck/scoop-hookdeck-cli.git
        
        
  2.   scoop install hookdeck
      
      
  1. Download the latest release's tar.gz file.

  2.     tar -xvf hookdeck_X.X.X_linux_x86_64.tar.gz
        
        
  3.   ./hookdeck
      
      

Step 2: Authenticate

hookdeck login

This opens your browser for authentication. If you don't have a Hookdeck account, you can create one during this step.

Step 3: Create a connection

hookdeck listen 3000 hubspot-source --path /webhooks/hubspot

This command:

  • Creates a public URL for receiving webhooks
  • Forwards events to http://localhost:3000/webhooks/hubspot
  • Displays the Source URL to configure in HubSpot

Step 4: Configure HubSpot

  1. Copy the Hookdeck Source URL from the CLI output
  2. In your HubSpot developer account, select your app
  3. Navigate to Webhooks
  4. Set the Target URL to your Hookdeck Source URL
  5. Create subscriptions for the events you want to receive
  6. Save the configuration

Step 5: Configure source verification

  1. Open the Hookdeck Dashboard
  2. Navigate to Connections and select your source
  3. Under Advanced Source Configuration, enable Source Authentication
  4. Select HubSpot from the list of platforms (if available), or configure using HMAC with:
    • Algorithm: SHA-256
    • Header: X-HubSpot-Signature-v3
    • Encoding: Base64
    • Secret: Your HubSpot app client secret
  5. Click Save

How Hookdeck verification works

When verification is enabled:

  1. Hookdeck receives the webhook from HubSpot
  2. Hookdeck validates the X-HubSpot-Signature-v3 against the payload using your client secret
  3. Valid requests are forwarded to your endpoint with x-hookdeck-verified: true
  4. Invalid requests are rejected and logged as "Verification Failed"

This means your application can trust any request from Hookdeck without implementing its own HubSpot signature verification logic. You only need to implement Hookdeck's signature verification on your server.

Troubleshooting common issues

Signature mismatch

If signatures don't match, verify:

  1. Raw body usage: Ensure you're using the exact bytes received, not a parsed/stringified version
  2. Secret accuracy: Confirm you're using the app's client secret, not the API key or a different credential
  3. Request URI: The URI must match exactly what HubSpot used — check for proxy rewrites, port differences, or scheme changes (HTTP vs HTTPS)
  4. Timestamp format: HubSpot timestamps are in milliseconds (not seconds) — make sure your tolerance check accounts for this
  5. Encoding: v3 signatures are Base64-encoded, not hex

Missing signature headers

  • Ensure your app has webhook subscriptions configured in the HubSpot developer portal
  • Private app webhooks use the same signature mechanism but are configured differently — check your private app settings

Event ordering issues

HubSpot does not guarantee event ordering. Use the occurredAt timestamp within each event to determine the actual sequence. For property change events, compare propertyValue against the current CRM state rather than assuming order.

Conclusion

Securing HubSpot webhooks requires verifying the HMAC-SHA256 signature (v3) on every incoming request. While you can implement this verification manually, Hookdeck simplifies the process by providing automatic signature verification, event management, and development tools, allowing you to focus on building your integration rather than infrastructure.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.