DEV Community

Cover image for HubSpot Webhook Signatures: Verifying v3 Without Mystery 401s
Moksh Gupta
Moksh Gupta

Posted on Originally published at devtoollab.com

HubSpot Webhook Signatures: Verifying v3 Without Mystery 401s

HubSpot's v3 webhook check fits in one line: join the method, the full URL, the raw body and the X-HubSpot-Request-Timestamp value, HMAC that string with SHA-256 and your app's client secret, Base64 the digest, and compare it to X-HubSpot-Signature-v3 in constant time. Anything with a timestamp more than 5 minutes old gets rejected.

What makes it annoying is that every mistake looks identical. Hash a slightly different URL, or a body that went through JSON.parse and back, and you get a different digest and a bare 401. I put together a longer walkthrough on DevToolLab where a Node.js verifier was tested against signatures from a separate Python signer. Here is the short version.

Verifying HubSpot webhook signatures

Checking One by Hand First

Before writing code, concatenate POST, the URL, the body and the timestamp yourself and drop the result into the HMAC Generator, set to SHA-256 with Base64 output and your client secret as the key. If the output matches the X-HubSpot-Signature-v3 header, your understanding of the recipe is right. The Webhook Signature Verifier ships presets for Stripe, GitHub and Shopify but not HubSpot, so with that tool you use the generic HMAC mode and paste the concatenated string as the payload. Neither tool sends your secret anywhere; both compute in the browser.

Three Signature Versions, One Worth Using

HubSpot's request validation docs, checked October 9, 2026, describe three schemes. For the two older ones, X-HubSpot-Signature-Version says which you received. The developer platform version of that page repeats the same v3 steps.

Version Header Input Used for
v1 X-HubSpot-Signature SHA-256 of client secret + body CRM object event subscriptions from the webhooks API
v2 X-HubSpot-Signature SHA-256 of client secret + method + URI + body workflow webhook actions, custom CRM cards
v3 X-HubSpot-Signature-v3 Base64 HMAC SHA-256 of method + URI + body + timestamp current version, sent with X-HubSpot-Request-Timestamp

When the v3 header is on the request, verify that one. It is the only scheme that is an actual HMAC and the only one that carries a timestamp, so it is all this post covers.

A v3 Verifier in Node.js

One step in HubSpot's instructions is easy to skip. The URI has to have 12 specific percent-encoded sequences decoded before hashing: %3A, %2F, %3F, %40, %21, %24, %27, %28, %29, %2A, %2C and %3B. The literal ? that begins the query string is left alone. This verifier handles the decoding, the 5-minute window and a length check before the constant-time compare:

import crypto from "node:crypto"

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

export function decodeUri(uri) {
  return uri.replace(/%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)/g, (m) => DECODE[m])
}

export function verifyV3({ secret, method, uri, rawBody, timestamp, signature, now = Date.now() }) {
  if (!signature || !timestamp) return false
  if (Math.abs(now - Number(timestamp)) > 5 * 60 * 1000) return false

  const source = method + decodeUri(uri) + rawBody + timestamp
  const expected = crypto.createHmac("sha256", secret).update(source, "utf8").digest("base64")

  const a = Buffer.from(expected)
  const b = Buffer.from(signature)
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}
Enter fullscreen mode Exit fullscreen mode

Wiring it into Express 5 comes down to one choice: take the body as raw bytes with express.raw, since those bytes are what HubSpot signed.

import express from "express"
import { verifyV3 } from "./verify.mjs"

const app = express()

app.post("/webhooks/hubspot", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyV3({
    secret: process.env.HUBSPOT_CLIENT_SECRET,
    method: req.method,
    uri: `https://${req.hostname}${req.originalUrl}`,
    rawBody: req.body.toString("utf8"),
    timestamp: req.get("X-HubSpot-Request-Timestamp"),
    signature: req.get("X-HubSpot-Signature-v3"),
  })
  if (!ok) return res.sendStatus(401)

  const events = JSON.parse(req.body)
  res.sendStatus(200)
})

app.listen(4040)
Enter fullscreen mode Exit fullscreen mode

Testing Against a Signer Written in Another Language

If the same code signs and verifies, a shared bug passes every test. So the signatures here came from Python's hmac module rather than Node's crypto:

import base64, hashlib, hmac, sys

secret, method, uri, ts = sys.argv[1:5]
body = sys.stdin.read()
for enc, dec in {"%3A": ":", "%2F": "/", "%3F": "?", "%40": "@", "%21": "!", "%24": "$",
                 "%27": "'", "%28": "(", "%29": ")", "%2A": "*", "%2C": ",", "%3B": ";"}.items():
    uri = uri.replace(enc, dec)
src = (method + uri + body + ts).encode("utf-8")
print(base64.b64encode(hmac.new(secret.encode(), src, hashlib.sha256).digest()).decode())
Enter fullscreen mode Exit fullscreen mode

The test used test-client-secret as the secret and a single contact.creation event shaped like the sample in HubSpot's docs. The Node side reported:

1 valid (python-signed, node-verified): true
2 tampered body: false
3 timestamp 6 min old: false
3b timestamp 4 min old: true
4 wrong secret: false
5 decoded uri: https://api.example.com/webhooks/hubspot?email=ann@example.com&tag=a,b
Enter fullscreen mode Exit fullscreen mode

Sending the same requests through curl to the running Express server gave 200 for the valid one and 401 for an edited body, a dropped header, and a request carrying the wrong Host.

Debugging a 401, in Order

Verification stops at the first check that fails, so walk the checks in the same order: headers present, timestamp fresh, source string built, HMAC equal.

The v3 check order: headers present, timestamp within 5 minutes, build the string, HMAC matches, then process, with a 401 exit after the header, timestamp and HMAC checks

  • The body was re-serialized. HubSpot's own Node.js sample runs bodyParser.json() and then hashes JSON.stringify(body), which only works when the original bytes happen to match JSON.stringify output. A body with a space after each comma verified from its raw bytes and failed after a parse and re-stringify.
  • The URI is not the one HubSpot called. Use the public https hostname, the same way the docs example does. Receiving the request as localhost instead of api.example.com broke verification in testing, and a reverse proxy that rewrites Host does the same thing in production.
  • The 12 sequences were left encoded. ann%40example.com must turn into ann@example.com before hashing. Other percent-encodings stay as they are.
  • timingSafeEqual threw instead of returning false. The docs sample passes the buffers straight to crypto.timingSafeEqual, and Node throws RangeError: Input buffers must have the same byte length if the header length differs from the digest. A truncated or junk header then takes down the handler rather than producing a 401. Check lengths first.
  • The clock is off. Requests older than 5 minutes are supposed to be refused, so a server that has drifted past that refuses all of them. Look at NTP before you look at the HMAC.

The original post goes through the same list with the test result behind each item, plus browser tools for capturing a live request and decoding its URL.

What a Valid Signature Does Not Prove

Passing v3 means HubSpot sent the request and nobody changed it in transit. It does not mean you have not seen it before: a captured request still verifies for up to 5 minutes. Store the eventId values you have already handled and skip repeats. The client secret also has to stay on the server, since it is the only thing separating your endpoint from anyone who finds the URL.

Wrap-Up

Prefer the v3 header. Hash method + decoded URI + raw body + timestamp with HMAC SHA-256, Base64 the result, check lengths, then compare in constant time. Take the body as bytes and the hostname from your public URL. When a request still fails, paste the exact string you hashed into an HMAC tool and compare its output to the header; whatever differs in that string is your bug.

References

Top comments (0)