Skip to content

CRM

How HubSpot signs webhooks

HubSpot's v3 signature is a base64 HMAC-SHA256 over method + URL + body + timestamp, keyed with the app's client secret; requests older than 5 minutes should be rejected.

Checked against HubSpot's documentation on October 31, 2026. HubSpot webhook docs

How the signature works

HeaderX-HubSpot-Signature-v3: {signature}
Signed content{method}{full URL}{raw body}{X-HubSpot-Request-Timestamp}
AlgorithmHMAC-SHA256
Encodingbase64
KeyThe app's client secret, used as text
Where to find itThe app's client secret (developer account → your app → Auth).
Replay protectionReject requests whose X-HubSpot-Request-Timestamp (milliseconds) is more than 5 minutes old.
  • Before hashing, decode %3A %2F %3F %40 %21 %24 %27 %28 %29 %2A %2C %3B in the URL (not the ? that starts the query).
  • Older v1/v2 signatures (X-HubSpot-Signature) are plain SHA-256 hashes of secret + body (v1) or secret + method + URL + body (v2).

verified Source: developers.hubspot.com/docs/apps/legacy-apps/authentication/validating-requests

Verification code

Generated from the scheme above and tested against a signed example request, a changed body and a wrong secret. Pass the raw request body, before any JSON or form parsing.

const crypto = require('node:crypto');

const TOLERANCE_SECONDS = 300;

/**
 * Verify a HubSpot webhook. rawBody: the request body as a Buffer, exactly as received
 * (before any JSON parsing); headers: lower-case names, as in req.headers.
 */
function verifyWebhook(rawBody, headers, secret, url = '', method = 'POST') {
  rawBody = Buffer.from(rawBody);
  const header = headers['x-hubspot-signature-v3'] || '';
  const signatures = [header.trim()];
  const ts = headers['x-hubspot-request-timestamp'] || '';
  url = url.replace(/%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)/gi, (e) => decodeURIComponent(e));
  const signed = Buffer.concat([Buffer.from(method.toUpperCase() + url), rawBody, Buffer.from(ts)]);
  const key = secret;
  const expected = crypto.createHmac('sha256', key).update(signed).digest('base64');
  const valid = signatures.some((sig) => {
    const a = Buffer.from(sig);
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
  if (!valid) return false;
  const signedAt = Number(ts) / 1000;
  return Number.isFinite(signedAt) && Math.abs(Date.now() / 1000 - signedAt) <= TOLERANCE_SECONDS;
}

// Express: keep the raw body for this route
// app.post('/webhooks/hubspot', express.raw({ type: '*/*' }), (req, res) => {
//   if (!verifyWebhook(req.body, req.headers, process.env.HUBSPOT_WEBHOOK_SECRET, `https://${req.headers.host}${req.originalUrl}`, req.method)) return res.sendStatus(401);
//   res.sendStatus(200); // then process the event asynchronously
// });
import base64
import hashlib
import hmac
import re
import time

TOLERANCE_SECONDS = 300


def verify_webhook(raw_body: bytes, headers, secret: str, url: str = '', method: str = 'POST') -> bool:
    """Verify a HubSpot webhook. raw_body: the request body exactly as received (bytes);
    headers: a mapping with lower-case names (Flask and Django headers are case-insensitive)."""
    header = headers.get('x-hubspot-signature-v3', '')
    signatures = [header.strip()]
    ts = headers.get('x-hubspot-request-timestamp', '')
    url = re.sub(r'%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)', lambda m: bytes.fromhex(m.group(1)).decode(), url, flags=re.I)
    signed = (method.upper() + url).encode() + raw_body + (ts).encode()
    key = secret.encode()
    digest = hmac.new(key, signed, hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode()
    if not any(hmac.compare_digest(sig, expected) for sig in signatures):
        return False
    try:
        signed_at = int(ts) / 1000
    except ValueError:
        return False
    return abs(time.time() - signed_at) <= TOLERANCE_SECONDS


# Flask: verify_webhook(request.get_data(), request.headers, SECRET, request.url, request.method)
<?php

const TOLERANCE_SECONDS = 300;

/**
 * Verify a HubSpot webhook. $rawBody: file_get_contents('php://input'), before any decoding;
 * $headers: lower-case names, e.g. array_change_key_case(getallheaders()).
 */
function verify_webhook(string $rawBody, array $headers, string $secret, string $url = '', string $method = 'POST'): bool
{
    $header = (string) ($headers['x-hubspot-signature-v3'] ?? '');
    $signatures = [trim($header)];
    $ts = (string) ($headers['x-hubspot-request-timestamp'] ?? '');
    $url = preg_replace_callback('/%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)/i', fn ($m) => rawurldecode($m[0]), $url);
    $signed = strtoupper($method) . $url . $rawBody . $ts;
    $key = $secret;
    $expected = base64_encode(hash_hmac('sha256', $signed, $key, true));
    $valid = false;
    foreach ($signatures as $sig) {
        $valid = $valid || hash_equals($expected, $sig);
    }
    if (! $valid) {
        return false;
    }
    $signedAt = (int) $ts / 1000;

    return $signedAt > 0 && abs(time() - $signedAt) <= TOLERANCE_SECONDS;
}
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"math"
	"net/http"
	neturl "net/url"
	"regexp"
	"strconv"
	"strings"
	"time"
)

const toleranceSeconds = 300

// VerifyWebhook checks a HubSpot webhook. rawBody is the request body exactly as received
// (io.ReadAll(r.Body) before any decoding).
func VerifyWebhook(rawBody []byte, h http.Header, secret, url, method string) bool {
	header := h.Get("X-HubSpot-Signature-v3")
	signatures := []string{strings.TrimSpace(header)}
	ts := h.Get("x-hubspot-request-timestamp")
	url = regexp.MustCompile(`(?i)%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)`).ReplaceAllStringFunc(url, func(e string) string { s, _ := neturl.PathUnescape(e); return s })
	signed := []byte{}
	signed = append(signed, strings.ToUpper(method)...)
	signed = append(signed, url...)
	signed = append(signed, rawBody...)
	signed = append(signed, ts...)
	key := []byte(secret)
	mac := hmac.New(sha256.New, key)
	mac.Write(signed)
	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
	valid := false
	for _, sig := range signatures {
		if hmac.Equal([]byte(sig), []byte(expected)) {
			valid = true
		}
	}
	if !valid {
		return false
	}
	n, err := strconv.ParseFloat(ts, 64)
	if err != nil {
		return false
	}
	signedAt := n / 1000
	return math.Abs(float64(time.Now().Unix())-signedAt) <= toleranceSeconds
}

Headers HubSpot sends

HeaderMeaning
X-HubSpot-Signature-v3base64 HMAC-SHA256
X-HubSpot-Request-TimestampUnix time in milliseconds
X-HubSpot-Signaturev1/v2 SHA-256 hash
X-HubSpot-Signature-Versionv1 or v2

Timeouts and retries

Response timeout5 seconds per batch of notifications.
Counts as success2xx.
RetriesUp to 10 retries spread over the next 24 hours with varying delays, after connection failures, timeouts and 4xx or 5xx responses.
OrderingNot guaranteed; use occurredAt.
DuplicatesPossible; use eventId.
DisablingNot documented.

verified Source: developers.hubspot.com/docs/api-reference/legacy/webhooks/guide

Event types

The event is in subscriptionType of each item in the JSON array. Examples:

contact.creationcontact.propertyChangedeal.creationcompany.deletion

verified Source: developers.hubspot.com/docs/api-reference/legacy/webhooks/guide

Source IP addresses

HubSpot doesn't publish the addresses it sends webhooks from, so verify the signature instead.

No webhook source addresses are listed in the webhooks guide.

An allow-list only filters traffic: proxies, CDNs and shared cloud addresses mean the signature is still the real proof of origin.

Common mistakes

  • Hashing the URL your framework sees behind a proxy instead of the public URL HubSpot called.
  • Forgetting the URL-decoding step for the listed characters.
  • Treating the timestamp as seconds: it is milliseconds.
  • Using v1/v2 logic on v3 headers (or the other way round).

Stuck? Point the webhook at a free webhook inspector bin: it detects HubSpot, checks the signature with your secret in the browser and shows the expected value next to the received one.

Not confirmed in HubSpot's documentation

  • This describes legacy (public) apps; apps on HubSpot's new developer platform document their own validation guide.
  • The User-Agent shown is an example.

FAQ

How do I verify a HubSpot webhook signature?

Compute HMAC-SHA256 over {method}{full URL}{raw body}{X-HubSpot-Request-Timestamp} using app client secret, encode it as base64 and compare it in constant time with the value in X-HubSpot-Signature-v3 (format: {signature}). Then reject requests whose timestamp is more than 5 minutes away from your clock.

How long does HubSpot wait for a webhook response?

5 seconds per batch of notifications. Success means: 2xx.

Does HubSpot retry failed webhooks?

Up to 10 retries spread over the next 24 hours with varying delays, after connection failures, timeouts and 4xx or 5xx responses.

What IP addresses does HubSpot send webhooks from?

No. No webhook source addresses are listed in the webhooks guide.

Why does my HubSpot signature check fail?

Hashing the URL your framework sees behind a proxy instead of the public URL HubSpot called. Forgetting the URL-decoding step for the listed characters.

Other webhook providers

Stripe GitHub GitLab Bitbucket Cloud Shopify Slack Twilio Paddle Billing Zoom Linear Square PayPal SendGrid Mailgun Discord Svix Standard Webhooks Twitch EventSub Intercom Jira Cloud Calendly Typeform