Agent skill

HubSpot Webhooks Skill

Receive and verify HubSpot webhooks. Use when setting up HubSpot webhook handlers, debugging X-HubSpot-Signature-v3 signature verification, or handling CRM events like contact.creation, contact.propertyChange, or deal.creation.

Install this skill

npx skills add hookdeck/webhook-skills --skill hubspot-webhooks


When to Use This Skill

  • Setting up HubSpot webhook handlers
  • Verifying X-HubSpot-Signature-v3 headers
  • Debugging signature verification failures
  • Handling CRM events like contact creation, property changes, or deal events
  • Migrating from HubSpot signature v1/v2 to v3

Essential Code (USE THIS)

HubSpot does not provide an SDK helper for webhook signature verification, so verification is implemented manually with HMAC-SHA256 and base64 across all frameworks.

HubSpot Signature Verification (JavaScript)

const crypto = require('crypto');

const MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes

/**
 * Verify HubSpot v3 webhook signature.
 *
 * Signed content = HTTP method + request URI + raw body + timestamp
 * Signature is HMAC-SHA256 (base64) of that string using the app's Client Secret.
 */
function verifyHubSpotWebhook({ method, uri, rawBody, timestamp, signature, secret }) {
  if (!signature || !timestamp || !secret) return false;

  // Reject stale requests (older than 5 minutes)
  const ts = Number(timestamp);
  if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > MAX_AGE_MS) return false;

  const body = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody;
  const signedContent = `${method}${uri}${body}${timestamp}`;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedContent, 'utf8')
    .digest('base64');

  try {
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  } catch {
    return false;
  }
}

Express Webhook Handler

const express = require('express');
const app = express();

// CRITICAL: Use express.raw() - HubSpot requires raw body for HMAC verification
app.post('/webhooks/hubspot',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-hubspot-signature-v3'];
    const timestamp = req.headers['x-hubspot-request-timestamp'];

    // Reconstruct the full request URI (HubSpot signs the URL it called)
    const uri = `${req.protocol}://${req.get('host')}${req.originalUrl}`;

    const valid = verifyHubSpotWebhook({
      method: req.method,
      uri,
      rawBody: req.body,
      timestamp,
      signature,
      secret: process.env.HUBSPOT_CLIENT_SECRET,
    });

    if (!valid) {
      console.error('HubSpot signature verification failed');
      return res.status(400).send('Invalid signature');
    }

    // HubSpot sends an array of events in each webhook
    const events = JSON.parse(req.body.toString());

    for (const event of events) {
      switch (event.subscriptionType) {
        case 'contact.creation':
          console.log('New contact:', event.objectId);
          break;
        case 'contact.propertyChange':
          console.log('Contact property changed:', event.objectId, event.propertyName);
          break;
        case 'deal.creation':
          console.log('New deal:', event.objectId);
          break;
        default:
          console.log('Unhandled event:', event.subscriptionType);
      }
    }

    res.status(200).send('OK');
  }
);

Python (FastAPI) Signature Verification

import hmac
import hashlib
import base64
import time

MAX_AGE_MS = 5 * 60 * 1000  # 5 minutes

def verify_hubspot_webhook(method: str, uri: str, raw_body: bytes,
                           timestamp: str, signature: str, secret: str) -> bool:
    if not signature or not timestamp or not secret:
        return False

    try:
        ts = int(timestamp)
    except ValueError:
        return False

    if abs(int(time.time() * 1000) - ts) > MAX_AGE_MS:
        return False

    body = raw_body.decode("utf-8")
    signed_content = f"{method}{uri}{body}{timestamp}"

    expected = base64.b64encode(
        hmac.new(secret.encode("utf-8"), signed_content.encode("utf-8"), hashlib.sha256).digest()
    ).decode("utf-8")

    return hmac.compare_digest(expected, signature)

For complete working examples with tests, see:

Common Event Types

HubSpot calls these subscriptionType values. Each webhook delivery contains an array of one or more event objects.

EventDescription
contact.creationA new contact was created
contact.propertyChangeA property on a contact changed
contact.deletionA contact was deleted
company.creationA new company was created
company.propertyChangeA property on a company changed
deal.creationA new deal was created
deal.propertyChangeA property on a deal changed
ticket.creationA new ticket was created

For full event reference, see HubSpot Webhooks API.

Environment Variables

HUBSPOT_CLIENT_SECRET=your_app_client_secret   # From your HubSpot app settings

The signing key is your App's Client Secret (sometimes called Application Secret), not a private app token.

Signature Versions

HubSpot has shipped three signature versions:

  • v1 (X-HubSpot-Signature) - SHA-256 of clientSecret + body. Deprecated.
  • v2 (X-HubSpot-Signature, X-HubSpot-Signature-Version: v2) - SHA-256 of clientSecret + method + URI + body. Deprecated.
  • v3 (X-HubSpot-Signature-v3, requires X-HubSpot-Request-Timestamp) - HMAC-SHA256 (base64) of method + URI + body + timestamp. Use this.

New integrations should use v3 only. A v4 webhooks API is in beta on HubSpot's new developer platform but uses different mechanics; pin to v3 for stability.

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 hubspot --path /webhooks/hubspot

Then paste the Hookdeck URL into your HubSpot app's webhook settings as the target URL.

Reference Materials